Skip to content

Commit 6599ac0

Browse files
JetoPistolaclaude
andcommitted
[OPIK-7279] [SDK] fix: convert trace lifecycle to upsert-only
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>
1 parent ddf0f30 commit 6599ac0

11 files changed

Lines changed: 194 additions & 64 deletions

e2e/assert_journal.py

Lines changed: 46 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -41,25 +41,62 @@ def _flatten_items(payload):
4141

4242
def main() -> None:
4343
rows = _load()
44-
traces, spans = [], []
44+
traces, spans, updates = [], [], []
4545
for r in rows:
46+
kind = r.get("kind")
4647
items = _flatten_items(r.get("payload"))
47-
if r.get("kind") == "traces":
48+
if kind == "traces":
4849
traces += items
49-
elif r.get("kind") == "spans":
50+
elif kind == "spans":
5051
spans += items
51-
52-
print(f"journal: {len(rows)} rows | traces={len(traces)} spans={len(spans)}")
52+
elif kind == "update":
53+
updates.append(r.get("payload"))
54+
55+
# Upsert-only lifecycle: the create and the finalize arrive as two SEPARATE
56+
# trace-batch rows sharing one id (create carries name/thread/input; finalize
57+
# carries output/end_time). Merge by id so the checks below see the coalesced
58+
# trace, exactly as real Opik would store it.
59+
merged: dict = {}
60+
for t in traces:
61+
tid = t.get("id")
62+
if tid is None:
63+
merged.setdefault(id(t), {}).update(t)
64+
else:
65+
slot = merged.setdefault(tid, {})
66+
slot.update({k: v for k, v in t.items() if v is not None})
67+
merged_traces = list(merged.values())
68+
69+
print(
70+
f"journal: {len(rows)} rows | trace-batches={len(traces)} "
71+
f"merged-traces={len(merged_traces)} spans={len(spans)} updates={len(updates)}"
72+
)
5373

5474
errors = []
5575

76+
# 0. Upsert-only: finalize must NOT go through trace.update() (a PATCH the
77+
# mock records as an "update" row). Its presence means the plugin regressed
78+
# to the forbidden post-create mutation — the source of the SDK's
79+
# "Calling Trace.update() shortly after creation ... may cause data loss"
80+
# warning (OPIK-7279).
81+
if updates:
82+
errors.append(
83+
f"{len(updates)} trace.update() PATCH(es) — lifecycle must be "
84+
"upsert-only (same-id re-send), never trace.update()/end()"
85+
)
86+
5687
# 1. A root trace exists and is named (descriptive or the fallback).
57-
if not traces:
88+
if not merged_traces:
5889
errors.append("no traces captured")
5990
else:
60-
names = [t.get("name") for t in traces]
91+
names = [t.get("name") for t in merged_traces]
6192
if not any(n for n in names):
6293
errors.append(f"trace(s) have no name: {names}")
94+
# 1b. The finalize re-send landed: the trace carries output + end_time
95+
# (acceptance criteria — the trace still finalizes correctly).
96+
if not any(t.get("output") for t in merged_traces):
97+
errors.append("no trace carries output (finalize re-send missing)")
98+
if not any(t.get("end_time") for t in merged_traces):
99+
errors.append("no trace carries end_time (trace not finalized)")
63100

64101
# 2. At least one LLM span and one tool span, all with name+type (no NA).
65102
llm = [s for s in spans if s.get("type") == "llm"]
@@ -83,7 +120,8 @@ def main() -> None:
83120
sys.exit(1)
84121

85122
print("\n=== E2E PASSED ===")
86-
print(f" trace: {traces[0].get('name')!r}")
123+
named = next((t.get("name") for t in merged_traces if t.get("name")), None)
124+
print(f" trace: {named!r} (finalized via upsert, no trace.update PATCH)")
87125
print(f" llm spans: {len(llm)} | tool spans: {len(tool)} | NA: 0")
88126

89127

e2e/assert_real_opik.py

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -58,15 +58,20 @@ def main() -> None:
5858
# correctly-typed LLM + tool spans with no NA spans.
5959
errors = []
6060
# The trace name and thread_id are set only at creation. If the create
61-
# message coalesces with the finalize update()/end() in one batch window
62-
# (a fast turn), the trace lands with name=None/thread_id=None — an "NA"
63-
# trace. The plugin flushes the create to prevent this; assert it held.
61+
# message coalesces with the finalize re-send in one batch window (a fast
62+
# turn), the trace lands with name=None/thread_id=None — an "NA" trace. The
63+
# plugin flushes the create to prevent this; assert it held.
6464
if not t.get("name"):
6565
errors.append("trace has no name (NA trace — create/finalize batching race)")
6666
if not t.get("thread_id"):
6767
errors.append("trace has no thread_id (session grouping lost to the race)")
6868
if not t.get("end_time"):
6969
errors.append("trace not finalized (end_time is null)")
70+
# The finalize re-send (upsert: same id + output + end_time) must have
71+
# coalesced onto the trace — proves the upsert-only lifecycle landed the
72+
# finished payload, not just an open trace (OPIK-7279).
73+
if not t.get("output"):
74+
errors.append("trace has no output (finalize re-send did not land)")
7075
if not llm:
7176
errors.append("no llm spans")
7277
if not tool:

e2e/run_e2e.sh

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -88,14 +88,20 @@ if command -v timeout >/dev/null 2>&1; then TIMEOUT="timeout 180"; else TIMEOUT=
8888
# No runtime install (the isolated network has no internet). Do NOT run
8989
# `hermes plugins enable` (it prompts and would hang with no TTY). stdin from
9090
# /dev/null so any stray prompt gets EOF instead of blocking.
91+
# Capture the FULL Hermes output (not just the tail) so we can assert the Opik
92+
# SDK batching warning is absent — the upsert-only lifecycle must not trip it.
93+
HERMES_LOG="$WORK/hermes.log"
9194
$TIMEOUT docker run --rm --name e2e-hermes --network "$NET" \
9295
-e HERMES_UID=0 -e HERMES_GID=0 \
9396
-v "$HERMES_HOME:/opt/data" \
9497
"$E2E_IMAGE" \
9598
sh -c '
9699
hermes chat -q "Compute 2 to the power 10 and report the number." \
97-
--provider openai-api --model gpt-5 2>&1 | tail -20
98-
' < /dev/null || echo "(hermes turn exited non-zero / timed out; assertion judges from the journal)"
100+
--provider openai-api --model gpt-5 2>&1
101+
' < /dev/null > "$HERMES_LOG" 2>&1 \
102+
|| echo "(hermes turn exited non-zero / timed out; assertion judges from the journal)"
103+
tail -20 "$HERMES_LOG" || true
104+
cp "$HERMES_LOG" /tmp/opik-e2e-hermes.log 2>/dev/null || true
99105

100106
# Give the SDK background flush a moment to POST to mock-opik.
101107
sleep 3
@@ -104,5 +110,16 @@ sleep 3
104110
cp "$JOURNAL_DIR/opik-journal.jsonl" /tmp/opik-e2e-journal.jsonl 2>/dev/null || true
105111
echo "==> journal saved to /tmp/opik-e2e-journal.jsonl ($(wc -l < "$JOURNAL_DIR/opik-journal.jsonl" 2>/dev/null || echo 0) rows)"
106112

113+
# Assert the "may cause data loss" batching warning is absent from Hermes'
114+
# output — its presence means the plugin regressed to trace.update()/end()
115+
# shortly after create (OPIK-7279). Matched loosely so wording drift in the SDK
116+
# still catches it.
117+
echo "==> asserting no Opik batching warning in Hermes output"
118+
if grep -Ei "may cause data loss|Calling Trace\.update\(\) shortly after creation" "$HERMES_LOG"; then
119+
echo "=== E2E FAILED: Opik batching warning present (lifecycle not upsert-only) ==="
120+
exit 1
121+
fi
122+
echo " (no batching warning — lifecycle is upsert-only)"
123+
107124
echo "==> asserting journal"
108125
MOCK_OPIK_JOURNAL="$JOURNAL_DIR/opik-journal.jsonl" python3 "$REPO_ROOT/e2e/assert_journal.py"

e2e/run_e2e_real_opik.sh

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -103,11 +103,21 @@ docker build -q --build-arg HERMES_IMAGE="$HERMES_IMAGE" \
103103

104104
echo "==> running one Hermes turn (Opik is REAL)"
105105
if command -v timeout >/dev/null 2>&1; then TIMEOUT="timeout 180"; else TIMEOUT=""; fi
106+
HERMES_LOG="$WORK/hermes.log"
106107
$TIMEOUT docker run --rm --name e2e-real-hermes --network "$OPIK_NET" \
107108
-e HERMES_UID=0 -e HERMES_GID=0 -v "$HERMES_HOME:/opt/data" \
108109
opik-hermes-e2e:local \
109-
sh -c 'hermes chat -q "Compute 2 to the power 10 and report the number." --provider openai-api --model gpt-5 2>&1 | tail -15' \
110-
< /dev/null || echo "(hermes turn non-zero/timeout; assertion judges from Opik)"
110+
sh -c 'hermes chat -q "Compute 2 to the power 10 and report the number." --provider openai-api --model gpt-5 2>&1' \
111+
< /dev/null > "$HERMES_LOG" 2>&1 || echo "(hermes turn non-zero/timeout; assertion judges from Opik)"
112+
tail -15 "$HERMES_LOG" || true
113+
114+
# Upsert-only lifecycle must not trip the SDK batching warning (OPIK-7279).
115+
echo "==> asserting no Opik batching warning in Hermes output"
116+
if grep -Ei "may cause data loss|Calling Trace\.update\(\) shortly after creation" "$HERMES_LOG"; then
117+
echo "=== E2E FAILED: Opik batching warning present (lifecycle not upsert-only) ==="
118+
exit 1
119+
fi
120+
echo " (no batching warning — lifecycle is upsert-only)"
111121

112122
echo "==> letting the SDK flush, then querying the REAL Opik API"
113123
sleep 5

observability/opik/hooks.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -207,7 +207,8 @@ def on_post_llm_call(
207207
# (agent/turn_finalizer.py). It carries `assistant_response` but no
208208
# api_call_count, so it never matches a PendingGeneration. This is the
209209
# only reliable end-of-turn signal — finalize the root trace here,
210-
# otherwise the trace never .end()s and never surfaces as completed.
210+
# otherwise the trace never gets its finalize re-send (output +
211+
# end_time) and never surfaces as completed.
211212
if pending is None:
212213
if state is not None and assistant_response is not None:
213214
finish_trace(task_key, output={"content": safe_value(assistant_response)})

observability/opik/lifecycle.py

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
from __future__ import annotations
1010

11+
import datetime
1112
from typing import Any
1213

1314
from .client import get_client
@@ -51,7 +52,7 @@ def start_root_trace(
5152
)
5253
# NOTE: the caller flushes this create (via flush_trace_create) AFTER
5354
# releasing the state lock. name/thread_id/input are set only at creation, so
54-
# the create must not coalesce with the turn's later update()+end() in one
55+
# the create must not coalesce with the turn's later finalize re-send in one
5556
# batch window (a fast turn) or the trace lands NA (name=None/thread=None/
5657
# input=null) — the trace-level twin of the span NA-bug. flush() blocks on
5758
# the network, so it is deliberately kept out of the lock.
@@ -97,14 +98,21 @@ def finish_trace(task_key: str, *, output: Any = None) -> None:
9798
# for calls that never received a post (interrupted turn). They have no
9899
# span yet — an in-flight call with no response isn't a meaningful span,
99100
# so we simply drop them rather than emit a partial span.
101+
#
102+
# Upsert-only finalize: re-send the SAME trace id with the finished
103+
# payload (output + end_time) instead of trace.update()/trace.end(). The
104+
# SDK's batching layer coalesces this with the create into one final row.
105+
# An update() shortly after create trips the "may cause data loss"
106+
# warning; the upsert is the mandated pattern and avoids it. name/
107+
# thread_id/input were flushed with the create, so they are not re-sent.
100108
final_output = merge_trace_output(output, state)
101-
if final_output is not None:
102-
state.trace.update(
103-
output=final_output
104-
if isinstance(final_output, dict)
105-
else {"content": final_output}
106-
)
107-
state.trace.end()
109+
client.trace(
110+
id=state.trace.id,
111+
output=final_output
112+
if final_output is None or isinstance(final_output, dict)
113+
else {"content": final_output},
114+
end_time=datetime.datetime.now(datetime.timezone.utc),
115+
)
108116
except Exception as exc: # pragma: no cover - fail-open
109117
debug(f"finish trace failed: {exc}")
110118
finally:

observability/opik/state.py

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
from dataclasses import dataclass, field
1515
from typing import Any, Callable, Dict, Optional, Tuple
1616

17+
from .client import get_client
1718
from .config import debug
1819

1920
# Hard cap on live trace state. Each turn keys the store by a unique turn_id,
@@ -126,15 +127,23 @@ def evict_stale_locked() -> None:
126127
entry. Bounds the leak from turns that never reach ``finish_trace``
127128
(interrupted / tool-only final step / empty final content), whose unique
128129
per-turn key would otherwise linger forever. The evicted entry's trace is
129-
ended so it is not left dangling on the Opik side.
130+
finalized via an upsert re-send (same id + end_time) so it is not left
131+
dangling on the Opik side — never trace.end(), which is the forbidden
132+
post-create-mutation that trips the batching "may cause data loss" warning.
130133
"""
131134
over = len(store) - (MAX_TRACE_STATE - 1)
132135
if over <= 0:
133136
return
137+
client = get_client()
134138
stale = sorted(store.items(), key=lambda kv: kv[1].last_updated_at)[:over]
135139
for key, state in stale:
136140
store.pop(key, None)
141+
if client is None:
142+
continue
137143
try:
138-
state.trace.end()
144+
client.trace(
145+
id=state.trace.id,
146+
end_time=datetime.datetime.now(datetime.timezone.utc),
147+
)
139148
except Exception as exc: # pragma: no cover - fail-open
140149
debug(f"evict stale trace failed: {exc}")

tests/conftest.py

Lines changed: 33 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,12 @@ def __init__(self, events: list, trace_id: str, kwargs: dict):
6565
self._events = events
6666
self.id = trace_id
6767
self.create_kwargs = kwargs
68-
self.ended = False
68+
# A finalize re-send (upsert: same id, output+end_time) merges here.
69+
# `finalized` flips when the finish/evict re-send arrives, replacing the
70+
# old .end() bool. `updates` collects each finalize re-send's kwargs so
71+
# tests can assert the finished payload without a forbidden trace.update.
72+
self.finalized = False
73+
self.finalize_kwargs: dict = {}
6974
self.updates: list[dict] = []
7075
self.spans: list[FakeSpan] = []
7176

@@ -75,27 +80,46 @@ def span(self, **kwargs: Any) -> FakeSpan:
7580
self.spans.append(s)
7681
return s
7782

78-
def update(self, **kwargs: Any) -> None:
83+
def upsert(self, **kwargs: Any) -> None:
84+
"""Record a same-id re-send from client.trace(id=...) as a finalize."""
85+
self.finalized = True
86+
self.finalize_kwargs.update(kwargs)
7987
self.updates.append(kwargs)
80-
self._events.append(("trace.update", sorted(kwargs)))
88+
self._events.append(("trace.upsert", sorted(kwargs)))
8189

82-
def end(self, **kwargs: Any) -> None:
83-
self.ended = True
84-
self._events.append(("trace.end",))
90+
# Kept only so tests can prove the plugin NO LONGER calls these. The
91+
# upsert-only lifecycle must never touch trace.update()/trace.end().
92+
def update(self, **kwargs: Any) -> None: # pragma: no cover - must not be called
93+
raise AssertionError("trace.update() is forbidden; use an upsert re-send")
94+
95+
def end(self, **kwargs: Any) -> None: # pragma: no cover - must not be called
96+
raise AssertionError("trace.end() is forbidden; use an upsert re-send")
8597

8698

8799
class FakeOpik:
88-
"""Minimal stand-in for opik.Opik that records the lifecycle."""
100+
"""Minimal stand-in for opik.Opik that records the lifecycle.
101+
102+
``trace()`` models real Opik's upsert coalescing: a call carrying an ``id``
103+
that matches an existing trace merges into it (a finalize re-send) instead
104+
of minting a new row. A call with no ``id`` (or an unknown one) is a create.
105+
"""
89106

90107
def __init__(self, **_: Any):
91108
self.events: list = []
92109
self.traces: list[FakeTrace] = []
93110
self.flushed = 0
94111

95112
def trace(self, **kwargs: Any) -> FakeTrace:
96-
tid = f"trace-{len(self.traces) + 1}"
113+
tid = kwargs.get("id")
114+
if tid is not None:
115+
existing = next((t for t in self.traces if t.id == tid), None)
116+
if existing is not None:
117+
self.events.append(("client.trace", kwargs.get("name")))
118+
existing.upsert(**kwargs)
119+
return existing
120+
new_id = tid or f"trace-{len(self.traces) + 1}"
97121
self.events.append(("client.trace", kwargs.get("name")))
98-
t = FakeTrace(self.events, tid, kwargs)
122+
t = FakeTrace(self.events, new_id, kwargs)
99123
self.traces.append(t)
100124
return t
101125

tests/test_extra_hooks.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -132,9 +132,9 @@ def test_session_end_finalizes_open_traces_and_flushes(plugin):
132132
session_id="sX",
133133
turn_id="T1",
134134
)
135-
assert not plugin._fake.traces[0].ended
135+
assert not plugin._fake.traces[0].finalized
136136
plugin.on_session_end(session_id="sX")
137-
assert plugin._fake.traces[0].ended
137+
assert plugin._fake.traces[0].finalized
138138
assert plugin._fake.flushed >= 1
139139

140140

@@ -152,9 +152,9 @@ def test_session_end_finalizes_task_keyed_traces(plugin):
152152
)
153153
key = plugin.keys.trace_key("task-42", "sess-y", turn_id="T1")
154154
assert key.startswith("task:"), "precondition: turn is task-keyed"
155-
assert not plugin._fake.traces[0].ended
155+
assert not plugin._fake.traces[0].finalized
156156
plugin.on_session_end(session_id="sess-y")
157-
assert plugin._fake.traces[0].ended
157+
assert plugin._fake.traces[0].finalized
158158
assert plugin._fake.flushed >= 1
159159

160160

tests/test_keying_eviction.py

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ def test_concurrent_turns_dont_collide(plugin):
2929
assert a != b
3030

3131

32-
def test_lru_eviction_bounds_state_and_ends_evicted_trace(plugin):
32+
def test_lru_eviction_bounds_state_and_finalizes_evicted_trace(plugin):
3333
cap = plugin.state.MAX_TRACE_STATE
3434
# Open cap+5 distinct turns; each opens a root trace via pre_llm_request.
3535
for i in range(cap + 5):
@@ -43,6 +43,9 @@ def test_lru_eviction_bounds_state_and_ends_evicted_trace(plugin):
4343
)
4444
# State never exceeds the cap...
4545
assert len(plugin.state.store) <= cap
46-
# ...and evicted traces were ended (not left dangling on the Opik side).
47-
ended = [t for t in plugin._fake.traces if t.ended]
48-
assert len(ended) >= 5
46+
# ...and evicted traces were finalized via an upsert re-send (end_time,
47+
# same id), not the forbidden trace.end() — not left dangling on Opik.
48+
finalized = [t for t in plugin._fake.traces if t.finalized]
49+
assert len(finalized) >= 5
50+
assert all(t.finalize_kwargs.get("end_time") is not None for t in finalized)
51+
assert not any(e[0] in ("trace.update", "trace.end") for e in plugin._fake.events)

0 commit comments

Comments
 (0)