Skip to content

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/python

Or, 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/python

For local development against the SDK source, an editable install picks up your edits:

bash
pip install -e ./sdk/python

The 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 published

Pin 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.

OptionTypeDefaultWhat it controls
api_keystr—Required. The key issued on the platform. Empty raises ValueError.
server_urlstrregion defaultCollector base URL, e.g. https://collect.tangentlens.com/collector. /v1/events is appended. Overrides region.
endpointstrNonePositional 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_sizeint200Events buffered before the worker is woken early, and the maximum events drained per batch.
max_batch_sizeintNoneSynonym for flush_queue_size.
flush_interval_millisint10000Milliseconds between automatic flushes.
flush_intervalfloatNoneThe same interval in seconds. flush_interval_millis wins if both are given.
max_retriesint12Attempts per request before the batch is discarded.
max_post_bytesint40000Request 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_sizeint10000Hard ceiling on buffered events. Past it, track() drops the oldest event to make room.
timeout_millisint10000Per-request socket timeout.
timeoutfloatNoneThe same timeout in seconds.
log_levelstr | int"warn"none/error/warn/info/debug. none skips the work entirely, whatever your logging config says. A bool raises ValueError.
logger_provider() -> LoggerNoneZero-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.
proxystrNoneProxy URL, e.g. http://proxy.internal:3128, applied to both http and https.
headersdict[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_outboolFalseStart with tracking off. track() records nothing.
min_id_lengthintNoneWhen 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) -> NoneNoneCalled for every delivery or plugin problem with the exception and a context string. Exceptions raised inside it are swallowed.
dont_retry_status_codesIterable[int](400, 401, 403, 410, 422)Statuses treated as permanent: the batch is abandoned rather than retried.
pluginsIterable[Plugin]()Plugins registered at construction, in order.
experimentsboolTrueFalse disables variant() and the definitions fetch entirely. See Experiments.
experiment_definitionsdictNoneA /config/experiments body supplied directly, skipping the fetch. Definitions given this way are authoritative and are never refetched.
experiment_poll_interval_secondsint300How long experiment definitions are cached. The collector's own pollIntervalSeconds wins once it has answered.
server_zone"US" | "EU"NoneDeprecated 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_created with properties={"status": "archived"}, not project_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 is api_request. Product metrics deliberately exclude api_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 event

Per-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-0 reference 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"},
)
FieldMeaning
priceUnit price. Required unless revenue is given.
quantityHow many: seats, licences, tickets, items. Defaults to 1; must be ≥ 1.
currency3-letter ISO 4217 code. Upper-cased for you; a wrong length raises ValueError.
item_idWhat the money was for: a plan, a ticket, a course, a SKU.
revenue_typeFree-form category: upgrade, renewal, purchase, refund.
revenueThe total, when it is not price × quantity (a discount, a partial refund).
propertiesExtra 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.

FieldMeaning
keyThe assigned variant's key, or the control's when the subject is excluded, or your fallback's, or None.
payloadThat variant's payload, or None.
in_experimentTrue 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 False

The 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"])
# 2

The 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().

OperatorMatches when
equalsthe property equals the first value
notEqualsthe property is present and differs from the first value
inthe property is one of the values
notInthe property is present and is none of the values
containsthe property contains the first value as a substring
existsthe property is present
notExiststhe property is absent

Two rules that surprise people:

  • A rule naming a property you did not supply fails, including notEquals and notIn. 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 rule

A 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 False

A 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"))
# 8874

The 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 happensWhat the SDK does
Connection refused, DNS failure, timeoutKeeps the definitions it already had; retries with exponential backoff, starting at the poll interval and capped at an hour.
500 from the config feedThe same.
401 or 403The 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 readThe same; a definition we cannot parse is dropped, its readable neighbours are kept.
Nothing ever loadsvariant() 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 ​

OptionTypeDefaultWhat it controls
experimentsboolTrueFalse disables variant() and the definitions fetch entirely.
experiment_definitionsdictNoneA /config/experiments body supplied directly. Definitions given this way are authoritative and are never refetched.
experiment_poll_interval_secondsint300How 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 → deliver

Stages ​

StagePluginTypeRuns
preparePluginType.PREPAREFirst, before entities are collected. Normalise, or reject early.
transformPluginType.TRANSFORMThe default. Mutate or drop the event once entities are attached.
deliverPluginType.DELIVERLast, after the send gate. Forward the event somewhere else.

Hooks ​

HookSignatureEffect
setup(config, client) -> NoneOnce, when the plugin is added. config is already resolved.
teardown() -> NoneOn remove() or shutdown().
entities(event) -> list | NoneEntities appended to the event. Called on every plugin regardless of stage.
apply(event) -> event | NoneMutate or replace the event. Returning None drops it.
should_send(event) -> boolFalse drops the event. Called on every plugin regardless of stage.
flush() -> NoneDeliver-stage plugins only, on every flush(). Send what you have buffered.
on_identity_changed({"user_id", "device_id"}) -> NoneAfter set_user_id().
on_session_id_changed(session_id) -> NoneAfter set_session_id().
on_opt_out_changed(bool) -> NoneAfter set_opt_out().
on_consent_changed(dict) -> NoneAfter set_consent().
on_reset() -> NoneAfter reset().

Things that are easy to get wrong:

  • entities and should_send run for every registered plugin. Only apply is stage-selective.
  • A deliver-stage apply returning None does not stop the collector send — the event is already past the gate. Use should_send to drop.
  • Every hook runs on the calling thread inside track(). Do not block in one. A deliver plugin that needs the network should buffer in apply and send in flush.
  • An exception in any hook is caught, reported through on_error as plugin.<hook>:<name> and the pipeline carries on — except in apply, where the event keeps its pre-hook value.
  • The event dict has explicit None values for absent fields (event["groups"] is None, not missing). event.setdefault("groups", {}) therefore returns None. Use event.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 event

Set 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_ROUTES

Register 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, /metrics and /favicon.ico by 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/destination and the hooks execute/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:

ClassFrameworks
WSGIMiddlewareFlask, Django (WSGI), Bottle, Pyramid, anything WSGI
ASGIMiddlewareFastAPI, 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:

OptionTypeDefaultWhat it controls
clientClient—Required.
route(scope) -> strframework guessReturns the route template. Get this right — the raw path is unbounded cardinality.
caller_id(scope) -> str | NoneNoneReturns the user id for the event.
ignoreSequence[str]("/health", "/healthz", "/ready", "/metrics", "/favicon.ico")Exact paths skipped entirely.
event_typestr"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 response
python
# 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 reaches flush_queue_size, and calls flush().

  • Sharing. The client is safe to share across threads. track() puts onto a queue.Queue; the plugin list is guarded by an RLock. 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 than shutdown_timeout_millis (default 5 s): past it, it returns 0 and the background worker keeps sending. Pass raise_on_error=True to have failures raise SelicaError instead of only reaching on_error.

  • shutdown() stops the worker, performs a final flush(), and runs every plugin's teardown. It is idempotent. It is not reversible: a shut-down client stays stopped.

  • At process exit, shutdown() is registered with atexit in the constructor, so a normal interpreter exit flushes. Call it explicitly anyway when you can — atexit handlers do not run on os._exit(), on SIGKILL, 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:

    python
    with 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 here
  • Short-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 have atexit registered. Build the client after forking.


Opt-out ​

python
selica.set_opt_out(True)     # track() records nothing from now on
selica.opt_out               # True
selica.set_opt_out(False)    # resume

Opting 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)
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 event

The 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 ​

SymptomLikely causeFix
Nothing arrives at allNever flushed; the process exited inside the 10-second intervalCall flush() or shutdown(), or use the client as a context manager. Check stats()["queued"].
Nothing arrives, disabled_reason is setA 401/403 disabled the clientRead selica.disabled_reason. Fix the key and build a new client — a disabled one never recovers.
Nothing arrives, no errors, stats() all zeroopt_out=True, or a plugin is dropping everythingCheck selica.opt_out and stats()["filtered"]; temporarily remove() plugins to bisect.
Nothing arrives, no errors, but queued growsThe worker thread is not running — client built before a fork()Build the client in the child process.
401 from the collectorKey unknown, inactive, expired, wrong kind, or an origin the key does not allowCall validate_key() at start-up. Use a sk_ key server-side; a pk_ key sent with an Origin header is rejected.
429Rate limit for this keyThe 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 indexRead 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_lengthYour own min_id_length check dropped it before sendingSend a longer id, or drop the option and let the collector decide.
on_error with oversizeOne event exceeds max_post_bytesTrim the properties, or raise max_post_bytes (the collector's hard cap is 1 MB per event).
Events land at the wrong time / cluster oddlyClock skew — eventTime is generated on your hostRun 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"] climbingThe queue is full because flush() never runs fast enoughUsually 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 shutdownThe process exited before a flush completedatexit 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 reportsYou generated a genuinely new event for the same actionThe 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 eventsIdentity was set on the client, and a concurrent request overwrote itPass user_id= on each track() call. See Identity.
variant() always returns the fallbackDefinitions never loaded, or the experiment is not runningA 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 FalseThe subject was excluded by targeting or traffic allocationThat 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 eventsThe subject was excluded, or was already exposed on this clientOne 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 reportsThe middleware could not find a route templatePass route= explicitly. On Flask the built-in fallback does not work — see the Flask example.
CORS errorsNot applicableServer-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 missinglogging is unconfigured, or log_level gates itThe 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")  # 8874

set_opt_out(opt_out) -> None / opt_out (property) ​

Turns tracking off and back on; fires on_opt_out_changed.

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.

Tangent — product analytics.