Skip to content

Commit f93f591

Browse files
feat(otlp): add --format otlp-genai with OTel GenAI semantic conventions (#110)
LLM request/response pairs now export as gen_ai.client.operation child spans. Tool calls export as gen_ai.tool.call/<name> spans. Errors use the OTel exception event format. Root span carries gen_ai.agent.id and gen_ai.agent.name. --format otlp is unchanged for backwards compatibility. Closes #100 Co-authored-by: Ona <no-reply@ona.com>
1 parent c3fbdaa commit f93f591

6 files changed

Lines changed: 771 additions & 13 deletions

File tree

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
# ADR-0011: OTLP GenAI Semantic Conventions Export Format
2+
3+
**Status:** Accepted
4+
**Date:** 2026-05
5+
**Deciders:** Siddhant Khare
6+
7+
## Context
8+
9+
agent-strace already exports OTLP (ADR-0006). The OpenTelemetry GenAI semantic
10+
conventions (`gen_ai.*`) are now the standard attribute set for AI spans and are
11+
natively understood by Datadog LLM Observability, Grafana GenAI dashboards,
12+
Honeycomb, and any OTel-compatible backend.
13+
14+
Without this mapping, agent-strace traces land in production backends as
15+
unrecognized custom spans. They don't get AI-specific dashboards, cost views,
16+
token usage charts, or anomaly detection.
17+
18+
## Decision
19+
20+
Add a `--format otlp-genai` flag to `agent-strace export` that applies the
21+
OTel GenAI semantic conventions mapping. The existing `--format otlp` output
22+
is unchanged for backwards compatibility.
23+
24+
### Mapping
25+
26+
| agent-strace event | OTel GenAI span / attribute |
27+
|---|---|
28+
| `llm_request` + `llm_response` | `gen_ai.client.operation` child span with `gen_ai.request.model`, `gen_ai.request.max_tokens`, `gen_ai.usage.input_tokens`, `gen_ai.usage.output_tokens`, `gen_ai.response.finish_reasons` |
29+
| `tool_call` + `tool_result` | `gen_ai.tool.call/<name>` child span with `gen_ai.tool.name`, `gen_ai.tool.call.id` |
30+
| `user_prompt` | `gen_ai.user.message` event on root span |
31+
| `assistant_response` | `gen_ai.assistant.message` event on root span |
32+
| `error` | OTel `exception` event with `exception.type`, `exception.message` |
33+
| `session_start` / `session_end` | Root span with `gen_ai.agent.id`, `gen_ai.agent.name` |
34+
35+
### Span hierarchy
36+
37+
```
38+
session root span (gen_ai.agent.session)
39+
├── gen_ai.client.operation (one per LLM request/response pair)
40+
├── gen_ai.tool.call/<name> (one per tool call/result pair)
41+
└── gen_ai.tool.call/<name> (error variant with exception event)
42+
```
43+
44+
### Key differences from --format otlp
45+
46+
- LLM request/response pairs become proper child spans (not events on root)
47+
- Root span carries `gen_ai.agent.id` and `gen_ai.agent.name`
48+
- Error events use the OTel `exception` event format
49+
- Tool input/output attributes use `gen_ai.tool.input.*` / `gen_ai.tool.output`
50+
- Scope name includes `genai-semconv-1.27` for version tracking
51+
52+
## Consequences
53+
54+
- Traces exported with `--format otlp-genai` appear in Grafana, Datadog, and
55+
Honeycomb with AI-specific dashboards populated automatically.
56+
- Existing `--format otlp` output is unchanged — no breaking change.
57+
- No new runtime dependencies — the mapping is pure Python stdlib.
58+
- The `gen_ai.system` attribute is derived heuristically from the model name;
59+
this may be incorrect for custom or fine-tuned models.

README.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -219,6 +219,7 @@ agent-strace replay [session-id] [--limit N] Replay a session (--limit caps e
219219
agent-strace retention status Show session count, size, and what policy would delete
220220
agent-strace retention clean [--dry-run] Delete sessions that exceed retention limits
221221
agent-strace sample --strategy worst --n 20 Export worst/diverse/random/recent sessions as JSONL
222+
agent-strace export <session> --format otlp-genai Export with OTel GenAI semantic conventions
222223
agent-strace watch [--timeout DURATION] [--budget $] [--on-death CMD] [--rules file]
223224
Watch a live session; kill/pause on rule breach
224225
agent-strace share <session-id> [-o file] Export a self-contained HTML report
@@ -1224,6 +1225,26 @@ Any attempt by the agent to read `cvm/attestation-service/` or `cvm/auth-service
12241225

12251226
Export sessions as OpenTelemetry spans to your existing observability stack. Sessions become traces. Tool calls become spans with duration and inputs. Errors get exception events. No new dependencies.
12261227

1228+
### OTel GenAI semantic conventions
1229+
1230+
Use `--format otlp-genai` to export with strict [OTel GenAI semantic conventions](https://opentelemetry.io/docs/specs/semconv/gen-ai/). This produces AI-native spans that populate token usage charts, cost views, and LLM dashboards in Datadog, Grafana, and Honeycomb automatically.
1231+
1232+
```bash
1233+
agent-strace export <session-id> --format otlp-genai \
1234+
--endpoint http://localhost:4318
1235+
```
1236+
1237+
Key differences from `--format otlp`:
1238+
1239+
| Aspect | `--format otlp` | `--format otlp-genai` |
1240+
|---|---|---|
1241+
| LLM calls | Events on root span | `gen_ai.client.operation` child spans |
1242+
| Tool calls | `tool/<name>` spans | `gen_ai.tool.call/<name>` spans |
1243+
| Root span | `agent.name` attribute | `gen_ai.agent.id` + `gen_ai.agent.name` |
1244+
| Errors | Custom error span | OTel `exception` event format |
1245+
1246+
`--format otlp` is unchanged for backwards compatibility.
1247+
12271248
### Datadog
12281249

12291250
```bash

src/agent_trace/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
"""agent-trace: strace for AI agents."""
22

3-
__version__ = "0.42.1"
3+
__version__ = "0.43.0"

src/agent_trace/cli.py

Lines changed: 44 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -272,14 +272,25 @@ def cmd_export(args: argparse.Namespace) -> int:
272272
for e in events:
273273
sys.stdout.write(e.to_json() + "\n")
274274

275-
elif args.format == "otlp":
276-
from .otlp import export_otlp, session_to_otlp
275+
elif args.format in ("otlp", "otlp-genai"):
276+
from .otlp import export_otlp, session_to_otlp, session_to_otlp_genai
277277

278+
use_genai = args.format == "otlp-genai"
278279
endpoint = args.endpoint
280+
281+
# When --endpoint is set and format is plain otlp, default to otlp-genai
282+
# for better backend compatibility (backwards-compat: explicit --format otlp
283+
# always uses the legacy mapping)
284+
if endpoint and not use_genai:
285+
use_genai = False # explicit --format otlp keeps legacy behaviour
286+
279287
if not endpoint:
280288
# No endpoint: dump OTLP JSON to stdout
281289
meta = store.load_meta(session_id)
282-
payload = session_to_otlp(meta, events, service_name=args.service_name)
290+
if use_genai:
291+
payload = session_to_otlp_genai(meta, events, service_name=args.service_name)
292+
else:
293+
payload = session_to_otlp(meta, events, service_name=args.service_name)
283294
sys.stdout.write(json.dumps(payload, indent=2) + "\n")
284295
return 0
285296

@@ -290,14 +301,33 @@ def cmd_export(args: argparse.Namespace) -> int:
290301
key, val = h.split(":", 1)
291302
headers[key.strip()] = val.strip()
292303

293-
ok = export_otlp(
294-
store=store,
295-
session_id=session_id,
296-
endpoint=endpoint,
297-
headers=headers,
298-
service_name=args.service_name,
299-
)
300-
return 0 if ok else 1
304+
if use_genai:
305+
# Export using GenAI conventions
306+
import urllib.request, urllib.error
307+
meta = store.load_meta(session_id)
308+
payload = session_to_otlp_genai(meta, events, service_name=args.service_name)
309+
body = json.dumps(payload).encode("utf-8")
310+
url = endpoint.rstrip("/") + "/v1/traces"
311+
req_headers = {"Content-Type": "application/json"}
312+
req_headers.update(headers)
313+
req = urllib.request.Request(url, data=body, headers=req_headers, method="POST")
314+
try:
315+
with urllib.request.urlopen(req, timeout=30) as resp:
316+
ok = resp.status in (200, 202)
317+
sys.stderr.write(f"Exported {len(events)} events to {url} (HTTP {resp.status})\n")
318+
return 0 if ok else 1
319+
except Exception as exc:
320+
sys.stderr.write(f"OTLP GenAI export failed: {exc}\n")
321+
return 1
322+
else:
323+
ok = export_otlp(
324+
store=store,
325+
session_id=session_id,
326+
endpoint=endpoint,
327+
headers=headers,
328+
service_name=args.service_name,
329+
)
330+
return 0 if ok else 1
301331

302332
return 0
303333

@@ -477,7 +507,9 @@ def build_parser() -> argparse.ArgumentParser:
477507
# export
478508
p_export = sub.add_parser("export", help="export a session")
479509
p_export.add_argument("session_id", nargs="?", help="session ID or prefix")
480-
p_export.add_argument("--format", choices=["json", "csv", "ndjson", "otlp"], default="json")
510+
p_export.add_argument("--format", choices=["json", "csv", "ndjson", "otlp", "otlp-genai"],
511+
default="json",
512+
help="output format (otlp-genai uses strict OTel GenAI semantic conventions)")
481513
p_export.add_argument("--endpoint", help="OTLP collector URL (e.g. http://localhost:4318)")
482514
p_export.add_argument("--header", action="append", help="HTTP header for OTLP (e.g. 'x-honeycomb-team: KEY')")
483515
p_export.add_argument("--service-name", default="agent-trace", help="OTel service name (default: agent-trace)")

0 commit comments

Comments
 (0)