Metadata-Version: 2.5
Name: truesight-sdk
Version: 0.5.0
Summary: Server-side Python SDK for TrueSight analytics ingestion
Project-URL: Homepage, https://github.com/komorebitech/cf-truesight
Project-URL: Source, https://github.com/komorebitech/cf-truesight/tree/master/sdks/python
Project-URL: Issues, https://github.com/komorebitech/cf-truesight/issues
Author-email: Cityflo Engineering <tech@cityflo.com>
License-Expression: MIT
Keywords: analytics,cityflo,event-tracking,truesight
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Classifier: Typing :: Typed
Requires-Python: >=3.10
Requires-Dist: requests>=2.28
Requires-Dist: urllib3>=2.0
Provides-Extra: dev
Requires-Dist: mypy>=1.10; extra == 'dev'
Requires-Dist: pytest>=8.0; extra == 'dev'
Requires-Dist: responses>=0.25; extra == 'dev'
Requires-Dist: ruff>=0.6; extra == 'dev'
Requires-Dist: types-requests; extra == 'dev'
Description-Content-Type: text/markdown

# truesight-sdk

Server-side Python SDK for [TrueSight](https://github.com/komorebitech/cf-truesight) analytics ingestion.

Designed for backends that emit events on behalf of authenticated users (Django, Flask, FastAPI, Airflow DAGs, management commands). Sync by design — if you need async, wrap calls in your own task queue.

## Installation

```bash
pip install truesight-sdk
```

Python 3.10+.

## Quick Start

### Single event

```python
from truesight_sdk import TrueSightClient

client = TrueSightClient(
    api_key="ts_server_live_...",
    base_url="https://ingest.truesight.example.com",
)

client.track(
    event_name="Purchased Lite Pack",
    user_id=str(customer.pk),
    properties={"plan_slug": "5-30-days", "amount": 1200},
)
```

### User profile update (identify)

```python
client.identify(
    user_id=str(customer.pk),
    email=customer.email,
    properties={
        "home_locality": "Andheri",
        "favourite_route": "M1",
        "weekly_subscription_active": True,
    },
)
```

This upserts the user's row in `truesight.user_profiles` and also appends a `$identify` event to the stream for history.

> **Identify merges into the profile — it does not replace it.** The server upserts `user_profiles` field-wise: any field you omit (`email`, `name`, `mobile_number`, or any key in `properties`) is left untouched, and `properties` is merged key-wise (new values win). So you can send just the fields you want to change. To explicitly *clear* a value, list it in a `$unset` array inside `properties`, e.g. `properties={"$unset": ["category"]}` (promoted fields match case-insensitively; custom property keys are matched in their canonical `snake_case` form, see Naming below).

> **Naming.** Event names, property keys and trait keys are stored in `snake_case`; the SDK canonicalises them in `as_payload()` with the same rule the server uses (`truesight_sdk.to_snake_case`): `"Purchased Lite Pack"` → `purchased_lite_pack`, `"cartValue"` → `cart_value`, `"Home Hub"` → `home_hub`. A leading `$` is kept, values are never touched. Rewritten names are logged at `DEBUG` on the `truesight_sdk` logger. Prefer canonical names in new code.

### Bulk profile updates (`identify_bulk`)

For a one-shot backfill — e.g. *"set the email on every customer that's missing one"* — use `identify_bulk`. It takes any iterable of `IdentifyEvent`, streams it lazily (only `chunk_size` ops in memory at a time), and auto-chunks to the server's 500-per-call cap. Because the server merges, you only send the fields you're changing:

```python
from truesight_sdk import IdentifyEvent, TrueSightClient

client = TrueSightClient(api_key="ts_server_live_...", base_url="...")

# Fill in just the email — name/mobile/properties already on each profile are preserved.
ops = (
    IdentifyEvent(
        user_id=str(c.pk),
        email=c.email,
        event_id=stable_uuid_for(c),   # optional: makes re-runs idempotent
    )
    for c in customers.iterator()      # a generator/QuerySet streams; nothing is buffered whole
)
sent = client.identify_bulk(ops)       # returns the number of profiles sent
```

`identify_bulk` is **not transactional** — chunks POST sequentially, so if one chunk fails the earlier ones are already applied. Pass a stable `event_id` per op so a re-run safely converges (retries collapse to the same row).

For continuous, interleaved bulk emission (long-running jobs that mix `track` and `identify`), prefer `BatchingClient` below; for a finite list to push once, `identify_bulk` is simpler.

### Batched ingestion (Airflow / cron / bulk syncs)

For workloads that emit many events in a tight loop, use `BatchingClient` to amortize HTTP overhead:

```python
from truesight_sdk import BatchingClient, TrueSightClient

inner = TrueSightClient(api_key="ts_server_live_...", base_url="...")

with BatchingClient(inner, batch_size=100, flush_interval_seconds=5.0) as batcher:
    for customer in qs.iterator():
        batcher.identify(
            user_id=str(customer.pk),
            properties=build_profile(customer),
        )
# Buffers drain on context exit.
```

`BatchingClient` is thread-safe; multiple producer threads can call `track()` / `identify()` concurrently.

### Reading events (admin queries)

The SDK also wraps TrueSight's admin event-query endpoint for read-path consumers:

```python
from truesight_sdk import AdminQueryClient

reader = AdminQueryClient(admin_token="...", base_url="https://admin.truesight.example.com")

page = reader.fetch_events(
    project_id="b219fb11-9a63-4843-8126-a3dc05b330a5",
    event_name="Purchased Lite Pack",
    from_=datetime(2026, 5, 1, tzinfo=timezone.utc),
    to=datetime(2026, 5, 28, tzinfo=timezone.utc),
    limit=500,
)

# Fetch a single user's profile + stats (email, name, properties,
# first_seen, last_seen, event_count). Raises `NotFound` (404) if the
# user_uid has never been seen by ingest.
profile = reader.fetch_profile(
    project_id="b219fb11-9a63-4843-8126-a3dc05b330a5",
    user_uid="42",
)
```

## API Keys

Server keys are scoped — they can only call `/v1/server/*` endpoints. Issue one via the CLI:

```bash
truesight projects api-keys create --scope server --label "<your service>"
```

The plaintext key is returned **once** at creation time. Store it in your secrets manager.

## Error Handling

All errors subclass `TrueSightError`:

```python
from truesight_sdk import (
    AuthError,        # 401 — bad / revoked key
    Forbidden,        # 403 — wrong scope for this endpoint
    ValidationError,  # 400/422 — payload rejected
    RateLimited,      # 429 — slow down (after SDK's own retry budget)
    ServerError,      # 5xx — TrueSight is degraded (after retries)
    TrueSightError,   # base class — catch this to handle any SDK error
)

try:
    client.track("x", user_id="42")
except RateLimited:
    schedule_retry(...)
except TrueSightError as exc:
    log.exception("truesight ingest failed", request_id=exc.request_id)
```

Every error carries `status_code`, `request_id` (when the server returned one), and `response_body` for log correlation.

## Retry Idempotency

The SDK auto-generates a fresh `event_id` (UUIDv4) for every event when one isn't supplied. If you need retry idempotency (e.g. inside a Celery task that may run twice), pass an explicit `event_id` stable across retries:

```python
from truesight_sdk import TrackEvent
from uuid import uuid5, NAMESPACE_URL

stable_id = uuid5(NAMESPACE_URL, f"booking-confirmed:{booking.pk}")
client.track_batch([
    TrackEvent(
        event_name="Booking Confirmed",
        user_id=str(booking.customer_id),
        event_id=stable_id,
        properties={"booking_id": booking.pk},
    ),
])
```

The server dedups on `(project_id, event_id)` via ClickHouse's `ReplacingMergeTree`, so retries with the same id collapse to a single row.

### Attributing a server event to a client platform

Server-emitted events land under `platform = "server"` unless you say which
client the action came from. For transactional events the backend owns
(bookings, purchases, cancellations) pass the platform of the originating
request so dashboards still split them by platform:

```python
TrackEvent(
    event_name="completed_booking",
    user_id=str(user.pk),
    event_id=stable_id,
    platform=request.headers.get("Platform-Name"),  # "Apple" / "Android" / None
    app_version=request.headers.get("App-Version"),
    properties={...},
)
```

The server canonicalises the value (`"Apple"` → `"ios"`, `"Android"` →
`"android"`, blank → `"server"`); `os_name` defaults to match. `source` stays
`"server"`, so "which platform" and "which producer" remain separate columns.

## Development

```bash
# Install in dev mode
pip install -e ".[dev]"

# Run unit tests (no network)
pytest

# Run integration tests against a local TrueSight stack
export TRUESIGHT_BASE_URL=http://localhost:8080
export TRUESIGHT_API_KEY=ts_server_test_...
pytest -m integration

# Lint + typecheck
ruff check src/ tests/
mypy src/
```

## Versioning

Semver. `0.x` is pre-1.0 — the public API may shift between minor versions; pin the minor version until `1.0`.

## License

MIT — see top-level repo.
