Sitelet https://github.com/comet-ml/opik/pull/7959
Skip to content

[OPIK-8061] [SDK] feat: report anonymous usage analytics from the Python SDK - #7959

Merged
alexkuzmik merged 16 commits into
mainfrom
aliaksandrk/OPIK-NA-python-sdk-bi-analytics
Aug 25, 2026
Merged

[OPIK-8061] [SDK] feat: report anonymous usage analytics from the Python SDK#7959
alexkuzmik merged 16 commits into
mainfrom
aliaksandrk/OPIK-NA-python-sdk-bi-analytics

Conversation

@alexkuzmik

@alexkuzmik alexkuzmik commented Aug 21, 2026

Copy link
Copy Markdown
Collaborator

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 from track_openai or track_anthropic, or whether anyone runs evaluate().

One line, anywhere:

analytics.track_event("integration", "openai")
analytics.track_event("evaluation", "metric_created", metric=name)

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:

Before After
200 calls across 16 threads 16 events 1
Event in a forked child silently lost reported
Parent's event, re-reported by child not repeated
Blank OPIK_WORKSPACE all users share "" hostname hash
evaluate_threadssearch_threads counted once
get_or_create_datasetget_dataset counted once

A spawn pool still reports one copy per worker — separate interpreters cannot share state. Unique users stay correct (one anonymous_id, four session_ids for four workers), so dashboards must count uniq(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 to opik/environment.py and the environment collectors to opik/environment_details.py. Analytics and error_tracking now both read them as peers, so the two payloads cannot drift and share a session_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=false locally.

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 and track_event returns on its first line.

Destination answers Requests from a 30-event process Reporting
201 30 continues
410 / 403 / 401 / 404 1 stops
429 / 503 30 continues — try later is not stop

No 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

  • User facing
  • Documentation update

Issues

  • Resolves OPIK-8061

AI-WATERMARK

AI-WATERMARK: yes

  • Tools: Claude Code
  • Model(s): Claude Opus 5
  • Scope: implementation, tests, docs, and the PostHog dashboard. Design decisions
    (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.
  • Human verification: author reviewed each iteration and drove the scope changes.
    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

  • 64 new tests across counting, concurrency, fork, suppression, event names, identity, rejection handling and the worker contract
  • Full suite: 4,500 passing. Two test_payload_truncation.py failures are pre-existing on main
  • Verified live: events reach PostHog with release, python_version and identity intact

Open

  • Volume check with whoever owns comet-stats. The backend sends a few events per installation; this sends up to 97 per user process. Rate limits aren't visible from the SDK side.
  • Probe events to clean up — a handful of opik_python_sdk__* rows from testing, under alexkuzmik-opik2 and opik-python-sdk-connectivity-probe.
  • SDK events key on workspace; the UI and backend key on username, so they won't join per user.

Links

Documentation

sdk_configuration.mdx gains a Usage analytics section; the analytics-instrumentation skill covers the Python SDK.

🤖 Generated with Claude Code

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>
@github-actions github-actions Bot added documentation Improvements or additions to documentation python Pull requests that update Python code tests Including test files, or tests related like configuration. Python SDK labels Aug 21, 2026
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>
@github-actions

github-actions Bot commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

⏱️ pre-commit per-hook timing

Hook Description Result Duration
🌐 typecheck — frontend Whole-project tsc type check 32.25s
⚓ helm-docs Regenerate Helm chart README 10.13s
🌐 eslint — frontend Lint + autofix JS/TS 7.62s
☕ spotless — java backend Format Java code 5.77s
🐍 mypy — python sdk Static type check 1.61s
⚙️ actionlint — github workflows Lint GitHub Actions workflows 1.31s
🌈 zizmor — github workflows security Security-scan GitHub Actions workflows 0.14s
🐍 fix end of files — python sdk Ensure files end in a newline 0.04s
🐍 trim trailing whitespace — python sdk Strip trailing whitespace 0.04s
🐍 ruff-format — python sdk Format Python code (ruff) 0.03s
🐍 ruff — python sdk Lint + autofix Python (ruff) 0.02s
Total (11 ran) 58.96s
⏭️ 32 skipped (no matching files changed)
Hook Description Result
🤖 trim trailing whitespace — optimizer Strip trailing whitespace ⏭️
🤖 fix end of files — optimizer Ensure files end in a newline ⏭️
🤖 check yaml — optimizer Validate YAML syntax ⏭️
🤖 check json — optimizer Validate JSON syntax ⏭️
🤖 check toml — optimizer Validate TOML syntax ⏭️
🤖 check for added large files — optimizer Block large files (>1MB) ⏭️
🔐 detect private key — optimizer Block committed private keys ⏭️
🤖 check for merge conflicts — optimizer Block merge-conflict markers ⏭️
🤖 check for case conflicts — optimizer Block case-only name clashes ⏭️
🤖 pyupgrade — optimizer Modernize Python syntax ⏭️
🤖 ruff — optimizer Lint + autofix Python (ruff) ⏭️
🤖 ruff-format — optimizer Format Python code (ruff) ⏭️
🤖 mypy — optimizer Static type check ⏭️
📓 nbstripout — optimizer notebooks Strip notebook output ⏭️
📝 markdownlint — optimizer Lint Markdown ⏭️
🔤 codespell — optimizer Fix common misspellings ⏭️
📊 radon cc — optimizer Cyclomatic-complexity gate ⏭️
📊 radon raw — optimizer Raw size metrics gate ⏭️
📊 xenon — optimizer Fail on complexity thresholds ⏭️
📊 lizard — optimizer Cyclomatic-complexity gate ⏭️
🧹 vulture — optimizer Find dead code ⏭️
🛡️ trim trailing whitespace — guardrails Strip trailing whitespace ⏭️
🛡️ fix end of files — guardrails Ensure files end in a newline ⏭️
🛡️ ruff — guardrails Lint + autofix Python (ruff) ⏭️
🛡️ ruff-format — guardrails Format Python code (ruff) ⏭️
🛡️ mypy — guardrails Static type check ⏭️
block non-public FE plugins Block non-public FE plugins ⏭️
🧪 pre-commit wrapper smoke tests Self-test the wrapper scripts ⏭️
🧪 rebaseline script tests Self-test the changelog re-baseline script ⏭️
📘 eslint — typescript sdk Lint + autofix JS/TS ⏭️
📘 typecheck — typescript sdk Whole-project tsc type check ⏭️
🐳 hadolint — dockerfiles Lint Dockerfiles ⏭️

@github-actions

github-actions Bot commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

🌿 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)
↳ on page: /docs/opik/development/optimization-runs/optimization/configure_models
https://console.cloud.google.com/iam-admin/iam (401)
↳ on page: /docs/opik/administration/workspace-settings/ai_providers
https://console.cloud.google.com/iam-admin/roles (401)
↳ on page: /docs/opik/administration/workspace-settings/ai_providers
https://console.cloud.google.com/iam-admin/serviceaccounts (401)
↳ on page: /docs/opik/administration/workspace-settings/ai_providers
https://console.mistral.ai/api-keys/ (timeout)
↳ on page: /docs/opik/integrations/mistral
https://console.x.ai/ (403)
↳ on page: /docs/opik/integrations/xai-grok
https://docs.predibase.com/integrations/comet (403)
↳ on page: /docs/opik/integrations/predibase
https://portal.azure.com/ (403)
↳ on page: /docs/opik/administration/workspace-settings/ai_providers
https://x.ai/ (403)
↳ on page: /docs/opik/integrations/xai-grok


📌 Results for commit a7944c1

Comment thread sdks/python/src/opik/analytics/api.py Outdated
Comment thread sdks/python/src/opik/analytics/api.py Outdated
Comment thread sdks/python/src/opik/analytics/api.py Outdated
Comment thread sdks/python/src/opik/analytics/api.py Outdated
Comment thread sdks/python/src/opik/analytics/api.py
Comment thread sdks/python/src/opik/analytics/comet_stats.py
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>
Comment thread .agents/skills/analytics-instrumentation/SKILL.md
Comment thread .agents/skills/analytics-instrumentation/SKILL.md Outdated
Comment thread .agents/skills/analytics-instrumentation/SKILL.md Outdated
alexkuzmik and others added 2 commits August 24, 2026 13:46
- 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>
@alexkuzmik alexkuzmik changed the title [NA] [SDK] feat: report anonymous usage analytics from the Python SDK [OPIK-8061] [SDK] feat: report anonymous usage analytics from the Python SDK Aug 24, 2026
Comment thread sdks/python/tests/unit/analytics/test_review_fixes.py Outdated
Comment thread sdks/python/src/opik/analytics/rules.py
Comment thread sdks/python/src/opik/analytics/api.py
alexkuzmik and others added 2 commits August 24, 2026 14:49
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>
@alexkuzmik
alexkuzmik marked this pull request as ready for review August 24, 2026 15:11
@alexkuzmik
alexkuzmik requested review from a team as code owners August 24, 2026 15:11
@CometActions

CometActions commented Aug 24, 2026

Copy link
Copy Markdown
Collaborator

No test needed here.

Every product-code line here is an additive analytics.track_event(...) call plus a few # type: ignore comments — no trace, span, experiment or REST response changes shape, so nothing an Opik page renders differs after this merges. The new behaviour is real but outside the e2e estate's reach: taxonomy.yaml lists sdks/python as untracked surface with no capability to tag, and rules._not_running_tests switches reporting off under pytest, so a test would have to defeat the rule it is testing. Your own sdks/python/tests/unit/analytics/ suite already pins the parts that matter — test_rules.py covers the OPIK_ANALYTICS_ENABLE=false opt-out, the empty-URL kill switch and the default collector, and test_event_semantics.py covers what is reported and that a user's own metric class name is not. Nothing further needed from QA.

Run

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.

Comment thread sdks/python/src/opik/analytics/comet_stats.py
Comment on lines +147 to +154
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()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

Severity

Want Baz to fix this for you? Activate Fixer

Other fix methods

Fix in Cursor

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.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread sdks/python/src/opik/integrations/bedrock/opik_tracker.py
Comment thread sdks/python/src/opik/integrations/bedrock/opik_tracker.py Outdated
Comment thread sdks/python/tests/unit/analytics/test_rejection.py Outdated
Comment thread sdks/python/tests/unit/analytics/test_rejection.py Outdated
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>
@alexkuzmik
alexkuzmik requested review from a team as code owners August 24, 2026 15:54
Comment thread .github/workflows/e2e_tests_post_merge_v2.yml
Comment thread .github/workflows/installation_tests.yml Outdated
…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>
Comment thread sdks/python/tests/unit/message_processing/test_payload_truncation.py Outdated
Comment thread sdks/python/src/opik/analytics/worker.py Outdated
Comment thread sdks/python/src/opik/analytics/api.py Outdated
Comment thread sdks/python/src/opik/analytics/api.py Outdated
Comment thread sdks/python/src/opik/api_objects/opik_client.py
alexkuzmik and others added 2 commits August 24, 2026 21:22
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>
Comment thread .github/workflows/end2end_suites_v2.yml
Comment thread sdks/python/tests/unit/analytics/test_rejection.py Outdated
Comment thread sdks/python/tests/unit/analytics/test_event_semantics.py
Comment thread sdks/python/src/opik/analytics/api.py
alexkuzmik and others added 3 commits August 24, 2026 21:52
`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>
Comment on lines +33 to +39
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,
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

Severity

Want Baz to fix this for you? Activate Fixer

Other fix methods

Fix in Cursor

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.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread sdks/python/src/opik/evaluation/metrics/base_metric.py
Comment thread sdks/python/tests/unit/analytics/test_rejection.py
Comment thread sdks/python/src/opik/analytics/api.py
Comment thread sdks/python/src/opik/analytics/api.py
Comment thread sdks/python/src/opik/analytics/api.py
@github-actions

github-actions Bot commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

TS SDK E2E Tests - Node 18

317 tests   315 ✅  15m 42s ⏱️
 38 suites    2 💤
  1 files      0 ❌

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>
Comment thread sdks/python/src/opik/analytics/api.py
petrotiurin
petrotiurin previously approved these changes Aug 25, 2026

@petrotiurin petrotiurin left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Other than this, it looks really good. Thanks for addressing the feedback.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>
Comment on lines +81 to +83
sender = comet_stats.Sender(url="https://collector.invalid/notify/event/")
try:
assert sender._client._transport._pool._ssl_context.verify_mode is not None

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

Severity

Want Baz to fix this for you? Activate Fixer

Other fix methods

Fix in Cursor

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.

@alexkuzmik
alexkuzmik merged commit 493df33 into main Aug 25, 2026
216 of 222 checks passed
@alexkuzmik
alexkuzmik deleted the aliaksandrk/OPIK-NA-python-sdk-bi-analytics branch August 25, 2026 12:33
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

baz: pending documentation Improvements or additions to documentation Infrastructure Python SDK python Pull requests that update Python code 🔴 size/XL tests Including test files, or tests related like configuration.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants