Python SDK

Background capture plus an optional async direct client.

Background capture API (initialize / capture_event / shutdown) plus an optional async ChurnWarnClient for direct posts. Requires Python 3.9+ and httpx.

Install

cd sdks/churn_warn_python_sdk
pip install -e .

Call initialize once, then capture_event from anywhere; events are queued and sent on a background thread to POST /api/events/batch.

from churn_warn_sdk import (
  ChurnWarnOptions, Metrics, RawEvents, RecordEventInput,
  capture_event, initialize, shutdown,
)

initialize(ChurnWarnOptions(
  base_url="https://your-gateway.example.com",
  api_token="your-jwt",        # or api_key="..." for X-Api-Key
  default_source="python_sdk",
  batch_size=50,
  flush_interval_seconds=5.0,
  max_queue_size=10_000,
  on_send_error=lambda e: print("send failed", e),
))

capture_event("acct-1", Metrics.LOGIN, payload={"path": "/"})
capture_event(RecordEventInput(
  external_account_id="acct-2", event_type=RawEvents.APP_LOGIN, payload={"x": 1},
))

shutdown()

Auth

  • api_keyX-Api-Key header.
  • api_tokenAuthorization: Bearer … (a Bearer prefix is stripped).

Options

OptionNotes
batch_size / flush_interval_secondsFlush triggers, capped at 500 events per request.
max_queue_sizeWhen full, the event is dropped and on_send_error is invoked.
redact_payloadDefault True — masks sensitive patterns before enqueue.
on_before_enqueueOptional hook to transform events before queueing.

Batching & tenant rules

At most 500 events (MAX_EVENTS_PER_BATCH) per request; larger lists split automatically in the async client. If any event sets tenant_id, every non-null tenant_id in the batch must match — it's sent once as the batch-level tenantId.

Errors

  • Capture path: failures go to on_send_error; the sender raises ChurnWarnApiError on non-success HTTP.
  • Async client: non-success HTTP raises ChurnWarnApiError (status_code, body).

Prompt starters

Hand these to an AI coding assistant (Claude Code, Cursor, Copilot Chat) to wire the SDK into your app. Edit the placeholders first, then paste.

Integrate the Python SDK
Add the churn_warn_sdk to this Python app (Django / Flask / FastAPI / worker).

How it works: call initialize(ChurnWarnOptions(...)) once at startup, then
capture_event(external_account_id, event_type, payload={...}) from anywhere. It queues and
flushes on a background thread to POST /api/events/batch. Requires Python 3.9+ and httpx.

Please:
1. initialize() once at startup. Read config from the environment: api_key = CHURNWARN_API_KEY
 (X-Api-Key) or api_token = CHURNWARN_TOKEN (Bearer), base_url = the gateway URL,
 default_source = "<this-service-name>".
2. Wire on_send_error to our logger so dropped/failed sends are observable.
3. Call shutdown() in the app's graceful-shutdown path so the queue drains before exit.
4. Use the account id from our own database as external_account_id (the tenant/org id, NOT the
 per-user id), and keep payloads to a few small fields.
5. Import and use the Metrics and RawEvents constants instead of raw strings.

Leave redact_payload at its default (True). capture_event does not block — don't try/except it.

Pick one value for external_account_id and use it everywhere — changing the id later splits an account's history in two.

Capture the default-dashboard signals
Instrument the business signals that feed ChurnWarn's default Product Health dashboard by
adding capture_event(external_account_id, <metric>, payload=<dict>) calls in this codebase.
The browser SDK already auto-captures usage, sessions, clicks, frustration and JS errors —
do NOT re-send those from the server. Add only the signals below, each at the call site
described:

- Metrics.LOGIN — in the authentication success path, right after the session/token is issued
(the same place you already record a successful sign-in). payload={"external_user_id": the
signed-in user's id}.
- Metrics.ACTIVE_USER — once per user per day. Add it to auth middleware and guard it with a
cheap per-user-per-day check (cache/Redis/DB flag) so it fires at most once a day per user.
payload={"external_user_id": ...}. Drives the distinct-active-users count.
- Metrics.ONBOARDING_COMPLETED — where the onboarding/setup flow marks itself done (the final
step handler, or where you flip an "onboarded" flag). Fire once per account.
- Metrics.FEATURE_USED — in the handler of a high-value action (report generated, export run,
integration connected), not on every request. payload={"feature": "<stable-short-name>"}.
- Metrics.SESSION — where a session ends / on logout, if you track session length. Skip it
entirely if you have no session concept. payload={"session_minutes": <number>}.
- Metrics.SEAT_USED and Metrics.SEAT_PURCHASED — in your billing/subscription code where seat
counts can change (a subscription-updated webhook, a member invited/removed, a plan change).
Emit BOTH with current counts. payload={"count": <int>} on each (utilization = used ÷ purchased).
- Metrics.SEAT_EXPANDED / Metrics.PLAN_UPGRADED — in that same billing path, when seats
increase or the plan moves to a higher tier.
- Metrics.REFERRAL — where a referral is recorded as successful (accepted/converted) in your
invite/referral flow.
- Metrics.NPS_RESPONSE / Metrics.CSAT_RESPONSE — in your survey-submission handler or survey
webhook. payload={"score": <0-10 for NPS / 1-5 for CSAT>}.
- Metrics.SUPPORT_TICKET_OPENED / RESOLVED / NEGATIVE — in your ticket lifecycle / support
webhook, ONLY if you do NOT use a Zendesk/Intercom/Freshdesk integration (those map
automatically). payload on resolved: {"resolution_hours": <number>}.

Always pass our own DB account id (the tenant/organization id) as external_account_id.

Make these changes non-breaking — they must be pure additive telemetry:
- Add each call AFTER the existing operation has fully succeeded (after the DB commit / after
the response is decided). Never change control flow, return values, function signatures, or
business logic.
- capture_event is fire-and-forget and does not block — do NOT wrap surrounding code in new
try/except for it, and do NOT let it block the request path.
- Don't add new blocking I/O; the SDK buffers and sends on a background thread. Reuse the
existing initialize() — do not re-initialize.
- If you can't confidently find the correct call site for a signal, SKIP it and leave a
# TODO: ChurnWarn <metric> here comment instead of guessing. Omitting a signal is fine;
firing it from the wrong place is not.
- Keep each edit small and reviewable, and keep payloads to the documented fields only.

Treat the agent's output as a draft: review every inserted call site to confirm it fires only after success and with the right account id, run your tests (especially auth, billing and onboarding), and ship to a dev/staging tenant first — watch the events land on the account's score breakdown before going live. Contract value, days-to-renewal and tenure read the account's MRR, renewal date and created date — set those on the account record, not as events. See the default components reference.

Privacy

Helpers: redact_payload, safe_url_string, mask_sensitive_text.

On by default
Every SDK ships with payload redaction enabled(redact_payload / redactPayload). Sensitive values are masked in-process before an event is queued, so unmasked data never leaves your application. Masking is bounded, never throws, and falls back to a safe *** on any error.

Values detected and masked inside strings

PatternBecomes
Email addressesj***e@example.com (first/last char kept)
Credit-card numbers****-****-****-1234 (last 4 kept)
Phone numbers415****6789
US SSN-like values***-**-****
JWTs (eyJ…header.payload.sig)eyJhbGciOi...***
API keys / bearer tokens (key: value)abcd*** (first 4 kept)
Passwords embedded in URLsuser:********@host
IBANsDE89***6789

Keys always replaced with ***

passwordsecrettokenapi_keyauthorizationcookiessncredit_card

Any key containing one of these words has its value redacted. Keys named url / referrer (or ending in url) keep only their path — query strings and hash fragments are stripped.

Practices that matter more than masking
  • Send path or route instead of full URLs in server-side payloads.
  • Never use an email or username as external_account_id — use a stable non-PII id.
  • Masking is a safety net, not a license to send secrets. Keep payloads minimal.