Skip to content

Commit b90e9e8

Browse files
committed
fix(sessions): model span_id as a 16-hex OTel id, not a UUID
The record-ingest span_id is an OpenTelemetry span id (64-bit / 16 hex chars), not a UUID (128-bit / 32 hex). Typed as Optional[UUID], pydantic rejected every runner ingest with a 422 and session records silently dropped. Model it as what it is end to end: a validated 16-hex string (OTelSpanId) on the API model, core DTOs, and DAO column; varchar in the two unreleased migrations. trace_id stays UUID (genuinely the 32-hex OTel trace id). Adds contract + backfill + multi-turn tests. Claude-Session: https://claude.ai/code/session_01DnWRxU3dCJ11hgDidm26vW
1 parent dc7fc00 commit b90e9e8

15 files changed

Lines changed: 291 additions & 21 deletions

File tree

api/oss/databases/postgres/migrations/core_oss/versions/oss000000014_add_session_turns.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,8 @@ def upgrade() -> None:
3535
nullable=True,
3636
),
3737
sa.Column("trace_id", sa.UUID(as_uuid=True), nullable=True),
38-
sa.Column("span_id", sa.UUID(as_uuid=True), nullable=True),
38+
# OTel span id: 64-bit / 16 hex chars, NOT a UUID (128-bit / 32 hex). Text, not UUID.
39+
sa.Column("span_id", sa.String(), nullable=True),
3940
sa.Column("start_time", sa.TIMESTAMP(timezone=True), nullable=True),
4041
sa.Column("end_time", sa.TIMESTAMP(timezone=True), nullable=True),
4142
sa.Column(

api/oss/databases/postgres/migrations/tracing_oss/versions/oss000000004_add_records_turn_span.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,8 @@ def upgrade() -> None:
2323
# nullable and forward-fill only — the tracing DB is never backfilled/data-migrated;
2424
# existing rows carry no turn key to reconstruct one from, so they stay null.
2525
op.add_column("records", sa.Column("turn_id", sa.String(), nullable=True))
26-
op.add_column("records", sa.Column("span_id", sa.UUID(), nullable=True))
26+
# OTel span id: 64-bit / 16 hex chars, NOT a UUID (128-bit / 32 hex). Text, not UUID.
27+
op.add_column("records", sa.Column("span_id", sa.String(), nullable=True))
2728

2829
op.create_index(
2930
"ix_records_project_id_session_id_turn_id",

api/oss/src/apis/fastapi/sessions/models.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
)
1919
from oss.src.core.sessions.mounts.dtos import SessionMount, SessionMountQuery
2020
from oss.src.core.sessions.turns.dtos import HarnessKind, SessionTurn, SessionTurnQuery
21-
from oss.src.core.shared.dtos import Reference, Windowing
21+
from oss.src.core.shared.dtos import OTelSpanId, Reference, Windowing
2222

2323

2424
# ---------------------------------------------------------------------------
@@ -170,7 +170,7 @@ class SessionTurnAppendRequest(BaseModel):
170170
sandbox_id: Optional[str] = None
171171
references: Optional[List[Reference]] = None
172172
trace_id: Optional[UUID] = None
173-
span_id: Optional[UUID] = None
173+
span_id: Optional[OTelSpanId] = None
174174
start_time: Optional[datetime] = None
175175
end_time: Optional[datetime] = None
176176

@@ -208,4 +208,4 @@ class SessionRecordIngestRequest(BaseModel):
208208
# The turn this record belongs to; span_id bridges to observability when available.
209209
# Both forward-fill only (tracing-DB rule) — absent on producers that predate this.
210210
turn_id: Optional[str] = None
211-
span_id: Optional[UUID] = None
211+
span_id: Optional[OTelSpanId] = None

api/oss/src/core/sessions/records/dtos.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55
from pydantic import BaseModel
66

7-
from oss.src.core.shared.dtos import Lifecycle
7+
from oss.src.core.shared.dtos import Lifecycle, OTelSpanId
88

99

1010
class SessionRecordEvent(BaseModel):
@@ -20,7 +20,7 @@ class SessionRecordEvent(BaseModel):
2020

2121
# Forward-fill only (tracing-DB rule): populated on new records, null on old ones.
2222
turn_id: Optional[str] = None
23-
span_id: Optional[UUID] = None
23+
span_id: Optional[OTelSpanId] = None
2424

2525

2626
class SessionRecord(Lifecycle):
@@ -36,7 +36,7 @@ class SessionRecord(Lifecycle):
3636
attributes: Optional[Dict[str, Any]] = None
3737

3838
turn_id: Optional[str] = None
39-
span_id: Optional[UUID] = None
39+
span_id: Optional[OTelSpanId] = None
4040

4141

4242
class SessionRecordQuery(BaseModel):

api/oss/src/core/sessions/turns/dtos.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66

77
from agenta.sdk.agents.dtos import HarnessKind
88

9-
from oss.src.core.shared.dtos import Identifier, Lifecycle, Reference
9+
from oss.src.core.shared.dtos import Identifier, Lifecycle, OTelSpanId, Reference
1010

1111

1212
class SessionTurn(Identifier, Lifecycle):
@@ -19,7 +19,7 @@ class SessionTurn(Identifier, Lifecycle):
1919
sandbox_id: Optional[str] = None
2020
references: Optional[List[Reference]] = None
2121
trace_id: Optional[UUID] = None
22-
span_id: Optional[UUID] = None
22+
span_id: Optional[OTelSpanId] = None
2323
start_time: Optional[datetime] = None
2424
end_time: Optional[datetime] = None
2525

@@ -33,7 +33,7 @@ class SessionTurnCreate(BaseModel):
3333
sandbox_id: Optional[str] = None
3434
references: Optional[List[Reference]] = None
3535
trace_id: Optional[UUID] = None
36-
span_id: Optional[UUID] = None
36+
span_id: Optional[OTelSpanId] = None
3737
start_time: Optional[datetime] = None
3838
end_time: Optional[datetime] = None
3939

api/oss/src/core/shared/dtos.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
from datetime import datetime, timezone
2-
from typing import Optional
2+
from typing import Annotated, Optional
33
from uuid import UUID
44

5-
from pydantic import BaseModel, Field
5+
from pydantic import BaseModel, Field, StringConstraints
66

77
from agenta.sdk.models.shared import ( # noqa: F401
88
BoolJson,
@@ -51,6 +51,12 @@
5151
)
5252

5353

54+
# An OpenTelemetry span id: 64-bit, exactly 16 hex chars. NOT a UUID (which is 128-bit /
55+
# 32 hex). Typing this as UUID rejected every runner record-ingest with a 422 (16 vs 32 hex).
56+
# Trace ids ARE 128-bit (32 hex) and correctly continue to ride as UUID.
57+
OTelSpanId = Annotated[str, StringConstraints(pattern=r"^[0-9a-fA-F]{16}$")]
58+
59+
5460
class Status(BaseModel):
5561
timestamp: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
5662
type: Optional[str] = None

api/oss/src/dbs/postgres/sessions/records/dbas.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,9 @@ class RecordTurnSpanDBA:
1414
String,
1515
nullable=True,
1616
)
17+
# OTel span id (16 hex chars), NOT a UUID — stored as text (see OTelSpanId).
1718
span_id = Column(
18-
UUID(as_uuid=True),
19+
String,
1920
nullable=True,
2021
)
2122

api/oss/src/dbs/postgres/sessions/turns/dbas.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,9 +32,10 @@ class SessionTurnDBA(
3232
# eval_runs pattern: list of {id, slug, version} dicts, GIN jsonb_path_ops, .contains().
3333
references = Column(JSONB(none_as_null=True), nullable=True)
3434

35-
# Bridge — nullable.
35+
# Bridge — nullable. trace_id is a 128-bit OTel trace id (fits UUID); span_id is a
36+
# 64-bit OTel span id (16 hex), NOT a UUID — stored as text (see OTelSpanId).
3637
trace_id = Column(UUID(as_uuid=True), nullable=True)
37-
span_id = Column(UUID(as_uuid=True), nullable=True)
38+
span_id = Column(String, nullable=True)
3839

3940
start_time = Column(TIMESTAMP(timezone=True), nullable=True)
4041
end_time = Column(TIMESTAMP(timezone=True), nullable=True)
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
"""Acceptance test for the session archive/unarchive lifecycle routes (OSS edition).
2+
3+
POST /sessions/archive and POST /sessions/unarchive (session_id as a query param) soft-archive
4+
and restore the merged stream row; /sessions/query must exclude an archived session and return
5+
it again once unarchived. Covers TEST-GAPS.md §"archive / unarchive lifecycle", which had only
6+
service-level coverage and no route test.
7+
8+
Requires a live stack (AGENTA_API_URL/AGENTA_AUTH_KEY) — see the pytest `acceptance` marker.
9+
"""
10+
11+
import uuid
12+
13+
14+
def _session_ids(query_response) -> set:
15+
return {s["session_id"] for s in query_response.json().get("sessions", [])}
16+
17+
18+
class TestSessionArchiveUnarchive:
19+
"""A session created via the stream header is queryable, drops out of /sessions/query when
20+
archived, and returns when unarchived."""
21+
22+
def test_archive_excludes_then_unarchive_restores(self, authed_api):
23+
session_id = str(uuid.uuid4())
24+
25+
# Create the merged stream row (this is what /sessions/query lists).
26+
create = authed_api(
27+
"PUT",
28+
"/sessions/streams/header",
29+
params={"session_id": session_id},
30+
json={"name": "Archive Me", "description": "lifecycle check"},
31+
)
32+
assert create.status_code == 200, create.text
33+
34+
listed = authed_api("POST", "/sessions/query", json={})
35+
assert listed.status_code == 200, listed.text
36+
assert session_id in _session_ids(listed)
37+
38+
archived = authed_api(
39+
"POST", "/sessions/archive", params={"session_id": session_id}
40+
)
41+
assert archived.status_code == 200, archived.text
42+
43+
after_archive = authed_api("POST", "/sessions/query", json={})
44+
assert after_archive.status_code == 200, after_archive.text
45+
assert session_id not in _session_ids(after_archive)
46+
47+
unarchived = authed_api(
48+
"POST", "/sessions/unarchive", params={"session_id": session_id}
49+
)
50+
assert unarchived.status_code == 200, unarchived.text
51+
52+
after_unarchive = authed_api("POST", "/sessions/query", json={})
53+
assert after_unarchive.status_code == 200, after_unarchive.text
54+
assert session_id in _session_ids(after_unarchive)
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
"""Acceptance test for the record-ingest contract on POST /sessions/records/ingest.
2+
3+
Pins the regression from debug/qa-sessions-rebase/FINDING-record-ingest-422.md: the runner
4+
posts a 16-hex OTel span_id, and the API had typed span_id as UUID (32 hex) — so every
5+
ingest 422'd and session records silently dropped. This drives the endpoint with the body the
6+
runner actually sends and asserts it is accepted (200) and lands as a queryable record whose
7+
span_id round-trips.
8+
9+
Requires a live stack (AGENTA_API_URL/AGENTA_AUTH_KEY) with the record worker running — see
10+
the pytest `acceptance` marker.
11+
"""
12+
13+
import time
14+
import uuid
15+
16+
17+
class TestRecordIngestContract:
18+
"""POST /sessions/records/ingest accepts the runner-shaped body (16-hex span_id) and the
19+
record becomes queryable with the span id intact."""
20+
21+
def test_runner_shaped_ingest_is_accepted_and_persists(self, authed_api):
22+
session_id = str(uuid.uuid4())
23+
# Exactly the shape services/runner/src/sessions/persist.ts posts: a 16-hex OTel span
24+
# id (NOT a UUID), a turn_id, a per-turn record_index, and the event as attributes.
25+
span_id = uuid.uuid4().hex[:16]
26+
turn_id = f"turn-{uuid.uuid4().hex[:8]}"
27+
28+
ingest = authed_api(
29+
"POST",
30+
"/sessions/records/ingest",
31+
json={
32+
"session_id": session_id,
33+
"record_index": 0,
34+
"timestamp": "2026-07-18T00:00:00.000Z",
35+
"record_source": "agent",
36+
"record_type": "message",
37+
"attributes": {"type": "message", "text": "hello from the runner"},
38+
"turn_id": turn_id,
39+
"span_id": span_id,
40+
},
41+
)
42+
assert ingest.status_code == 200, ingest.text
43+
assert ingest.json() == {"ok": True}
44+
45+
# The record flows through Redis -> worker -> DB; poll the query endpoint until it lands.
46+
record = None
47+
deadline = time.time() + 20
48+
while time.time() < deadline:
49+
query = authed_api(
50+
"POST", "/sessions/records/query", json={"session_id": session_id}
51+
)
52+
assert query.status_code == 200, query.text
53+
records = query.json().get("records", [])
54+
if records:
55+
record = records[0]
56+
break
57+
time.sleep(1)
58+
59+
assert record is not None, "ingested record never became queryable"
60+
assert record["turn_id"] == turn_id
61+
assert record["span_id"] == span_id
62+
63+
def test_a_uuid_span_id_is_rejected(self, authed_api):
64+
"""A 32-hex UUID is not a span id; the endpoint must reject it (422), which is exactly
65+
the validation that a 16-hex span id must pass."""
66+
response = authed_api(
67+
"POST",
68+
"/sessions/records/ingest",
69+
json={
70+
"session_id": str(uuid.uuid4()),
71+
"record_source": "agent",
72+
"span_id": uuid.uuid4().hex, # 32 hex — a UUID, not a span id
73+
},
74+
)
75+
assert response.status_code == 422, response.text

0 commit comments

Comments
 (0)