Skip to content

[OPIK-7279] [SDK] fix: convert trace lifecycle to upsert-only (no trace.update()/end()) - #25

Merged
JetoPistola merged 2 commits into
mainfrom
danield/OPIK-7279-trace-upsert-only
Jul 10, 2026
Merged

[OPIK-7279] [SDK] fix: convert trace lifecycle to upsert-only (no trace.update()/end())#25
JetoPistola merged 2 commits into
mainfrom
danield/OPIK-7279-trace-upsert-only

Conversation

@JetoPistola

@JetoPistola JetoPistola commented Jul 9, 2026

Copy link
Copy Markdown
Collaborator

Details

image

QA of opik-hermes 0.1.1 surfaced the Opik SDK warning on every Hermes message:

OPIK: Calling Trace.update() shortly after creation with batching enabled may cause data loss.

That warning is the symptom of violating the codebase-wide upsert-only rule for Opik integrations: when an entity starts, send the partial (started) entity; when it finishes, re-send the same entity (same id) with the full/finished payload and let the SDK's batching layer coalesce the two sends into one row. An update()/end() shortly after create is the anti-pattern. So this is not "silence a warning" — it's adopting the upsert pattern for the trace lifecycle, which removes the warning as a side effect.

Spans were already upsert-compliant (created fully-formed in a single trace.span() call) and are left untouched — only the trace lifecycle needed the change.

Changes

  • observability/opik/lifecycle.pyfinish_trace now finalizes via a single client.trace(id=state.trace.id, ...) re-send instead of trace.update(output=...) + trace.end(). The re-send replays the full create payload (name / thread_id / input / start_time / metadata / tags) plus output + end_timenot just output+end_time. client.trace(id=...) builds a whole CreateTraceMessage, so any omitted field is sent as null and the backend's last-write-wins merge would clobber the create, landing an NA trace (name/thread_id → None). start_time is pinned at create time so the re-send carries the same value, not a fresh now().
  • observability/opik/state.py — carries the create kwargs on TraceState.create_kwargs; evict_stale_locked finalizes evicted traces via the same full-payload upsert re-send (+ end_time) instead of trace.end(); imports get_client and fails open if no client is available.
  • observability/opik/hooks.py — comment refresh only, no behavior change.

The create is still flushed as its own batch before the finalize re-send, so the NA-trace race (name/thread/input lost to batch coalescing on a fast turn) remains guarded.

Change checklist

  • User facing
  • Documentation updated (if needed)
  • Tests added/updated (if needed)
  • Breaking changes documented (if any)

Issues

  • OPIK-7279

Testing

  • Unit (102 pass): FakeOpik.trace() now models real Opik's upsert coalescing — a call carrying a known id merges into the existing trace as a finalize re-send rather than minting a new row. FakeTrace.update()/.end() now raise, so any regression back to the forbidden pattern fails loudly. Added test_finalize_is_upsert_not_update_or_end and test_finalize_resend_replays_full_create_payload (guards the NA-trace regression: the finalize upsert must replay the full create payload, not just output+end_time).
  • E2E (e2e/run_e2e.sh, e2e/run_e2e_real_opik.sh): capture full Hermes output and assert the may cause data loss warning is absent. assert_journal.py merges trace rows last-write-wins including nulls (mirroring real Opik) and asserts the finalized trace still carries name + thread_id + output + end_time with zero trace.update() PATCHes — so a partial re-send that nulls name/thread is caught. Verified to pass the fix and fail the old pattern.
  • Manual staging verification (controlled A/B): ran one real agent turn (real model) against a live Opik backend with the unfixed plugin vs. this branch, same harness. Unfixed emits the batching warning twice (Trace.update() + Trace.end()); this branch emits zero. The finalized trace queried back from the backend carried a real name/thread_id/input/output/end_time with no NA trace and clean llm+tool spans (no NA spans). This confirms the warning-absence check genuinely surfaces the warning (it fired on old code) rather than silently missing it.
  • Local ruff check / ruff format --check / pytest all green (matches CI).

Documentation

N/A — no user-facing docs. Behavior change is internal to the plugin's trace lifecycle; the upsert-only rule it adopts is the existing codebase-wide integration convention.

QA of opik-hermes 0.1.1 surfaced the Opik SDK warning on every Hermes
message: "Calling Trace.update() shortly after creation with batching
enabled may cause data loss." That warning is the symptom of violating
the codebase-wide upsert-only rule for integrations: when an entity
finishes, re-send the same id with the finished payload rather than
mutating it via update()/end().

- lifecycle.py finish_trace: finalize via a single client.trace(id=...,
  output=..., end_time=...) re-send instead of trace.update()+trace.end().
- state.py evict_stale_locked: finalize evicted traces via the same upsert
  (id + end_time) instead of trace.end(); fail-open if no client.
- Spans were already upsert-compliant and are left untouched.

Tests: FakeOpik.trace() now models real Opik's upsert coalescing; FakeTrace
update()/end() raise so a regression fails loudly. E2E asserts the batching
warning is absent and the finalized trace carries output + end_time.

Implements OPIK-7279.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@JetoPistola
JetoPistola marked this pull request as ready for review July 9, 2026 18:04
Comment thread e2e/run_e2e.sh
The real-Opik E2E caught an NA-trace regression: the finalize upsert
re-sent only id+output+end_time. But client.trace(id=...) builds a full
CreateTraceMessage — the omitted name/thread_id/input went as null and the
backend's last-write-wins merge clobbered the create, landing an NA trace
(name=None/thread_id=None). start_time also drifted to a fresh now().

The SDK's own Trace.update() docstring documents the fix: re-send the FULL
payload with the same id. So capture the create kwargs on TraceState
(pinning start_time) and replay them + output/end_time on both the finish
and eviction re-sends.

Also made the mock-Opik journal assertion faithful to real Opik
(last-write-wins including nulls) so the cheaper per-PR E2E now reproduces
and catches this NA regression, and added unit guards asserting the
finalize re-send carries name/thread_id/input and the create's start_time.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Comment thread observability/opik/lifecycle.py
@JetoPistola
JetoPistola merged commit e3b70bc into main Jul 10, 2026
10 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant