Skip to content

Commit 0c1ddb7

Browse files
committed
fix(tracing): stamp cubepi.run_id from agent active_run_id
Align span attribute cubepi.run_id with the business run id set by prompt/resume/respond (and returned to hosts). Stops minting a second tracer-private uuid when active_run_id is already set, so product UI and cubepi trace share one run identifier. OTel trace_id remains the JSONL shard key.
1 parent 15f4332 commit 0c1ddb7

6 files changed

Lines changed: 307 additions & 9 deletions

File tree

CHANGELOG.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Changed
11+
12+
- **`cubepi.run_id` now follows the agent business run id.** On
13+
`AgentStart`, the recorder stamps `agent.state.active_run_id` (the same
14+
string as `prompt(run_id=…)` / `Message.run_id`) onto every span in the
15+
activation. A tracer-private uuid is minted only as a fallback when
16+
`active_run_id` is unset (e.g. oneshot still mints its own session id).
17+
Hosts can filter traces with the same id they use for SSE, messages, and
18+
billing. OTel `trace_id` / `span_id` remain the tree identity; JSONL still
19+
shards by `trace_id`.
20+
1021
### Fixed
1122

1223
- **Durable HITL pauses now finalize traces as suspended rather than aborted.**

cubepi/tracing/recorder.py

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -190,7 +190,8 @@ class Recorder:
190190
"""Subscribe to agent + provider events and produce OTel spans.
191191
192192
Lifetime: one :class:`Recorder` per :meth:`Tracer.attach` call. The
193-
recorder maintains per-run state keyed by a generated run_id; one
193+
recorder maintains per-run state keyed by the business run_id
194+
(``agent.state.active_run_id`` when set, else a fallback uuid); one
194195
agent can host many sequential runs over the recorder's lifetime.
195196
"""
196197

@@ -572,7 +573,18 @@ def _on_agent_start(self) -> None:
572573
self._close_open_spans(self._run)
573574
self._sweep_tool_span_tokens(self._run)
574575

575-
run_id = str(uuid.uuid4())
576+
# Prefer the agent's business run id (prompt/resume/respond set
577+
# active_run_id before AgentStartEvent). Same string as
578+
# Message.run_id / host SSE ids — not a second tracer-private uuid.
579+
# Fallback only if active_run_id is unset (defensive / odd attach).
580+
agent_run_id: str | None = None
581+
agent = self._agent
582+
if agent is not None:
583+
try:
584+
agent_run_id = getattr(agent.state, "active_run_id", None)
585+
except Exception: # pragma: no cover — never break tracing
586+
agent_run_id = None
587+
run_id = agent_run_id if agent_run_id else str(uuid.uuid4())
576588
# Open the root invoke_agent span. Caller-context propagation
577589
# (parent_trace_id / parent_span_id from a host service) lands
578590
# here in a future run_scope feature.

dev/specs/2026-05-18-cubepi-tracing-design.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -585,7 +585,7 @@ Notation: **R** = Required, **CR** = Conditionally Required, **Rec** = Recommend
585585
| `gen_ai.conversation.id` | string | CR | `thread_id` |
586586
| `gen_ai.request.model` | string | CR | model id of first chat call (if pre-known) |
587587
| `error.type` | string | CR (on error) | see §12.3 |
588-
| `cubepi.run_id` | string | **R** | uuid generated at root open |
588+
| `cubepi.run_id` | string | **R** | agent `active_run_id` when set (host/`prompt` business run id); else uuid fallback at root open |
589589
| `cubepi.thread_id` | string | Rec | same as `gen_ai.conversation.id`, duplicated for `cubepi.*` namespace ergonomics |
590590
| `cubepi.agent.tools` | string[] | Opt | tool names registered |
591591
| `cubepi.agent.system_prompt.sha256` | string | Opt | first 16 hex of `sha256(system_prompt)` |
Lines changed: 214 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,214 @@
1+
# Unify Trace `cubepi.run_id` with Business Run ID
2+
3+
- Date: 2026-08-10
4+
- Status: Implemented (pending release)
5+
- Repos: cubepi (primary), cubeplex (consumer; almost no code change)
6+
- Related: `dev/specs/2026-05-18-cubepi-tracing-design.md` §10.1,
7+
`dev/specs/2026-08-04-tracing-hitl-suspension.md` (HITL: one business run,
8+
multiple `invoke_agent` activations)
9+
10+
## Goal
11+
12+
One **run** concept end-to-end.
13+
14+
The string that hosts put on `agent.prompt(run_id=...)`, that lands on
15+
`Message.run_id` / `cubepi_messages.run_id`, and that products use for SSE,
16+
cancel, steer, and billing **is the same string** stamped on every span as
17+
`cubepi.run_id`.
18+
19+
There is no second, tracer-private "trace run id".
20+
21+
## Context
22+
23+
Today two UUIDs coexist for one agent activation:
24+
25+
| Layer | Who mints | Example |
26+
|---|---|---|
27+
| Business / agent | host (`uuid7`) or `prompt` fallback (`uuid4().hex`) | cubeplex stream path, message ledger |
28+
| Tracing | recorder always `str(uuid.uuid4())` at `AgentStart` | span attr `cubepi.run_id`, stream log name |
29+
30+
That split was never a deliberate product rule that "business run ≠ trace run".
31+
History:
32+
33+
1. **2026-05-18** tracing Phase 1 defined `cubepi.run_id` as "uuid generated at
34+
root open" so JSONL could shard by run.
35+
2. **2026-05-19** PR #92 protected that attribute from being *clobbered by
36+
user metadata* (namespace → `cubepi.metadata.*`) — not a ban on adopting
37+
the agent run id.
38+
3. **2026-06-06** agent gained `prompt(run_id=...)` / `active_run_id` for the
39+
message ledger, fork, HITL. Tracing was never updated to read it.
40+
4. JSONL later **re-sharded by OTel `trace_id`**, so the original reason for a
41+
tracer-minted run id (file path) is already gone. Comments on the exporter
42+
still describe parent + subagent as same `trace_id`, different
43+
`cubepi.run_id`.
44+
45+
Symptom hosts hit in the wild: UI/API `run_id` cannot find the matching
46+
`cubepi trace` file; investigators rediscover the run via `conversation_id`.
47+
48+
## Concepts (normative)
49+
50+
```
51+
Business run_id ──► messages / SSE / billing / cubepi.run_id (span attr)
52+
OTel trace_id ──► span tree storage & CLI grouping (may hold multiple runs)
53+
OTel span_id ──► one span node
54+
```
55+
56+
- **Run** = one agent activation identity (`prompt` / `resume` / `respond`
57+
effective id, or oneshot session id). Product and tracing share it.
58+
- **Trace** = OTel distributed tree. Not a run. Nesting may put several runs
59+
under one `trace_id`.
60+
- **Span** = one node. Root `invoke_agent` starts a subtree; filtering "all
61+
spans of this run" uses the denormalized `cubepi.run_id` attribute (or a
62+
tree walk from the root `span_id`). The attribute must be the **business**
63+
id, not a second random UUID.
64+
65+
## Non-goals
66+
67+
- Continuing one OTel `trace_id` across HITL pause and resume (already out of
68+
scope per HITL suspension spec). Correlation across activations is the
69+
**shared business `run_id`** (and host metadata).
70+
- Changing JSONL sharding (stays `trace_id`).
71+
- Changing how hosts mint run ids (cubeplex keeps `uuid7()`).
72+
- Building a UI; only the id contract.
73+
- Removing the `cubepi.run_id` attribute name (keep it; change who fills it).
74+
75+
## Design
76+
77+
### 1. Recorder: prefer `agent.active_run_id`
78+
79+
On `AgentStartEvent``_on_agent_start`:
80+
81+
```text
82+
if attached agent has active_run_id set:
83+
run_id = that value
84+
else:
85+
run_id = str(uuid.uuid4()) # fallback only
86+
stamp CUBEPI_RUN_ID = run_id on root and all children (unchanged)
87+
```
88+
89+
Ordering already allows this: `Agent.prompt` / `resume` / `respond` set
90+
`active_run_id` **before** the loop emits `AgentStartEvent`. Recorder holds
91+
`self._agent` from `attach`.
92+
93+
No change to the PR #92 rule: user `tracing_context(metadata=...)` still lands
94+
under `cubepi.metadata.*` and **must not** overwrite `cubepi.run_id`. Hosts
95+
pass the business id through `agent.prompt(run_id=...)`, not through metadata.
96+
97+
### 2. Oneshot: keep minting, same semantic
98+
99+
`Tracer.oneshot` has no agent. It continues to mint a run id for that session.
100+
That id **is** the oneshot's run id (only activation id that exists), not a
101+
parallel "trace run" concept. Optional later: accept `run_id=` on oneshot for
102+
host correlation.
103+
104+
### 3. Nested subagents
105+
106+
Unchanged structure:
107+
108+
- Parent and child may share one OTel `trace_id` (existing nesting).
109+
- Each agent's activation has its own business `run_id` (child `prompt` with a
110+
fresh id, or host policy).
111+
- Spans of each activation carry **that** activation's id.
112+
113+
Do not force child spans to inherit the parent's business run id.
114+
115+
### 4. HITL pause / resume
116+
117+
One business `run_id` across pause and resume (already true on the agent).
118+
Each activation still opens a new `invoke_agent` root / may be a new
119+
`trace_id`. After this change both roots' spans carry the **same**
120+
`cubepi.run_id`, so `cubepi trace` filter-by-run-id finds both halves.
121+
122+
### 5. Stream recording path
123+
124+
`record_stream` writes `<run_id>.stream.jsonl`. After the change the filename
125+
uses the business id. Sanitize as today (`_safe_filename`). Collision risk is
126+
the same as "two concurrent activations with the same run_id", which hosts
127+
already must not do for the ledger.
128+
129+
### 6. Spec text update
130+
131+
In the tracing design §10.1, change:
132+
133+
| Attribute | Source (old) | Source (new) |
134+
|---|---|---|
135+
| `cubepi.run_id` | uuid generated at root open | agent `active_run_id` when set; else generated fallback |
136+
137+
### 7. cubeplex
138+
139+
Already passes `run_id` into `agent.prompt`. No required change for alignment.
140+
141+
Optional follow-ups (separate PRs, not required for the contract):
142+
143+
- Message action **Info** chip: document that the id is the cubepi/trace run id
144+
(already true once this lands).
145+
- Admin traces filter: accept the same id.
146+
147+
## Change list (implementation)
148+
149+
### cubepi
150+
151+
| Area | Change |
152+
|---|---|
153+
| `cubepi/tracing/recorder.py` | `_on_agent_start`: resolve run id from `self._agent` active run; fallback uuid |
154+
| `cubepi/tracing/tracer.py` | oneshot: document that minted id *is* the run id; optional `run_id=` later |
155+
| `dev/specs/2026-05-18-cubepi-tracing-design.md` | §10.1 source line for `cubepi.run_id` |
156+
| Tests | See below |
157+
| Changelog | breaking-ish note for anyone who stored old tracer-minted ids |
158+
159+
Public API surface: no new functions required. Behavior change only.
160+
161+
### Tests (invariants)
162+
163+
1. **`prompt(run_id="host-1")` → all spans `cubepi.run_id == "host-1"`**
164+
(root, turn, chat, tool if any).
165+
2. **`prompt()` without run_id → spans share one non-empty id; equals returned
166+
run id from `prompt`.**
167+
(Today `prompt` returns `effective_run_id`; tracing must match that, not a
168+
third uuid — so fallback should use the same source as the agent, not a
169+
second `uuid4()` in the recorder.)
170+
171+
**Important refinement:** when the agent already chose `effective_run_id`
172+
(including self-minted `uuid.uuid4().hex`), the recorder must **read that**,
173+
not mint again. Fallback self-mint only if `active_run_id` is unexpectedly
174+
unset (defensive).
175+
3. **Metadata cannot clobber** (existing PR #92 test still passes).
176+
4. **Subagent:** parent and child different business ids → different
177+
`cubepi.run_id` on their respective span subtrees; same `trace_id` when
178+
nested.
179+
5. **Sequential runs** on one attached agent: run A then run B → no id leak.
180+
6. **Oneshot:** still produces a non-empty `cubepi.run_id` on root + chat.
181+
182+
### Migration / compatibility
183+
184+
- Old JSONL files keep historical tracer-minted ids; no migration.
185+
- Operators who bookmarked old `cubepi.run_id` values for in-flight runs will
186+
not match new spans; only new activations align.
187+
- CLI filters (`cubepi trace ls` by run id attribute) start matching host ids
188+
after upgrade — that is the intended win.
189+
190+
## Success criteria
191+
192+
1. Given cubeplex (or any host) `run_id=R` on `prompt`, every span of that
193+
activation has `cubepi.run_id=R`.
194+
2. `cubepi trace` lookup by the id shown in the product UI finds that run's
195+
spans (for agent activations; oneshot still uses its own id).
196+
3. No second uuid is minted by the recorder when `active_run_id` is set.
197+
4. JSONL layout, OTel parentage, and metadata namespacing remain unchanged.
198+
5. Existing "metadata cannot clobber reserved attrs" tests stay green.
199+
200+
## Open points (non-blocking)
201+
202+
- Expose `run_id=` on `Tracer.oneshot` for host-supplied correlation of
203+
memory/background jobs.
204+
- Whether `AgentStartEvent` should carry `run_id` explicitly (nice for pure
205+
observers; not required if recorder reads `active_run_id`).
206+
- Admin UI deep-link from message Info chip → traces filtered by run id
207+
(cubeplex product; after this lands).
208+
209+
## Decision needed before implement
210+
211+
None for the core path: **one run concept; recorder adopts `active_run_id`.**
212+
213+
Approve this draft → implement in cubepi (tests first) → bump cubeplex's
214+
cubepi pin when released.

tests/tracing/test_recorder.py

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1478,6 +1478,63 @@ async def test_metadata_cannot_clobber_reserved_cubepi_attrs(self):
14781478
assert attrs.get("cubepi.metadata.turn.index") == "x"
14791479

14801480

1481+
class TestBusinessRunIdAlignment:
1482+
"""cubepi.run_id must equal the agent/host business run id.
1483+
1484+
See dev/specs/2026-08-10-unify-trace-run-id.md — no second tracer-
1485+
private uuid when active_run_id is set.
1486+
"""
1487+
1488+
async def test_host_run_id_stamped_on_all_spans(self):
1489+
agent, provider, exporter, tracer = await _build()
1490+
provider.append_responses([faux_assistant_message("ok")])
1491+
1492+
returned = await agent.prompt("x", run_id="host-run-42")
1493+
await agent.wait_for_idle()
1494+
await tracer.shutdown()
1495+
1496+
assert returned == "host-run-42"
1497+
assert exporter.spans
1498+
run_ids = {_attrs(s).get("cubepi.run_id") for s in exporter.spans}
1499+
assert run_ids == {"host-run-42"}
1500+
1501+
async def test_agent_minted_run_id_matches_prompt_return(self):
1502+
agent, provider, exporter, tracer = await _build()
1503+
provider.append_responses([faux_assistant_message("ok")])
1504+
1505+
returned = await agent.prompt("x")
1506+
await agent.wait_for_idle()
1507+
await tracer.shutdown()
1508+
1509+
assert returned
1510+
run_ids = {_attrs(s).get("cubepi.run_id") for s in exporter.spans}
1511+
assert run_ids == {returned}
1512+
1513+
async def test_sequential_runs_do_not_leak_run_id(self):
1514+
agent, provider, exporter, tracer = await _build()
1515+
provider.append_responses(
1516+
[
1517+
faux_assistant_message("a"),
1518+
faux_assistant_message("b"),
1519+
]
1520+
)
1521+
1522+
r1 = await agent.prompt("one", run_id="run-a")
1523+
await agent.wait_for_idle()
1524+
r2 = await agent.prompt("two", run_id="run-b")
1525+
await agent.wait_for_idle()
1526+
await tracer.shutdown()
1527+
1528+
assert r1 == "run-a" and r2 == "run-b"
1529+
by_run: dict[str, int] = {}
1530+
for s in exporter.spans:
1531+
rid = _attrs(s).get("cubepi.run_id")
1532+
assert rid in ("run-a", "run-b")
1533+
by_run[rid] = by_run.get(rid, 0) + 1
1534+
assert by_run.get("run-a", 0) >= 1
1535+
assert by_run.get("run-b", 0) >= 1
1536+
1537+
14811538
class TestLifecycle:
14821539
async def test_shutdown_is_idempotent(self):
14831540
agent, provider, exporter, tracer = await _build()

website/docs/guides/tracing/getting-started.md

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -85,9 +85,12 @@ The run produces one JSONL file per trace (sharded by `trace_id`):
8585
8e1c9a3f4b2d…d976a.jsonl ← one trace, one file, one span per line
8686
```
8787

88-
A trace is the whole run, including any nested subagent runs (they inherit the
89-
parent's `trace_id`, so they land in the same file). Each span still carries
90-
`cubepi.run_id` as an attribute if you want to filter by individual run.
88+
A trace is the whole OTel tree, including any nested subagent runs (they
89+
inherit the parent's `trace_id`, so they land in the same file). Each span
90+
also carries `cubepi.run_id` — the **same** id as `agent.prompt(run_id=…)` /
91+
`Message.run_id` (hosts that pass a run id will see it here; otherwise the
92+
agent-minted id). Use it to filter by individual business run inside a
93+
trace that may hold several nested activations.
9194

9295
Open it with any tool that reads OTLP/JSON or with `jq` directly:
9396

@@ -164,9 +167,10 @@ than silently disappearing.
164167
Defaults (no opt-in needed):
165168

166169
- `invoke_agent` (root) — `gen_ai.operation.name`, `gen_ai.provider.name`,
167-
`gen_ai.agent.name`, `cubepi.run_id`, `cubepi.agent.system_prompt.sha256`,
168-
`cubepi.agent.tools` (names list), `cubepi.input_messages.count`,
169-
`cubepi.output_messages.count`
170+
`gen_ai.agent.name`, `cubepi.run_id` (business run id from
171+
`prompt`/`resume`/`respond`, not a separate tracer uuid),
172+
`cubepi.agent.system_prompt.sha256`, `cubepi.agent.tools` (names list),
173+
`cubepi.input_messages.count`, `cubepi.output_messages.count`
170174
- `cubepi.turn``cubepi.turn.index`, `cubepi.turn.stop_reason`,
171175
`cubepi.turn.tool_calls.count`, `cubepi.turn.terminated_by_tool`,
172176
`cubepi.run_id`

0 commit comments

Comments
 (0)