Theme
Python SDK
Server-side product analytics for the Tangent collector. Python 3.9+, no third-party dependencies — HTTP is urllib from the standard library and batching is one daemon thread.
1. What you get
A client you build once per process and share. track() never blocks and never raises: it puts the event on a bounded in-memory queue, and a background thread batches, splits, retries and delivers it. Delivery problems reach you through a logger and an on_error callback, never through an exception in your request path.
python
from selica_analytics import Client
selica = Client(api_key="pk_live_xxx", server_url="https://collect.tangentlens.com/collector")
selica.track("report_exported", user_id="user-1042", properties={"format": "csv", "rows": 1240})
selica.flush()Use a secret key (sk_live_…) from a server. pk_ keys work, but they are meant for browsers and mobile apps and are restricted to registered origins. The examples on this page use pk_live_xxx and https://collect.tangentlens.com/collector as placeholders — substitute your own.
2. Install
Nothing is published to PyPI yet. The commands below are marked accordingly.
Today: install from the repository
bash
# from a checkout of the repository
pip install ./sdk/pythonOr, without a checkout, straight from git — pinning the tag so a later commit cannot change under you:
bash
pip install "selica-analytics @ git+https://github.com/selicatech/[email protected]#subdirectory=sdk/python"In a requirements.txt:
selica-analytics @ git+https://github.com/selicatech/[email protected]#subdirectory=sdk/pythonFor local development against the SDK source, an editable install picks up your edits:
bash
pip install -e ./sdk/pythonThe package has no runtime dependencies, so nothing else is pulled in. pyproject.toml declares requires-python = ">=3.9".
Vendoring
The package is pure Python and self-contained. Copying sdk/python/selica_analytics/ into your own source tree works and is a legitimate choice for an air-gapped build. You then own updates.
Once published
These will work once the package is on PyPI. They fail today with No matching distribution found:
bash
pip install selica-analytics==2.2.0 # once published
poetry add [email protected] # once published
uv add selica-analytics==2.2.0 # once publishedPin an exact version. The SDK follows semantic versioning; a major bump is the only release that removes a deprecated alias.
3. Initialise
The minimum is a key:
python
from selica_analytics import Client
selica = Client(api_key="pk_live_xxx")With no server_url, the client resolves the collector from region (default US), which is https://collect.tangentlens.com/collector. Point it at your own collector with server_url.
A realistic production configuration:
python
import logging
import os
from selica_analytics import Client
log = logging.getLogger("myapp.analytics")
def on_selica_error(exc: Exception, context: str) -> None:
# context is one of: track, flush, worker, queue, oversize, min_id_length,
# rejected:<eventType>, plugin.<hook>:<plugin name>
log.warning("analytics %s: %s", context, exc)
selica = Client(
api_key=os.environ["SELICA_API_KEY"], # sk_live_… on a server
server_url="https://collect.tangentlens.com/collector",
flush_queue_size=200, # batch size and auto-flush trigger
flush_interval_millis=10_000,
max_retries=12,
max_post_bytes=40_000,
max_queue_size=10_000,
timeout_millis=10_000,
log_level="warn",
min_id_length=5, # match the collector, fail locally
headers={"X-Deploy": "blue"},
on_error=on_selica_error,
)selica.config is the resolved Config dataclass; it is what plugins receive in setup.
Build one client per process and share it. Each instance owns a queue, a thread and an atexit registration. A client per request leaks threads and loses whatever the previous one had buffered.
Verifying the key at start-up
python
if not selica.validate_key():
raise SystemExit(f"analytics key rejected: {selica.disabled_reason}")validate_key() returns False only on a definite 401/403 — and disables the client when it gets one. A network failure is not a verdict, so it returns True and leaves the client working. It waits at most shutdown_timeout_millis; no answer in time also returns True.
4. Configuration reference
Every keyword argument to Client(...). Defaults are the server profile.
| Option | Type | Default | What it controls |
|---|---|---|---|
api_key | str | — | Required. The key issued on the platform. Empty raises ValueError. |
server_url | str | region default | Collector base URL, e.g. https://collect.tangentlens.com/collector. /v1/events is appended. Overrides region. |
endpoint | str | None | Positional synonym for server_url. |
region | "US" | "EU" | "US" | Data residency. EU selects the EU collector so event data stays in the EU. Ignored when server_url is set. An unknown value raises ValueError. |
flush_queue_size | int | 200 | Events buffered before the worker is woken early, and the maximum events drained per batch. |
max_batch_size | int | None | Synonym for flush_queue_size. |
flush_interval_millis | int | 10000 | Milliseconds between automatic flushes. |
flush_interval | float | None | The same interval in seconds. flush_interval_millis wins if both are given. |
max_retries | int | 12 | Attempts per request before the batch is discarded. |
max_post_bytes | int | 40000 | Request bodies are split to stay under this. A single event larger than this can never be delivered, so it is reported (oversize) and skipped. |
max_queue_size | int | 10000 | Hard ceiling on buffered events. Past it, track() drops the oldest event to make room. |
timeout_millis | int | 10000 | Per-request socket timeout. |
timeout | float | None | The same timeout in seconds. |
log_level | str | int | "warn" | none/error/warn/info/debug. none skips the work entirely, whatever your logging config says. A bool raises ValueError. |
logger_provider | () -> Logger | None | Zero-argument factory returning an object with error/warn/info/debug. Route SDK output into your own logging stack. When set, the SDK does not apply log_level for you — your logger owns the gate. |
proxy | str | None | Proxy URL, e.g. http://proxy.internal:3128, applied to both http and https. |
headers | dict[str, str] | {} | Extra headers on every request. They are applied after the SDK's own, so a key named X-API-Key would override it — do not do that. |
opt_out | bool | False | Start with tracking off. track() records nothing. |
min_id_length | int | None | When set, an event whose userId/deviceId is shorter is dropped locally and reported. None leaves the check to the collector, which enforces 5. |
on_error | (Exception, str) -> None | None | Called for every delivery or plugin problem with the exception and a context string. Exceptions raised inside it are swallowed. |
dont_retry_status_codes | Iterable[int] | (400, 401, 403, 410, 422) | Statuses treated as permanent: the batch is abandoned rather than retried. |
plugins | Iterable[Plugin] | () | Plugins registered at construction, in order. |
experiments | bool | True | False disables variant() and the definitions fetch entirely. See Experiments. |
experiment_definitions | dict | None | A /config/experiments body supplied directly, skipping the fetch. Definitions given this way are authoritative and are never refetched. |
experiment_poll_interval_seconds | int | 300 | How long experiment definitions are cached. The collector's own pollIntervalSeconds wins once it has answered. |
server_zone | "US" | "EU" | None | Deprecated alias for region; honoured when region is absent, with a one-time warning. |
5. Tracking events
python
selica.track(
"report_exported",
user_id="user-1042",
properties={"format": "csv", "rows": 1240, "scope": "workspace"},
)Full signature:
python
selica.track(
event_type, # str, required
user_id=None, # str
properties=None, # dict
session_id=None, # str
event_time=None, # datetime; defaults to now, UTC
device_id=None, # str
user_properties=None, # dict of operations, or an Identify
groups=None, # dict, merged over the client's groups
revenue=None, # Revenue or dict
entities=None, # list of {"schema": ..., "data": ...}
device=None, # dict: platform, appVersion, osName, deviceModel
)Backdating an event — useful when replaying a queue or importing history:
python
from datetime import datetime, timezone
selica.track(
"invoice_paid",
user_id="user-1042",
event_time=datetime(2026, 9, 1, 10, 0, tzinfo=timezone.utc),
properties={"invoiceId": "inv-4410"},
)Any datetime is converted to UTC and serialised as RFC 3339. A naive datetime is interpreted in the machine's local timezone by astimezone() — pass an aware one.
What makes a good event name
snake_case, past tense, one name per thing that happened:project_created,invoice_paid,trial_started.- The name is the noun and verb, never the value.
project_createdwithproperties={"status": "archived"}, notproject_created_archived. - Keep the set of names small and closed. Names come from your code, not from user input — a name built from a variable produces unbounded cardinality that no report can group.
- The same rule applies to property values used as dimensions: a route template (
/users/{id}) groups; a raw path (/users/42) does not. $-prefixed names are reserved ($identify,$click,$exception, …), as isapi_request. Product metrics deliberately excludeapi_request.
Limits the collector enforces
Any string value ≤ 1024 characters; nesting ≤ 40 levels; a serialised event ≤ 1 MB; at most 5 group types. Breaking one of these does not fail the batch — the collector accepts the rest and lists the offender by index, which arrives at your on_error as rejected:<eventType>.
6. Identity
The collector needs at least one of userId / deviceId on every event, each at least 5 characters.
On a server, pass the user per event
A server SDK usually reports on behalf of many users, often concurrently. The client-level identity is a single shared field — setting it per request is a race, and the wrong user ends up on someone else's event.
python
# Right: the identity travels with the event.
selica.track("project_created", user_id=request.user.id, properties={"template": "blank"})python
# Wrong in any concurrent server: two requests interleave and overwrite each other.
selica.set_user_id(request.user.id)
selica.track("project_created", properties={"template": "blank"})set_user_id() is for a process that genuinely serves one identity: a CLI, a per-user worker, a notebook. Everywhere else, pass user_id= on the call. See Plugins for attaching per-request context safely with a contextvars plugin.
Anonymous events and the device id
Every client mints one stable id at construction, "srv-" + uuid4().hex, exposed as selica.device_id. When an event names no user, that id is sent as deviceId so the event is still valid. Every event from one process therefore shares one device id — this identifies the service instance, not a person. If you have a genuine anonymous end-user id (a browser id forwarded from your frontend), pass it as device_id= per event.
python
selica.track("page_viewed", device_id=request.cookies["anon_id"], properties={"path": "/pricing"})identify()
python
selica.identify("user-1042", {"plan": "pro", "company": "Northwind"})This sends a $identify event with the traits as a $set operation. It does not change the client's identity and does not send anything else.
What happens to history when someone signs in
Anonymous history joins to the known user through the device id: keep sending the same deviceId after the user signs in, and the collector stitches the two. Concretely, from a server that forwards a browser's anonymous id:
python
# before sign-in
selica.track("pricing_viewed", device_id=anon_id)
# at sign-in — same device id, now with a user
selica.identify("user-1042")
selica.track("signed_in", user_id="user-1042", device_id=anon_id)
# after
selica.track("project_created", user_id="user-1042", device_id=anon_id)Getting it wrong, exactly:
- New device id at sign-in. The anonymous session becomes an orphan. Funnels that start before sign-in break; the user looks like two people. Nothing errors.
- Reusing one device id across different people (for example, sending the process device id as if it were a person's). Their histories merge into one profile. This is not reversible from the SDK.
- Never sending a user id at all. Everything is attributed to the service instance and no per-user report is possible.
reset() on logout
python
selica.reset()Clears the user id, session id, groups and entities, and fires on_reset on every plugin. The Python client keeps its device_id across a reset — it identifies the process. Events already queued are kept; they were captured legitimately before the logout.
reset() is per-client, so on a shared server client it affects every in-flight request. On a server, prefer not holding identity on the client at all, and you will not need reset().
7. User properties
Every operation, through Identify:
python
from selica_analytics import Identify
ops = (Identify()
.set("plan", "pro") # $set — overwrite
.set_once("signup_date", "2026-01-04") # $setOnce — write only if never set
.add("seats", 1) # $add — increment a number
.append("roles", "admin") # $append — push onto the end of a list
.prepend("recent_projects", "p-482") # $prepend — push onto the front
.unset("trial_ends")) # $unset — remove the property
selica.set_user_properties("user-1042", ops)ops.to_dict() is exactly what goes on the wire:
python
{
"$set": {"plan": "pro"},
"$setOnce": {"signup_date": "2026-01-04"},
"$add": {"seats": 1},
"$append": {"roles": "admin"},
"$prepend": {"recent_projects": "p-482"},
"$unset": {"trial_ends": "-"},
}The raw dict works too, and an unknown operation name raises ValueError locally rather than being refused on ingest:
python
selica.set_user_properties("user-1042", {"$add": {"logins": 1}})Properties can also ride along with an ordinary event, avoiding a second request:
python
selica.track(
"invited_teammate",
user_id="user-1042",
properties={"role": "editor"},
user_properties=Identify().add("seats", 1).set("role", "admin"),
)8. Groups and entities
Groups
A group is an account-shaped dimension: an organisation, a workspace, a plan. At most five group types per event.
python
selica.set_group("orgId", "org-8891") # on every subsequent event
selica.track("seat_added", user_id="user-1042", groups={"plan": "enterprise"}) # per eventPer-event groups are merged over the client's, so a per-event value wins. On a shared server client, prefer the per-event form for the same reason you pass user_id per event.
Entities
An entity is a versioned, self-describing object attached to an event. Where a property is a loose key, an entity says which schema it conforms to — so reporting can validate it, evolve it, and join across events that carry the same entity.
python
selica.add_entity("com.acme/[email protected]", {"tenantId": "org-8891", "tier": "enterprise"})
selica.track(
"report_exported",
user_id="user-1042",
entities=[{"schema": "com.acme/[email protected]",
"data": {"id": "acct-42", "tier": "pro"}}],
)The schema reference is vendor/[email protected]. Major means breaking, exactly as you already expect from semantic versioning: rename or retype a field and you go to 2.0.0, so old events keep their old shape and reporting can tell them apart instead of silently mixing two incompatible shapes into one column. Add an optional field and it is 1.1.0.
In reporting, entities appear as their own grouped dimensions keyed by schema, separate from the flat properties bag. That is what the version buys you.
Deprecated: the older
iglu:vendor/name/jsonschema/1-0-0reference form is still accepted on ingest so stored events and older SDKs keep working. Do not use it in new integrations.
Note that the Python client does not validate the schema reference locally; a malformed one is refused by the collector and surfaces through on_error as rejected:<eventType>.
9. Revenue
Revenue is not a retail concept. A subscription upgrade uses every field naturally: quantity is three seats and item_id is the plan.
python
from selica_analytics import Revenue
selica.revenue(
"user-1042",
Revenue(price=49.00, quantity=3, currency="USD", item_id="team-pro",
revenue_type="upgrade"),
properties={"from_plan": "starter"},
)Revenue never raises. It is built inside your billing path, and analytics must not be the reason a payment fails. A quantity below 1 is corrected to 1, a malformed currency is dropped so the charge is still recorded, and everything corrected is listed by problems() and logged by the client.
The same fields describe any shape revenue takes. A one-off purchase is one of them, with quantity counting items instead of seats:
python
selica.revenue(
"user-1042",
Revenue(price=49.5, quantity=2, currency="USD", item_id="KB-1", revenue_type="purchase"),
properties={"orderId": "ord-77"},
)| Field | Meaning |
|---|---|
price | Unit price. Required unless revenue is given. |
quantity | How many: seats, licences, tickets, items. Defaults to 1; must be ≥ 1. |
currency | 3-letter ISO 4217 code. Upper-cased for you; a wrong length raises ValueError. |
item_id | What the money was for: a plan, a ticket, a course, a SKU. |
revenue_type | Free-form category: upgrade, renewal, purchase, refund. |
revenue | The total, when it is not price × quantity (a discount, a partial refund). |
properties | Extra properties for the surrounding event, not the revenue object. |
product_id is the previous name for item_id. It is still accepted, sets the same field, and logs one deprecation warning through the configured logger.
Where the total is computed. The Python Revenue helper computes revenue = price × quantity locally, rounded to 6 decimal places, and always sends a revenue field. The collector also computes it when absent. Send revenue= explicitly whenever the total is not the simple product — the local computation is a convenience, not a pricing engine.
A refund is a negative total:
python
selica.revenue("user-1042", Revenue(price=49.5, quantity=1, currency="USD",
revenue=-49.5, revenue_type="refund"))Revenue can also ride on a business event instead of the generic revenue type:
python
selica.track("subscription_renewed", user_id="user-1042",
revenue=Revenue(price=120.0, currency="USD", revenue_type="renewal"))10. Experiments
An experiment splits your users into variants, shows each one something different, and measures whether a goal event happens more often in one of them. variant() tells you which arm a person is in.
Assignment happens inside this process. There is no network call on your request path, it keeps working when our config service does not, and the same subject gets the same arm on every machine forever, because the arm is a pure function of the experiment key and the subject id.
python
from selica_analytics import Client
selica = Client(api_key="pk_live_xxx", server_url="https://collect.tangentlens.com/collector")
digest = selica.variant("report-scheduler", "user-1042")
frequency = (digest.payload or {}).get("frequency", "weekly")
schedule_digest(user, frequency)payload is your own JSON, defined in the console and handed back untouched — copy, a limit, a feature flag, a whole configuration object. We never interpret it, which is what lets one experiment change anything without us knowing your domain.
Who the subject is
This is a server SDK: one process serves many people, so name the person each evaluation is for. Do not set a process-wide identity and read the variant from it — under concurrency you will assign one request's user to another's arm.
python
# Right: the subject travels with the call.
result = selica.variant("report-scheduler", request.user.id,
user_properties={"plan": request.user.plan})
# Wrong in any concurrent server: the next request overwrites this.
selica.set_user_id(request.user.id)
result = selica.variant("report-scheduler")Omitting the subject falls back to the client's own user id, or its device id when there is no user. That is right for a single-user process — a CLI, a worker handling one account, a notebook — and wrong everywhere else.
The return value
variant() returns a VariantResult and never raises.
| Field | Meaning |
|---|---|
key | The assigned variant's key, or the control's when the subject is excluded, or your fallback's, or None. |
payload | That variant's payload, or None. |
in_experiment | True only when the subject was really assigned and an exposure was recorded for them. |
Five things can happen, and the difference between them matters:
python
definitions = {
"pollIntervalSeconds": 300,
"experiments": [
{
"key": "report-scheduler",
"salt": "",
"trafficAllocation": 100,
"targeting": [{"property": "plan", "operator": "in", "values": ["pro"]}],
"variants": [
{"key": "control", "weight": 50, "payload": {"frequency": "weekly"}},
{"key": "treatment", "weight": 50, "payload": {"frequency": "daily"}},
],
}
],
}
selica = Client(api_key="pk_live_xxx", server_url="https://collect.tangentlens.com/collector",
experiment_definitions=definitions)
pro = {"plan": "pro"}
free = {"plan": "free"}
# 1. Assigned. An $exposure is recorded once for this subject.
assigned = selica.variant("report-scheduler", "user-1042", user_properties=pro)
print(assigned.key, assigned.payload, assigned.in_experiment)
# treatment {'frequency': 'daily'} True
# 2. Excluded by targeting: the CONTROL's key and payload, and no exposure.
excluded = selica.variant("report-scheduler", "user-1042", user_properties=free)
print(excluded.key, excluded.payload, excluded.in_experiment)
# control {'frequency': 'weekly'} False
# 3. Unknown experiment, or definitions not loaded: your fallback.
unknown = selica.variant("no-such-experiment", "user-1042",
fallback={"key": "control", "payload": {"frequency": "weekly"}})
print(unknown.key, unknown.payload, unknown.in_experiment)
# control {'frequency': 'weekly'} False
# 4. Unknown, with no fallback given.
bare = selica.variant("no-such-experiment", "user-1042")
print(bare.key, bare.payload, bare.in_experiment)
# None None FalseThe fifth is traffic allocation, below.
An excluded subject gets the control's payload, not your fallback. That is deliberate: your code gets a usable configuration in every case, and the control is what a user outside the experiment should see. Your fallback is only for the case where there is no definition to read at all. Branch on in_experiment when you need to tell the two apart; branch on key when you just need to render something.
When an $exposure fires
An exposure is what tells the results engine that a person actually saw an arm. Exactly one is recorded per subject per experiment for the life of the client, so a template that reads the variant on every render costs one event, not one per render.
python
for _ in range(100):
selica.variant("report-scheduler", "user-1042", user_properties=pro)
selica.variant("report-scheduler", "user-2001", user_properties=pro)
print(selica.stats()["queued"])
# 2The event carries exactly two properties and nothing else:
json
{ "eventType": "$exposure",
"properties": { "experiment": "report-scheduler", "variant": "treatment" } }Every extra field would be a field you had to obtain consent for, to tell the results engine nothing it needs.
Nothing at all is recorded when the subject is excluded by targeting or by traffic allocation, when the experiment is unknown, when you have opted out, or while consent is denied. An unexposed user appearing in the results would bias every number computed from them.
reset() clears the memory of which subjects have been exposed, because the subject has changed. The next evaluation is a first evaluation for a new person.
Targeting
Every rule must match before a subject is assigned. Rules are evaluated before traffic allocation, so a subject excluded by targeting never consumes an allocation slot.
Values are compared as strings, case-sensitively, against the properties you pass as user_properties — or, when you pass none, the properties the client is holding from identify() and set_user_properties().
| Operator | Matches when |
|---|---|
equals | the property equals the first value |
notEquals | the property is present and differs from the first value |
in | the property is one of the values |
notIn | the property is present and is none of the values |
contains | the property contains the first value as a substring |
exists | the property is present |
notExists | the property is absent |
Two rules that surprise people:
- A rule naming a property you did not supply fails, including
notEqualsandnotIn. It does not match vacuously. - An unknown operator never matches. A newer console can write a rule an older SDK cannot read; excluding the user is recoverable, silently putting them in the wrong arm is not.
python
seats_rule = {"experiments": [{
"key": "seat-limit-banner", "salt": "", "trafficAllocation": 100,
"targeting": [{"property": "seats", "operator": "equals", "values": ["12"]}],
"variants": [{"key": "control", "weight": 0}, {"key": "on", "weight": 100}],
}]}
selica.set_experiments(seats_rule)
print(selica.variant("seat-limit-banner", "user-1042", user_properties={"seats": 12}).key)
# on — a number is compared as its string form
print(selica.variant("seat-limit-banner", "user-1042", user_properties={}).key)
# control — a missing property fails the ruleA zero-weight arm can never be assigned, which is what makes the pattern above readable: on means targeted, control means excluded.
Traffic allocation
trafficAllocation is the percentage of your audience the experiment is exposed to at all. It is a second, independent hash, so raising or lowering it does not reshuffle who is in which arm.
python
selica.set_experiments({"experiments": [{
"key": "pricing-page", "salt": "", "trafficAllocation": 40,
"variants": [
{"key": "control", "weight": 50, "payload": {"layout": "table"}},
{"key": "treatment", "weight": 50, "payload": {"layout": "cards"}},
],
}]})
inside = selica.variant("pricing-page", "user-2001")
outside = selica.variant("pricing-page", "user-7")
print(inside.key, inside.in_experiment)
# treatment True
print(outside.key, outside.in_experiment)
# control FalseA subject outside the allocation is not in the experiment at all: they get the control's value and no exposure.
How assignment is computed
Reproduce it yourself in one line — the same numbers in every Tangent SDK:
python
from selica_analytics import bucket_for
print(bucket_for("report-scheduler::user-1042"))
# 8874The key is experimentKey + ":" + salt + ":" + subject. Its SHA-256 digest's first four bytes are read big-endian as an unsigned 32-bit integer, modulo 10000, giving a bucket in 0..9999. Cumulative variant weights times 100 are the boundaries, and the first variant whose boundary is strictly greater than the bucket wins. Traffic allocation hashes experimentKey + ":alloc:" + salt + ":" + subject the same way.
A salt changes every assignment without changing the experiment key, which is how you restart an experiment with a fresh sample.
Where definitions come from
By default the client fetches GET {server_url}/config/experiments with your write key, on the background flush thread, no more often than every experiment_poll_interval_seconds (300 by default; the collector's own pollIntervalSeconds wins once it has answered). The first fetch is only scheduled after your first variant() call, so a process that runs no experiments never makes the request.
variant() itself never performs I/O and never awaits anything. It answers from the cache, which means a definition change takes up to one poll interval to reach you. That is the trade for an assignment that cannot add latency to your request path.
You can supply definitions yourself instead, which skips the fetch entirely — useful in tests, in an air-gapped deployment, or where you already distribute configuration:
python
selica = Client(api_key="pk_live_xxx", server_url="https://collect.tangentlens.com/collector",
experiment_definitions=definitions)When the fetch fails
It fails soft, in every case:
| What happens | What the SDK does |
|---|---|
| Connection refused, DNS failure, timeout | Keeps the definitions it already had; retries with exponential backoff, starting at the poll interval and capped at an hour. |
500 from the config feed | The same. |
401 or 403 | The same. It does not disable the client — your events matter more than your experiments, and disabling would stop them. |
| A body that is not JSON, or JSON we cannot read | The same; a definition we cannot parse is dropped, its readable neighbours are kept. |
| Nothing ever loads | variant() returns your fallback with in_experiment: False, forever, and tracking is completely unaffected. |
Whatever definitions are already in force stay in force. Stale assignments are much better than flipping every user into the control arm because a config request timed out.
Set experiments=False to switch the feature off entirely: variant() then always returns your fallback and no request is ever made.
Configuration
| Option | Type | Default | What it controls |
|---|---|---|---|
experiments | bool | True | False disables variant() and the definitions fetch entirely. |
experiment_definitions | dict | None | A /config/experiments body supplied directly. Definitions given this way are authoritative and are never refetched. |
experiment_poll_interval_seconds | int | 300 | How long definitions are cached. The collector's pollIntervalSeconds wins once it has answered. |
What this does not do
Said plainly, so you do not discover it after building on it: no holdout groups, no multi-armed bandits, no sequential testing or always-valid p-values, no Bayesian posteriors, and no CUPED variance reduction. Mutual exclusion is available through layers: an experiment can own a slice of a layer (set in the console), and the SDK honours the definition's layer field automatically — two experiments in one layer never share a user. Assignment cannot be forced for a specific user from the SDK — override it in your own code by branching before you call variant().
11. Plugins
A plugin declares when it runs (its stage) and what it does (its hooks). For each event the client runs:
prepare → entities (collected from every plugin) → transform → should_send → deliverStages
| Stage | PluginType | Runs |
|---|---|---|
prepare | PluginType.PREPARE | First, before entities are collected. Normalise, or reject early. |
transform | PluginType.TRANSFORM | The default. Mutate or drop the event once entities are attached. |
deliver | PluginType.DELIVER | Last, after the send gate. Forward the event somewhere else. |
Hooks
| Hook | Signature | Effect |
|---|---|---|
setup | (config, client) -> None | Once, when the plugin is added. config is already resolved. |
teardown | () -> None | On remove() or shutdown(). |
entities | (event) -> list | None | Entities appended to the event. Called on every plugin regardless of stage. |
apply | (event) -> event | None | Mutate or replace the event. Returning None drops it. |
should_send | (event) -> bool | False drops the event. Called on every plugin regardless of stage. |
flush | () -> None | Deliver-stage plugins only, on every flush(). Send what you have buffered. |
on_identity_changed | ({"user_id", "device_id"}) -> None | After set_user_id(). |
on_session_id_changed | (session_id) -> None | After set_session_id(). |
on_opt_out_changed | (bool) -> None | After set_opt_out(). |
on_consent_changed | (dict) -> None | After set_consent(). |
on_reset | () -> None | After reset(). |
Things that are easy to get wrong:
entitiesandshould_sendrun for every registered plugin. Onlyapplyis stage-selective.- A deliver-stage
applyreturningNonedoes not stop the collector send — the event is already past the gate. Useshould_sendto drop. - Every hook runs on the calling thread inside
track(). Do not block in one. A deliver plugin that needs the network should buffer inapplyand send inflush. - An exception in any hook is caught, reported through
on_errorasplugin.<hook>:<name>and the pipeline carries on — except inapply, where the event keeps its pre-hook value. - The event dict has explicit
Nonevalues for absent fields (event["groups"]isNone, not missing).event.setdefault("groups", {})therefore returnsNone. Useevent.get("groups") or {}.
Worked plugin 1 — attach an entity from request context
Safe on a shared client under concurrency, because the tenant lives in a contextvars.ContextVar rather than on the client.
python
import contextvars
from selica_analytics.plugins import Plugin, PluginType
current_tenant = contextvars.ContextVar("current_tenant", default=None)
class TenantEntity(Plugin):
"""Stamps the calling tenant onto every event as an entity and a group."""
name = "tenant-entity"
type = PluginType.PREPARE
def entities(self, event):
tenant = current_tenant.get()
if tenant is None:
return None
return [{"schema": "com.northwind/[email protected]", "data": {"tenantId": tenant}}]
def apply(self, event):
tenant = current_tenant.get()
if tenant is not None:
groups = event.get("groups") or {} # the key exists and is None
groups["orgId"] = tenant
event["groups"] = groups
return eventSet the variable where the request is handled (a middleware, a Celery task header, a gRPC interceptor):
python
current_tenant.set(request.headers["X-Tenant-Id"])Worked plugin 2 — drop health-check traffic
Load balancers and Kubernetes probe your service constantly. That traffic is real, and it is noise in every product report.
python
from selica_analytics.plugins import Plugin, PluginType
HEALTH_ROUTES = {"/health", "/healthz", "/ready", "/livez"}
class DropHealthChecks(Plugin):
"""Silently discards api_request events for probe routes."""
name = "drop-health-checks"
type = PluginType.TRANSFORM
def should_send(self, event):
if event.get("eventType") != "api_request":
return True
route = (event.get("properties") or {}).get("route")
return route not in HEALTH_ROUTESRegister both:
python
selica = Client(
api_key="pk_live_xxx",
server_url="https://collect.tangentlens.com/collector",
plugins=[TenantEntity(), DropHealthChecks()],
)
# or later
selica.add(DropHealthChecks())
selica.remove("drop-health-checks") # True when one was removed; runs teardown
selica.plugin_names() # ['tenant-entity']Dropped events are counted: selica.stats()["filtered"].
The bundled middleware already skips
/health,/healthz,/ready,/metricsand/favicon.icoby path. This plugin catches probes that arrive by another route, and covers events your own code produces.
Plugin names are unique: adding a second plugin with a name already registered raises ValueError.
Deprecated: stages were once
before/enrichment/destinationand the hooksexecute/contexts/filter. Both are still detected and called, with a one-time warning through the configured logger naming the replacement. They are removed in the next major version.
12. Framework integration
Two adapters cover the Python web ecosystem, in selica_analytics.middleware:
| Class | Frameworks |
|---|---|
WSGIMiddleware | Flask, Django (WSGI), Bottle, Pyramid, anything WSGI |
ASGIMiddleware | FastAPI, Starlette, Django (ASGI), Quart, anything ASGI |
Both emit the shared api_request event shape: method, route, path, status, statusClass, durationMs, requestBytes, responseBytes, userAgent, host, plus error: true at status ≥ 400. Both take the same options:
| Option | Type | Default | What it controls |
|---|---|---|---|
client | Client | — | Required. |
route | (scope) -> str | framework guess | Returns the route template. Get this right — the raw path is unbounded cardinality. |
caller_id | (scope) -> str | None | None | Returns the user id for the event. |
ignore | Sequence[str] | ("/health", "/healthz", "/ready", "/metrics", "/favicon.ico") | Exact paths skipped entirely. |
event_type | str | "api_request" | Rename the event if you must. |
A failure inside the tracker never propagates to the request: _record swallows everything.
Flask
python
from flask import Flask, request
from selica_analytics import Client
from selica_analytics.middleware import WSGIMiddleware
app = Flask(__name__)
selica = Client(api_key="pk_live_xxx", server_url="https://collect.tangentlens.com/collector")
@app.get("/orders/<order_id>")
def get_order(order_id):
selica.track("order_viewed", user_id=request.headers.get("X-User-Id"),
properties={"orderId": order_id})
return {"id": order_id}
# The middleware's built-in route fallback looks for an environ["werkzeug.request"] key that
# Flask does not actually set, so record the matched rule yourself while the request context
# is alive. Without this every distinct path becomes its own route.
@app.before_request
def _record_selica_route():
if request.url_rule is not None:
request.environ["selica.route"] = request.url_rule.rule
def flask_route(environ):
return environ.get("selica.route", environ.get("PATH_INFO", "/"))
app.wsgi_app = WSGIMiddleware(
app.wsgi_app,
selica,
route=flask_route,
caller_id=lambda environ: environ.get("HTTP_X_USER_ID"),
)The api_request event is recorded when the response body iterable is closed, so a streaming response is timed to its last byte rather than its first.
Django
Point wsgi.py at the middleware — this wraps the whole application, including Django's own middleware chain:
python
# myproject/wsgi.py
import os
from django.core.wsgi import get_wsgi_application
from selica_analytics import Client
from selica_analytics.middleware import WSGIMiddleware
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "myproject.settings")
selica = Client(api_key=os.environ["SELICA_API_KEY"],
server_url="https://collect.tangentlens.com/collector")
application = WSGIMiddleware(get_wsgi_application(), selica)Django does not expose its matched route through the WSGI environ, so that form reports raw paths. For route templates and per-request identity, write a small Django middleware instead — Django resolves the URL before your middleware sees the response, and resolver_match.route is the template:
python
# myproject/analytics.py
import time
from myproject.wsgi import selica
class SelicaRequestTracking:
"""Django middleware emitting one api_request event per request."""
def __init__(self, get_response):
self.get_response = get_response
self.ignore = {"/health", "/healthz", "/ready", "/metrics"}
def __call__(self, request):
if request.path in self.ignore:
return self.get_response(request)
started = time.perf_counter()
response = self.get_response(request)
try:
match = getattr(request, "resolver_match", None)
status = response.status_code
props = {
"method": request.method,
"route": match.route if match is not None else request.path,
"path": request.path,
"status": status,
"statusClass": f"{status // 100}xx",
"durationMs": round((time.perf_counter() - started) * 1000, 3),
"requestBytes": int(request.META.get("CONTENT_LENGTH") or 0),
"responseBytes": len(response.content) if response.has_header("Content-Length") else 0,
"userAgent": request.META.get("HTTP_USER_AGENT"),
"host": request.get_host(),
}
if status >= 400:
props["error"] = True
user = getattr(request, "user", None)
user_id = str(user.pk) if user is not None and user.is_authenticated else None
selica.track("api_request", user_id=user_id, properties=props)
except Exception: # analytics must never break a response
pass
return responsepython
# settings.py
MIDDLEWARE = [
# … Django's own middleware …
"myproject.analytics.SelicaRequestTracking",
]Note Django's route templates use <int:order_id> style, so the reported route reads orders/<int:order_id> — consistent, which is what matters.
FastAPI / Starlette
python
from fastapi import FastAPI, Request
from selica_analytics import Client
from selica_analytics.middleware import ASGIMiddleware
selica = Client(api_key="pk_live_xxx", server_url="https://collect.tangentlens.com/collector")
app = FastAPI()
app.add_middleware(
ASGIMiddleware,
client=selica,
caller_id=lambda scope: dict(scope["headers"]).get(b"x-user-id", b"").decode() or None,
)
@app.get("/orders/{order_id}")
def read_order(order_id: str, request: Request):
selica.track("order_viewed", user_id=request.headers.get("x-user-id"),
properties={"orderId": order_id})
return {"id": order_id}Here the route template works out of the box: Starlette puts the matched Route on the ASGI scope, and the middleware reads route.path — /orders/{order_id}.
add_middleware wraps in reverse registration order, so add this first if you want it outermost and timing the whole stack.
Shut down cleanly with a lifespan handler:
python
from contextlib import asynccontextmanager
@asynccontextmanager
async def lifespan(app: FastAPI):
yield
selica.shutdown()
app = FastAPI(lifespan=lifespan)shutdown() blocks while it flushes. In an async application that runs on the event loop thread — acceptable at shutdown, never during a request.
Celery, RQ and other workers
Build the client in the worker process, not at import time in a forked parent — a thread does not survive fork(), and a client created before the fork has a queue no thread is draining.
python
from celery.signals import worker_process_init, worker_process_shutdown
selica = None
@worker_process_init.connect
def _start_selica(**_):
global selica
selica = Client(api_key="pk_live_xxx", server_url="https://collect.tangentlens.com/collector")
@worker_process_shutdown.connect
def _stop_selica(**_):
if selica is not None:
selica.shutdown()Concurrency and lifecycle
Threading model. One daemon thread per client, named
selica-flush. It wakes on the flush interval, or early when the queue reachesflush_queue_size, and callsflush().Sharing. The client is safe to share across threads.
track()puts onto aqueue.Queue; the plugin list is guarded by anRLock. Plugin hooks run on the calling thread, so a plugin's own state must be thread-safe or thread-local.flush()sends everything currently queued and returns how many events were delivered. It never holds your thread longer thanshutdown_timeout_millis(default 5 s): past it, it returns 0 and the background worker keeps sending. Passraise_on_error=Trueto have failures raiseSelicaErrorinstead of only reachingon_error.shutdown()stops the worker, performs a finalflush(), and runs every plugin'steardown. It is idempotent. It is not reversible: a shut-down client stays stopped.At process exit,
shutdown()is registered withatexitin the constructor, so a normal interpreter exit flushes. Call it explicitly anyway when you can —atexithandlers do not run onos._exit(), onSIGKILL, on a segfault, or in a worker killed by a supervisor's hard timeout. In those cases everything still queued is lost silently.The context manager shuts down for you:
pythonwith Client(api_key="pk_live_xxx", server_url="https://collect.tangentlens.com/collector") as selica: selica.track("job_finished", user_id="worker-01", properties={"durationMs": 1200}) # flushed and stopped hereShort-lived processes (a CLI, a Lambda, a cron job) should always
flush()before returning. Otherwise the process may exit inside the 10-second interval with events still in the queue.fork(). After a fork the child has the parent's queue contents but no worker thread, and both processes haveatexitregistered. Build the client after forking.
13. Privacy and consent
Opt-out
python
selica.set_opt_out(True) # track() records nothing from now on
selica.opt_out # True
selica.set_opt_out(False) # resumeOpting out stops new events and fires on_opt_out_changed on every plugin. It does not discard what is already queued in the Python SDK — events captured before the opt-out are still delivered. If your policy requires them dropped, do it explicitly at construction with opt_out=True, or accept a final flush of pre-opt-out data.
Start opted out and enable after a decision:
python
selica = Client(api_key="pk_live_xxx", server_url="https://collect.tangentlens.com/collector", opt_out=True)
...
if user_consented:
selica.set_opt_out(False)Consent
python
selica.set_consent({"analytics": True, "marketing": False})
selica.consent # {'analytics': True, 'marketing': False}Be clear about what this does. set_consent() records the state and fires on_consent_changed on every plugin. It is a signal, not a gate: the Python client does not itself hold back or discard events based on consent. Enforcement is yours to write, and a should_send plugin is the place:
python
class ConsentGate(Plugin):
name = "consent-gate"
def setup(self, config, client):
self.client = client
def should_send(self, event):
return bool(self.client.consent.get("analytics"))If you need consent enforced before anything is buffered at all, use opt_out — that check happens at the top of track().
Anonymous mode
There is no anonymous-mode switch in the Python SDK. Achieve it per event by sending no user_id — the process device id is used instead — or strip identity in a prepare plugin:
python
class Anonymise(Plugin):
name = "anonymise"
type = PluginType.PREPARE
def apply(self, event):
event["userId"] = None
event["sessionId"] = None
return eventThe client's own device_id still goes out, identifying the service instance.
Do Not Track
DNT is a browser signal and has no meaning in a server process; the Python SDK does not read one. If your frontend forwards DNT: 1, honour it at your edge — skip the track() call, or gate on it in a should_send plugin using request context.
IP addresses
The collector anonymises the client IP before storage by default (last octet for IPv4, last 80 bits for IPv6) and records context.ipAnonymized. The Python client does not send an ipAddress field at all: the middleware resolves the caller IP but deliberately discards it (_forward_ip is a no-op), because the client has no per-event IP override yet. Forwarding an end-user IP from Python is not supported.
14. Reliability
Batching. Events go onto a queue.Queue bounded at max_queue_size (10 000). The worker drains up to flush_queue_size (200) per batch and splits each batch so no request body exceeds max_post_bytes (40 000). An event too large on its own can never be delivered, so it is reported (oversize) and skipped rather than blocking everything behind it.
Retries. Up to max_retries (12) attempts per request, with exponential backoff and jitter: base = min(2^attempt, 30) seconds, then a random value in [base/2, base). On a 429 the Retry-After header wins, clamped to 60 seconds. Statuses in dont_retry_status_codes (400, 401, 403, 410, 422) are permanent — the batch is abandoned and reported. A retried batch reuses the same messageIds, and storage collapses duplicates on that key, so a retry cannot double-count.
Fail closed on a bad key. A 401 or 403 disables the client permanently: disabled_reason is set, the queue is emptied, and every later track() is a no-op. That is deliberate — a client with a revoked key must stop, not keep buffering forever. It is not recoverable; build a new client with a good key.
Partial acceptance. A 202 can still list events the collector refused, by index with reasons. Those are permanently invalid and are never retried; each reaches on_error as rejected:<eventType>.
Back-pressure. When the queue is full, track() drops the oldest event to make room for the newest, increments stats()["dropped"] and reports the first drop and every hundredth. It never blocks the caller and never grows without bound.
What survives what. Nothing is persisted to disk. The queue is in memory only, so a process crash, a SIGKILL, or an os._exit() loses whatever was queued. A clean interpreter exit flushes through atexit. This is best-effort delivery at the process boundary and at-least-once delivery on the wire.
python
selica.stats() # {'queued': 3, 'dropped': 0, 'filtered': 12}15. Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| Nothing arrives at all | Never flushed; the process exited inside the 10-second interval | Call flush() or shutdown(), or use the client as a context manager. Check stats()["queued"]. |
Nothing arrives, disabled_reason is set | A 401/403 disabled the client | Read selica.disabled_reason. Fix the key and build a new client — a disabled one never recovers. |
Nothing arrives, no errors, stats() all zero | opt_out=True, or a plugin is dropping everything | Check selica.opt_out and stats()["filtered"]; temporarily remove() plugins to bisect. |
Nothing arrives, no errors, but queued grows | The worker thread is not running — client built before a fork() | Build the client in the child process. |
401 from the collector | Key unknown, inactive, expired, wrong kind, or an origin the key does not allow | Call validate_key() at start-up. Use a sk_ key server-side; a pk_ key sent with an Origin header is rejected. |
429 | Rate limit for this key | The SDK backs off and honours Retry-After automatically. Persistent 429s mean too many events: raise flush_queue_size to send fewer, larger requests, or reduce what you track. |
on_error with rejected:<eventType> | The collector refused individual events by index | Read the error text — it names the path, e.g. properties.notes exceeds 1024 characters. These are permanent; fix the payload. Common causes: missing eventType, an id under 5 characters, more than 5 group types, a bad currency code, a malformed entity schema reference. |
on_error with min_id_length | Your own min_id_length check dropped it before sending | Send a longer id, or drop the option and let the collector decide. |
on_error with oversize | One event exceeds max_post_bytes | Trim the properties, or raise max_post_bytes (the collector's hard cap is 1 MB per event). |
| Events land at the wrong time / cluster oddly | Clock skew — eventTime is generated on your host | Run NTP. The collector defaults to arrival time when eventTime is absent, so omitting it is the safer choice on a host you do not trust. A skewed clock does not error; it silently misplaces events on every time-series report. |
stats()["dropped"] climbing | The queue is full because flush() never runs fast enough | Usually delivery is failing — look at on_error. Otherwise raise max_queue_size, shorten flush_interval_millis, or track less. Dropping is by design: the alternative is unbounded memory. |
| Events lost at shutdown | The process exited before a flush completed | atexit runs shutdown() on a clean exit only. It does not run on os._exit(), SIGKILL, a segfault, or a supervisor's hard kill. Call shutdown() in your own signal handler and allow it a grace period. |
| Duplicate events in reports | You generated a genuinely new event for the same action | The wire de-duplicates on messageId, and retries reuse it, so retries cannot duplicate. Two track() calls produce two message ids and two rows. |
| Missing user ids on events | Identity was set on the client, and a concurrent request overwrote it | Pass user_id= on each track() call. See Identity. |
variant() always returns the fallback | Definitions never loaded, or the experiment is not running | A fallback means there was no definition to read. Check selica.experiments — empty means nothing loaded. Definitions are fetched on the flush thread, so a client built with start_worker=False never fetches any; supply experiment_definitions= instead. The feed fails soft and silently by design, so raise log_level to "warn" to see why. |
variant() returns a key but in_experiment is False | The subject was excluded by targeting or traffic allocation | That is the control's key, not your fallback, and no $exposure was recorded. Check the properties you passed against the rules; remember a missing property fails every operator but notExists, and an unknown operator never matches. |
An experiment records no $exposure events | The subject was excluded, or was already exposed on this client | One exposure per subject per experiment for the life of the client. reset() clears that memory. Excluded subjects deliberately record nothing. |
| Every path is its own route in reports | The middleware could not find a route template | Pass route= explicitly. On Flask the built-in fallback does not work — see the Flask example. |
| CORS errors | Not applicable | Server-to-server requests have no Origin and no preflight. If you see CORS errors, the request is coming from a browser and needs the browser SDK and a pk_ key with a registered origin. |
| SDK log output is missing | logging is unconfigured, or log_level gates it | The default logger writes through logging.getLogger("selica") at WARNING. Configure logging, raise log_level, or pass on_error. |
16. API reference
Client(api_key, **options)
All options in the configuration table. Raises ValueError for an empty key, an unknown region, or a bad log_level.
track(event_type, user_id=None, properties=None, session_id=None, event_time=None, device_id=None, user_properties=None, groups=None, revenue=None, entities=None, device=None) -> None
Records an event. Never raises, never blocks.
python
selica.track("report_exported", user_id="user-1042", properties={"format": "csv"})identify(user_id, traits=None) -> None
Sends a $identify event with traits as a $set operation.
python
selica.identify("user-1042", {"plan": "pro"})set_user_properties(user_id, ops) -> None
Sends a $identify event with explicit operations, as a dict or an Identify.
python
selica.set_user_properties("user-1042", Identify().add("logins", 1))set_user_id(user_id) -> None
Pins a user onto subsequent events and fires on_identity_changed. Not safe on a shared server client — pass user_id per event instead.
set_session_id(session_id) -> None / session_id (property)
Sets and reads the session id attached to subsequent events. Fires on_session_id_changed.
python
selica.set_session_id("sess-4410")
assert selica.session_id == "sess-4410"set_group(group_type, value) -> None
Group membership on every subsequent event. At most 5 group types.
python
selica.set_group("orgId", "org-8891")add_entity(schema, data) -> None
A versioned entity on every subsequent event.
python
selica.add_entity("com.northwind/[email protected]", {"tenantId": "org-8891"})revenue(user_id, revenue, properties=None) -> None
Records a revenue event.
python
selica.revenue("user-1042", Revenue(price=49.5, quantity=2, currency="USD"))variant(experiment_key, subject=None, *, user_properties=None, fallback=None) -> VariantResult
Assigns a subject to one arm of an experiment, locally and with no network call. Returns a VariantResult with key, payload and in_experiment. Records one $exposure the first time a given subject is assigned to a given experiment. Never blocks, never raises. See Experiments.
python
result = selica.variant("report-scheduler", "user-1042", user_properties={"plan": "pro"})set_experiments(definitions) -> None
Installs a /config/experiments body directly, in place of fetching one. A body that cannot be parsed leaves the previous definitions in force.
experiments (property)
The definitions currently in force, as {key: Experiment}.
selica_analytics.bucket_for(key) -> int
The assignment hash: SHA-256 of the key, first four bytes read big-endian and unsigned, modulo 10000. Every Tangent SDK produces the same number for the same key.
python
from selica_analytics import bucket_for
bucket_for("report-scheduler::user-1042") # 8874set_opt_out(opt_out) -> None / opt_out (property)
Turns tracking off and back on; fires on_opt_out_changed.
set_consent(state) -> None / consent (property)
Records consent state and fires on_consent_changed. Does not gate events by itself — see Privacy and consent.
reset() -> None
Clears user id, session id, groups and entities; fires on_reset. Keeps device_id.
add(plugin) -> Client
Registers a plugin, runs its setup, returns self for chaining. ValueError when the plugin has no name or the name is taken.
python
selica.add(DropHealthChecks()).add(TenantEntity())remove(name) -> bool
Removes by name and runs teardown. True when one was removed.
plugin_names() -> list[str]
Registered names, in registration order.
flush(raise_on_error=False) -> int
Sends everything buffered on the calling thread; returns events delivered. Also runs every deliver plugin's flush. With raise_on_error=True, a delivery failure raises SelicaError.
python
delivered = selica.flush()shutdown() -> None
Stops the worker after a final flush and tears down plugins. Idempotent, registered with atexit, not reversible.
validate_key() -> bool
GET /v1/key/validate. Returns False and disables the client on 401/403; returns True on network trouble, which is not a verdict.
disabled_reason (property) -> str | None
Why the client stopped, or None while it is working.
stats() -> dict[str, int]
python
{"queued": 3, "dropped": 0, "filtered": 12}device_id (attribute) -> str
The stable per-process id, "srv-" + uuid4().hex.
config (attribute) -> Config
The resolved configuration dataclass, as handed to every plugin's setup.
__enter__ / __exit__
with Client(...) as selica: shuts the client down on exit.
Identify()
set(key, value), set_once(key, value), add(key, number), append(key, value), prepend(key, value), unset(key) — each returns self. is_empty() -> bool, to_dict() -> dict. An unknown operation or an empty key raises ValueError.
Revenue(price=None, quantity=1, currency=None, item_id=None, revenue_type=None, revenue=None, properties=None)
Raises ValueError when neither price nor revenue is given, when quantity < 1, or when currency is not exactly 3 characters. to_dict() -> dict is the wire object.
Plugin
Base class; every hook is a no-op. Set name (unique) and type (a PluginType). Hooks are listed in Plugins.
PluginType
PREPARE, TRANSFORM, DELIVER, and the tuple ALL. normalize(stage) returns the current name for a stage or None; is_legacy(stage) is True for a superseded name.
Config
Dataclass of resolved options, plus events_url and validate_url properties.
Logger, StdlibLogger, LogLevel
Logger is the four-method interface (error/warn/info/debug) a logger_provider must satisfy. StdlibLogger(level, name="selica") forwards to logging. LogLevel has NONE/ERROR/WARN/INFO/DEBUG and LogLevel.parse(value).
Region
Region.US, Region.EU, and the REGIONS map of region to default collector URL. ServerZone and SERVER_ZONES are deprecated aliases.
SelicaError
Raised only by flush(raise_on_error=True), and used as the exception type passed to on_error for internally generated problems.
selica_analytics.middleware
WSGIMiddleware(app, client, route=None, caller_id=None, ignore=DEFAULT_IGNORE, event_type="api_request") and ASGIMiddleware with the same signature. See Framework integration.