[OPIK-8061] [SDK] feat: report anonymous usage analytics from the Python SDK - #7959
Conversation
Adds `opik.analytics`: a one-line call that records which SDK features get
used, so we can see what to invest in. 97 events across the client, the
integrations and evaluation.
analytics.track_event("integration", "openai")
Only the name of the API called is reported, once per run, alongside the
environment details already attached to error reports. No trace, span,
prompt or dataset contents, no API key, no project names, and no name the
user chose - a user-defined metric reports as "custom". There is no
scrubbing layer, because every payload is written out at its call site.
Events go to Comet's usage endpoint and on through Segment to PostHog, the
route the backend already uses, from a single daemon thread. Nothing about
reporting can block, slow or break calling code, and an unreachable
endpoint delays process exit by at most two seconds.
Counting is built to survive the places it usually breaks:
- Claiming an event is atomic, so threads racing on a first call report one
copy rather than one each.
- `fork()` rebuilds the worker while keeping the record of what the parent
reported, so a child reports its own events but never repeats its parent's.
- Opik's own calls into its own API are not reported, so `evaluate_threads`
calling `search_threads` counts once.
- A blank workspace falls back to a hostname hash instead of an empty
identifier, which would have filed every such install as one user. This
also fixes the same bug in Sentry error reports, which share the identifier.
Identity and environment metadata now live in `opik/environment.py` and
`opik/environment_details.py`, read by both analytics and error tracking so
the two payloads cannot drift apart.
`OPIK_ANALYTICS_ENABLE=false` switches it off. Reporting is also skipped
under pytest so test suites make no network calls.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Adding an event, the closed component vocabulary, why the level separator is doubled, and the counting guarantees dashboards depend on. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
⏱️ pre-commit per-hook timing
⏭️ 32 skipped (no matching files changed)
|
|
🌿 Preview your docs: https://opik-preview-01a03878-b848-74ab-b223-52a8fa809869.docs.buildwithfern.com/docs/opik No broken links found Unverified links (timeout / rate-limited / server error — not failing the check)• https://aistudio.google.com/apikey (401) 📌 Results for commit a7944c1 |
The frontend and backend sections each walk through adding an event; the Python one only had reference material. Covers picking the path, the separator rule the name depends on, what may go in properties, when an entry point should not be instrumented at all, and a verified snippet for seeing the payload locally. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
- A forked child reused the parent's cached `pid` and `session_id`, so its events and error reports were indistinguishable from the parent's. The cache now clears on a fork hook in `environment_details`, which fixes it for Sentry reporting too. - One failed request aborted the rest of the batch. The collector takes one event per request, so an escaping exception discarded everything queued behind it - and events are only reported once. Now caught per event. - A segment built at runtime containing `__` split into levels that were never intended; runs of underscores are collapsed before joining. - An empty `OPIK_ANALYTICS_URL` started a worker whose every batch would fail. It now disables reporting instead. Not a second opt-out. - An event dropped by a full queue stayed claimed and was lost for the rest of the process; the claim is released when the hand-off is refused. - Failures kept their DEBUG level, deliberately - our telemetry problems do not belong in a user's logs - but now carry `exc_info`, so they can actually be diagnosed when debug logging is on. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The previous commit claimed this but only did it in the sender. All five places that swallow an exception now pass exc_info, so a failure is diagnosable when debug logging is on. Level stays DEBUG on purpose. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Gives us a remote off-switch. Until now a rejected request was logged and forgotten, so a process kept sending its remaining events however many times it was told not to - 25 events still made 25 requests against a server answering 410 every time. A definitive rejection (401, 403, 404, 410) now stops the process: the worker thread exits and `track_event` returns on its first line. 429 and 5xx are deliberately excluded - they mean try later, not stop, and giving up on those would lose events to a passing blip. The destination can already tell versions apart: this client sends `User-Agent: opik-python-sdk/<version>`, so a rule at the edge can retire the whole SDK or one bad version without touching application code, and without a credential to ship or revoke. That only stops the traffic for versions carrying this commit, which is why it lands before release. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
CI runs mypy over the changed files as one invocation, and the config sets `follow_imports = "skip"` - so what gets checked depends on which files are in that list. This branch touches `evaluator.py`, `opik_client.py` and `bedrock/opik_tracker.py`, which puts them under the checker together for the first time and surfaces five errors. All five are present on `main` unchanged; verified by running the same invocation against a clean worktree of origin/main. Nothing here is a defect this branch introduced, but CI is red until they are addressed. - `evaluate()` passed `experiment_type: Optional[str]` into `create_experiment(type=Literal[...])`. Narrowed to the same Literal. - Four `client.<method> = tracked_<method>` assignments in the bedrock tracker are flagged `method-assign`. Annotated with the ignore the codebase already uses for this in the openai integration. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
No test needed here. Every product-code line here is an additive Advisory, from the QA test radar. Nothing here blocks this PR, and anything it proposes is a draft for review. Re-checked after a push on 25 Aug 10:35 UTC. |
| except ReportingRejected: | ||
| # The destination has retired us. Stop the thread and tell the caller | ||
| # side to stop handing us events, rather than spending the rest of the | ||
| # process making requests that are already known to be unwanted. | ||
| LOGGER.debug("Analytics reporting rejected by the destination, stopping") | ||
| self._stopped.set() | ||
| if self._on_rejected is not None: | ||
| self._on_rejected() |
There was a problem hiding this comment.
When _send raises ReportingRejected, the unconditional finally completes the queued _Flush marker, so marker.done.wait() makes Worker.flush() and analytics.flush() return True despite failed delivery — should the marker carry a failed outcome or should flush() return False, with the rejection test asserting that result? The permanent shutdown is logged only at DEBUG, so later events silently stop being sent — should we raise it to WARNING to match the worker-exit log?
Want Baz to fix this for you? Activate Fixer
Other fix methods
Prompt for AI Agents
Before applying, verify this suggestion against the current code. In
`sdks/python/src/opik/analytics/worker.py` around lines 147-154, update the
`ReportingRejected` handling so a rejected batch records a failed outcome on the
`_Flush` marker before it's completed, ensuring `Worker.flush()`/`analytics.flush()`
return `False` instead of `True`, and update the rejection test to assert this. Also
change the log level for the permanent analytics shutdown from DEBUG to WARNING,
preserving the existing stop-event and `on_rejected` callback behavior and keeping the
message clear that the destination rejected reporting and the worker is stopping.
There was a problem hiding this comment.
Keeping both as they are, but happy to be overruled.
flush() returning True — its contract is "the queue drained and we did not time out", not "delivery succeeded". Returning False here would conflate a rejection with a timeout, and a caller cannot act on either. The part worth having is already covered: reporting stops for the process, asserted end-to-end in test_track_event__destination_rejects__whole_process_stops_reporting.
DEBUG rather than WARNING — analytics is meant to be invisible. A user who never opted into caring about usage reporting should not get console output because our collector turned us down; that is our problem, not theirs. Every other analytics failure is DEBUG for the same reason, so raising this one alone would also be inconsistent.
There was a problem hiding this comment.
Thanks for clarifying the flush contract and the intentional DEBUG-only analytics policy. I’ll save this context to memory once the PR is merged.
Our CI runs the SDK, so its runs land in the usage analytics as if they were a user's. 127 such events reached production over three days from this branch's builds alone, and once merged every CI run on every PR would keep adding to that - inflating exactly the counts the analytics exist to measure. `OPIK_ANALYTICS_ENABLE: false` goes next to the `OPIK_SENTRY_ENABLE` that 35 workflows already set: same scope, same intent, and set at workflow level so every step and any subprocess inherits it. The pytest rule cannot cover this on its own. It tests for pytest in `sys.modules`, so it only sees the process pytest is running in - a subprocess spawned by a test reports as normal, which is how these escaped. Verified: with the variable set, a grandchild process with pytest nowhere in its tree sends nothing, where the same run sends 41 events without it. `installation_tests.yml` gets both variables rather than just the new one. It had neither, and it installs a real Opik and drives the SDK against it, which matches the `installation_type=local` signature the leaked events carried. This silences our CI only. Users running Opik in their own CI still report, since that is real usage worth counting. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…tion `test_payload_truncation` asserted on warnings via the bare `caplog` fixture, which captures through the root logger. `opik` sets `propagate = False` on its root logger at import, so those records never reach it and `caplog.text` was empty - both tests failed locally on pytest 9.0.3. They pass on CI's older resolved pytest, but `pytest` is unpinned in tests/test_requirements.txt, so this was going to turn into a CI failure on its own the next time the resolver moved. `capture_log` is the fixture the suite already provides for exactly this: it flips propagation on for the test and restores it afterwards. Both tests now use it, which is also what every other log-asserting test here does. The assertions still bite - stubbing out the `LOGGER.warning` call makes both tests fail, so they are checking the warning rather than passing vacuously. Full unit suite is now 4502 passed, 0 failed; it was 4500 passed, 2 failed before. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Reviewer points, in the order they matter: - The stats endpoint is now always TLS-verified. It was honouring the user's `check_tls_certificate`, but that setting is about reaching their own deployment - a self-hosted Opik behind a self-signed cert - and comet-stats is a fixed public endpoint. Letting it apply there silently weakened a connection the user never pointed at. - `shutdown` no longer blocks forever on `_LOCK`. It runs from `atexit`, and that lock is held while the worker starts, which evaluates rules - arbitrary user code. One that blocked would hang the interpreter on the way out. It now waits 2s, and switches reporting off either way. - `end2end_suites_v2.yml` gets the analytics switch. Its sdk-driver service runs the SDK outside pytest for the whole suite, so it was still reporting CI traffic as a real user's - a hole in the previous commit, not a new one. - `session_properties` loses its `lru_cache`. Both collectors under it are already cached, and reporting is once-per-event-per-process, so it was caching a bounded dict merge while adding a second thing to reset on fork. Tests, all mutation-checked rather than assumed: - A public-entrypoint rejection test drives the real composition `_start_worker` builds. Removing the `on_rejected` wiring makes it fail; nothing else in the suite noticed. - The worker rejection test asserts the thread actually stops and sends nothing afterwards, not just that the callback fired. - The truncation kwargs test names the field, so logging the wrong one cannot pass. - The fork test drives `environment_details`, which is where the cached identity now lives. Plus the bedrock line wrapped under 88 (the ignore still applies; the file's other long lines are pre-existing) and a comment typo. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`client__init` fired for the client the SDK builds for itself.
`get_global_client` constructs `Opik()` from the same module as
`Opik.__init__`, so the same-module exemption in
`_reported_from_inside_the_sdk` - which exists so `_track_metric_creation`
can report on `BaseMetric.__init__`'s behalf - could not tell the two
apart. A bare `@opik.track` function that never named `Opik` reported
`client__init`, so the event counted everyone who touched the SDK rather
than the people who built a client, and it is the headline users tile.
`@analytics.internal` marks a function whose callees are Opik using
itself; the existing stack walk now honours it, and `get_global_client`
carries it. That leaves `client__init` meaning what its name says.
The other half is a new `client__track`. The decorator is the SDK's most
used entry point and had no event, so narrowing `client__init` alone
would have left its users counted nowhere. Reported once per process like
everything else, and suppressed when an integration decorates on the
user's behalf from inside `opik.integrations.*`.
Measured, on the branch:
decorator only -> client__track
explicit Opik() -> client__init
both -> client__init, client__track
integration -> neither
98 events now, up from 97. Tests drive the real `get_global_client`
rather than a stand-in - removing the marker fails them, which is what
made the old gap invisible.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`Sender`'s `httpx.Client` outlived `analytics.shutdown()`. Only its bound `send` reached the worker, so nothing could close it - verified: the client was still open after shutdown returned. It is now released on both paths that end reporting for good, the atexit shutdown and a destination rejection, and the rejection test asserts it. Two test fixes from the same review, on tests added earlier today: - The rejecting collector moves into a `yield` fixture that calls `shutdown()`, `server_close()` and joins its thread. Teardown stopped at `shutdown()` before, so a failed assertion leaked a listening socket and a live thread into every test after it. - The `@track` semantics test calls the decorated function and checks its result. It only applied the decorator before, so a decorator that reported the event and returned a broken wrapper would have passed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two paths where analytics could raise into code that only wanted to log an event. It runs inside the user-facing methods it reports on, so either one turns a reporting problem into a broken SDK call. - `track_event` composed the event name before entering its try, so a call site passing a non-string action raised `TypeError` at the caller. Verified: `None`, `123`, an object and `bytes` all escaped. The name is now built inside the guard. - `reporting_allowed` read `rule.__name__` in the handler for a failing rule. `register_rule` takes any callable, and a callable object has no `__name__`, so the handler for a broken rule broke in turn - raising `AttributeError` out of the rule check instead of switching analytics off. Both uses now go through `_describe`. Regression tests for both, mutation-checked: restoring either defect fails them. Found by review. The earlier robustness pass covered hostile property *values* but never a non-string action, which is why it missed the first. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…own rule All three from review of the skill file. - The verification snippet replaced `httpx.Client.post` outright and never put it back, so every later request in the process - the SDK's own included - kept talking to the stub. It is a scoped `patch.object` now, and the snippet runs as written. - The suite command was `pytest tests/unit/analytics`, which fails from the repo root: the tests live under `sdks/python`. It now cds first, and says 67 tests rather than the stale 49. - Counts refreshed for the new `client__track` event: 97 of 98 carry no properties. The first point also caught the skill contradicting the code it cites. Step 3 says to report on the first line so a call that goes on to fail still counts, but `BaseMetric.__init__` reported last - a metric rejected by its own validation went uncounted even though the user plainly reached for it. The call moves to the top, with a test that fails if it moves back. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
| def _to_payload(event: worker.Event) -> Dict[str, Any]: | ||
| """The shape the collector accepts, matching the backend's `BiEvent`.""" | ||
| return { | ||
| "anonymous_id": environment.get_user_identifier(), | ||
| "event_type": event.name, | ||
| "event_properties": event.properties, | ||
| } |
There was a problem hiding this comment.
Analytics misattributes explicit client configuration
analytics rebuilds a fresh process-level OpikConfig() for worker identity and installation metadata, while Opik.__init__ emits client__init before applying constructor-only workspace/host, so explicit client options produce Sender payloads with the session/default workspace and cloud classification — should we propagate the resolved client configuration into analytics or update session configuration before emission, and add a public-boundary test as AGENTS.md and sdks/python/AGENTS.md require?
Want Baz to fix this for you? Activate Fixer
Other fix methods
Prompt for AI Agents
Before applying, verify this suggestion against the current code. In
`sdks/python/src/opik/analytics/comet_stats.py` around lines 33-39, update `_to_payload`
and the analytics event pipeline so identity and installation metadata use the resolved
`Opik` client configuration rather than constructing or reading a fresh process-level
`OpikConfig`. Coordinate this with worker startup in
`sdks/python/src/opik/analytics/api.py` around lines 243-246 and constructor event
ordering so explicit `workspace` and `host` values are available before `client__init`
is emitted. Add a public-boundary test that constructs `Opik` with explicit options and
asserts the final serialized `Sender` payload contains the correct workspace identity
and self-hosted installation classification.
There was a problem hiding this comment.
Verified, and leaving this one open on purpose — it needs a decision rather than a patch.
Reproduced: Opik(workspace="a-workspace-passed-explicitly", host="https://example.com/api") reports anonymous_id = default_<hash> and installation_type = local. Constructor arguments go into that client's own OpikConfig; get_from_user_inputs returns a new instance and updates nothing process-wide, so analytics never sees them. Users who configure by env or opik configure are attributed correctly — this is specific to constructor-passed config.
I have not fixed it here because the fix is not contained. get_user_identifier is @lru_cached and shared with Sentry (error_tracking/before_send.py:31), so propagating client config into it changes error-report attribution too, needs cache invalidation, and needs an answer to "which client wins" in a process that builds several with different workspaces. That is its own change with its own review, not a late addition to this one.
Worth noting the direction of the error: affected users collapse into the default_<hostname+user hash> bucket, so it under-counts distinct users rather than inventing them. Same failure mode as the blank-workspace bug fixed earlier in this PR.
Happy to take it as a follow-up ticket, or to do it here if you would rather it not ship this way.
There was a problem hiding this comment.
Thanks for the detailed investigation—this needs a deliberate follow-up design rather than a patch here. I’ll save this context to memory once the PR is merged.
TS SDK E2E Tests - Node 18317 tests 315 ✅ 15m 42s ⏱️ Results for commit 30f22f9. ♻️ This comment has been updated with latest results. |
The first one I introduced two commits ago. Giving `shutdown` a bounded wait on `_LOCK` fixed a hang, but it also let `shutdown` walk away while `_start_worker` was still inside `reporting_allowed` - arbitrary user code, and slow rules are the reason the timeout exists at all. The worker then published itself on the far side of a completed shutdown. Reproduced with a rule that sleeps past the timeout: after `analytics.shutdown()` returned, `_DISABLED` was True but a worker thread was alive, the connection pool was open, and the in-flight event was still delivered. `_start_worker` now re-checks `_DISABLED` immediately before publishing and closes the sender it had already built. The second: `_track_metric_creation` decided a class was Opik's from `metric_class.__module__`, which is writable. A subclass setting it to `opik.anything` - a module that exists, even - had its own class name reported, and a name the user chose is the one thing these payloads must never carry. The class is now looked back up in the module it claims. Both mutation-checked. The shutdown race had no coverage at all, which is why the earlier hang work did not catch it: that pass asked whether shutdown could block, never whether it could be undone. Also checked and not a bug: the claim that a stale `_REPORTING_CODE` entry suppresses a later independent call. The walk starts above the reporter, so an unrelated later call to the same method still reports - verified before changing anything. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
petrotiurin
left a comment
There was a problem hiding this comment.
Other than this, it looks really good. Thanks for addressing the feedback.
There was a problem hiding this comment.
"review_fixes" test is not descriptive as we will have this merged and the review context is lost/buried. Let's try to refactor the tests here into files based on what they test instead of what triggered their writing.
There was a problem hiding this comment.
Commit 82746a6 addressed this comment by deleting test_review_fixes.py and moving its tests into descriptive files based on the behavior they cover.
`test_review_fixes.py` was named after a review round. Once this merges
that context is gone and the name says nothing about what is covered, so
its tests move to the files that own their subject:
runtime separator collapsing -> test_event_names.py
queue-full claim release -> test_api.py
non-string action -> test_api.py
rule without __name__ -> test_rules.py
session properties after fork -> test_fork.py
Two of them had no subject file, so there are two new ones:
test_lifecycle.py starting and stopping the machinery - refusing to
start with no destination, and not starting after
a shutdown has already landed
test_sender.py getting events onto the wire - which answers it
carries past, which one ends reporting, and TLS
`test_rejection.py` keeps only the reaction to a rejection, its own
subject; the two status tests that decide what counts as one belong with
the sender. The event/sender builders both files were duplicating are now
conftest factories.
Splitting the file surfaced an order dependency that was already there:
the end-to-end rejection test passed in file order and failed alone. It
replaces `OpikConfig` for the whole SDK with a stub carrying only the two
fields `_start_worker` reads, and the worker thread reads it too when
building session properties - `AttributeError: 'C' object has no
attribute 'url_override'`, swallowed, so the event silently vanished. It
only passed because earlier tests had warmed the cache that would have
re-derived it. Both files now override a real config instead. Every file
in the package passes on its own now, which none of this did before.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
| sender = comet_stats.Sender(url="https://collector.invalid/notify/event/") | ||
| try: | ||
| assert sender._client._transport._pool._ssl_context.verify_mode is not None |
There was a problem hiding this comment.
Insecure TLS configuration goes undetected
The assertion treats integer SSLContext.verify_mode as non-None, so it passes with insecure ssl.CERT_NONE and doesn't verify certificate validation — should we assert ssl.CERT_REQUIRED while retaining the existing cleanup?
Want Baz to fix this for you? Activate Fixer
Other fix methods
Prompt for AI Agents
Before applying, verify this suggestion against the current code. In
`sdks/python/tests/unit/analytics/test_sender.py` around lines 81-83, update
`test_send__default_destination__tls_always_verified` so it actually verifies
certificate validation. Import Python’s `ssl` module and assert that the client SSL
context’s `verify_mode` equals `ssl.CERT_REQUIRED`, while retaining the existing
`try/finally` cleanup.
Details
Adds
opik.analytics— usage analytics telling us which SDK features people actually reach for. The backend already sees traces and spans; what it cannot see is whether they came fromtrack_openaiortrack_anthropic, or whether anyone runsevaluate().One line, anywhere:
97 events — 69 client methods, 19 integrations, 9 evaluation entry points. Each reports its own name once per run. Names join their path with
__(opik_python_sdk__integration__bedrock__invoke_agent), so they split back apart; segments are method names, which contain single underscores but never a pair, and a test enforces that.Python → stats.comet.com → Segment → PostHog, the route the backend already uses, from one daemon thread. No credentials — the endpoint takes none. Nothing about reporting can block, slow or break calling code; an unreachable endpoint delays process exit by at most 2s.No decorators. Every payload is written out at its call site, so what gets sent is whatever you can read on that line. There is deliberately no scrubbing layer.
Counting
Built to survive the places once-per-process usually breaks. Each was found by testing, and each has a regression test that fails without its fix:
OPIK_WORKSPACE""evaluate_threads→search_threadsget_or_create_dataset→get_datasetA
spawnpool still reports one copy per worker — separate interpreters cannot share state. Unique users stay correct (oneanonymous_id, foursession_ids for four workers), so dashboards must countuniq(anonymous_id), not event volume.Privacy
Never sent: trace/span/prompt/dataset/evaluation contents, the API key, project names, or any name the user chose — a user-defined metric reports as the literal
"custom". Verified end-to-end against a live endpoint.Attribution is the workspace name, the same identifier already used for error reports, so an error and the usage around it describe the same user.
Shared with error tracking
get_user_identifier()moved toopik/environment.pyand the environment collectors toopik/environment_details.py. Analytics anderror_trackingnow both read them as peers, so the two payloads cannot drift and share asession_id. This fixes a live bug in Sentry reporting: a blank workspace filed every such install under one empty identifier.Turning it off
OPIK_ANALYTICS_ENABLE=falselocally.Remotely, the destination can retire the SDK — or one bad version — without a release and without a credential. This client sends
User-Agent: opik-python-sdk/<version>, so an edge rule can match the whole SDK or a single version, and a definitive rejection (401,403,404,410) stops the process: the worker exits andtrack_eventreturns on its first line.201410/403/401/404429/503No write key: revoking one would stop us accepting data but not stop the SDK sending it, and the same lever already exists server-side without a credential to ship or leak.
Reporting is also skipped under pytest, so test suites make no network calls.
Change checklist
Issues
AI-WATERMARK
AI-WATERMARK: yes
(no decorators, explicit payloads, the double-underscore separator, a single
opt-out, routing via comet-stats rather than Segment directly) were directed by
the author across review iterations rather than chosen by the model.
Every behavioural claim in this description was verified by running it — the
thread race, the fork cases, the blank-workspace identity and the batch-abort
were each reproduced before being fixed, and re-checked after.
Testing
test_payload_truncation.pyfailures are pre-existing onmainrelease,python_versionand identity intactOpen
opik_python_sdk__*rows from testing, underalexkuzmik-opik2andopik-python-sdk-connectivity-probe.Links
Documentation
sdk_configuration.mdxgains a Usage analytics section; theanalytics-instrumentationskill covers the Python SDK.🤖 Generated with Claude Code