From 60120a7ae831e41b2c39d9f0e0ccbced863a0bae Mon Sep 17 00:00:00 2001 From: Edson Date: Thu, 1 Oct 2026 01:21:14 -0400 Subject: [PATCH 1/2] feat(sensor): add opt-in GenAI semconv attributes to OTel session logs Add a gen_ai_attributes OpenTelemetry config option. When enabled, adr.agent.session log records also carry gen_ai.conversation.id and gen_ai.agent.name so GenAI observability backends can correlate ADR sessions without a Collector transform. The body and adr.* attributes are unchanged, and the option is off by default. The default value is left out of the delivery checkpoint fingerprint so existing checkpoints stay valid after upgrade. --- Sensor/README.md | 7 +++++ Sensor/adr_sensor/exporters/config.py | 7 +++++ .../exporters/delivery_checkpoint.py | 6 +++- Sensor/adr_sensor/exporters/opentelemetry.py | 10 +++++++ Sensor/tests/test_delivery_checkpoint.py | 20 +++++++++++++ Sensor/tests/test_exporter_config.py | 5 ++++ Sensor/tests/test_opentelemetry_exporter.py | 30 +++++++++++++++++++ 7 files changed, 84 insertions(+), 1 deletion(-) diff --git a/Sensor/README.md b/Sensor/README.md index 75af748..720a034 100644 --- a/Sensor/README.md +++ b/Sensor/README.md @@ -420,6 +420,13 @@ no redaction or field projection, so prompts, responses, tool arguments, tool results, usernames, hostnames, and local paths can be transmitted. Any normalization already performed by a source parser still applies. +Set `"gen_ai_attributes": true` to also add OpenTelemetry GenAI semantic-convention +attributes to `adr.agent.session` records: `gen_ai.conversation.id` (the ADR +session ID, same value as `adr.session.id`) and `gen_ai.agent.name` (the source, +same value as `adr.source`). The body and `adr.*` attributes are unchanged. The +GenAI conventions are still in development status, so the option is off by default. +Enabling it is a destination change and resends sessions once. + System-configuration records are sent as `adr.system.configuration` logs on each run. Sensor health logs are also sent on every run, even when all session snapshots are already acknowledged. With `--save-sessions`, successful session delivery is tracked independently diff --git a/Sensor/adr_sensor/exporters/config.py b/Sensor/adr_sensor/exporters/config.py index 9cb6256..30c86e1 100644 --- a/Sensor/adr_sensor/exporters/config.py +++ b/Sensor/adr_sensor/exporters/config.py @@ -15,6 +15,7 @@ "timeout_seconds", "flush_timeout_seconds", "certificate_file", + "gen_ai_attributes", } @@ -32,6 +33,7 @@ class OpenTelemetryConfig: timeout_seconds: float = 10.0 flush_timeout_seconds: float = 30.0 certificate_file: Optional[str] = None + gen_ai_attributes: bool = False def load_opentelemetry_config(path: Path) -> OpenTelemetryConfig: @@ -84,6 +86,10 @@ def load_opentelemetry_config(path: Path) -> OpenTelemetryConfig: if not Path(certificate_file).is_file(): raise OpenTelemetryConfigError(f"OpenTelemetry certificate file does not exist: {certificate_file}") + gen_ai_attributes = document.get("gen_ai_attributes", False) + if not isinstance(gen_ai_attributes, bool): + raise OpenTelemetryConfigError("OpenTelemetry gen_ai_attributes must be a boolean") + return OpenTelemetryConfig( endpoint=endpoint, service_name=service_name, @@ -91,6 +97,7 @@ def load_opentelemetry_config(path: Path) -> OpenTelemetryConfig: timeout_seconds=timeout_seconds, flush_timeout_seconds=flush_timeout_seconds, certificate_file=certificate_file, + gen_ai_attributes=gen_ai_attributes, ) diff --git a/Sensor/adr_sensor/exporters/delivery_checkpoint.py b/Sensor/adr_sensor/exporters/delivery_checkpoint.py index cadeef8..5021061 100644 --- a/Sensor/adr_sensor/exporters/delivery_checkpoint.py +++ b/Sensor/adr_sensor/exporters/delivery_checkpoint.py @@ -34,8 +34,12 @@ class DeliveryCheckpoint: def __init__(self, output_dir: Path, config: OpenTelemetryConfig): _validate_credential_provider() + config_fields = asdict(config) + if not config.gen_ai_attributes: + # Keep checkpoints from before this option existed valid when it is off. + del config_fields["gen_ai_attributes"] destination = { - "config": asdict(config), + "config": config_fields, "schema_version": SCHEMA_VERSION, "checkpoint_version": 1, } diff --git a/Sensor/adr_sensor/exporters/opentelemetry.py b/Sensor/adr_sensor/exporters/opentelemetry.py index 3747df1..9b5b9f9 100644 --- a/Sensor/adr_sensor/exporters/opentelemetry.py +++ b/Sensor/adr_sensor/exporters/opentelemetry.py @@ -80,6 +80,7 @@ def __init__( self._info_severity = severity_number_cls.INFO self._warning_severity = severity_number_cls.WARN self._flush_timeout_millis = int(config.flush_timeout_seconds * 1000) + self._gen_ai_attributes = config.gen_ai_attributes self._closed = False self._submitted_count = 0 self._flushed_count = 0 @@ -103,6 +104,7 @@ def export( "adr.source": entry.source, "adr.session.id": entry.session_id, **({"adr.model": entry.model} if entry.model else {}), + **(_gen_ai_attributes(entry) if self._gen_ai_attributes else {}), }, ) @@ -204,6 +206,14 @@ def _emit( self._flush() +def _gen_ai_attributes(entry: AgentEvent) -> dict: + """Map session identity onto OpenTelemetry GenAI semantic-convention attributes.""" + return { + "gen_ai.conversation.id": entry.session_id, + "gen_ai.agent.name": entry.source, + } + + def _datetime_to_unix_nanos(value: datetime) -> int: if value.tzinfo is None or value.utcoffset() is None: value = value.replace(tzinfo=timezone.utc) diff --git a/Sensor/tests/test_delivery_checkpoint.py b/Sensor/tests/test_delivery_checkpoint.py index 6f2ccad..da56747 100644 --- a/Sensor/tests/test_delivery_checkpoint.py +++ b/Sensor/tests/test_delivery_checkpoint.py @@ -92,6 +92,26 @@ def test_destination_or_authentication_change_resends_sessions(tmp_path, config, assert changed.pending_entries([event]) == [event] +def test_default_gen_ai_attributes_keep_existing_checkpoint(tmp_path, config, monkeypatch): + for prefix in ("OTEL_EXPORTER_OTLP_", "OTEL_EXPORTER_OTLP_LOGS_"): + for setting in ("CLIENT_CERTIFICATE", "CLIENT_KEY"): + monkeypatch.delenv(f"{prefix}{setting}", raising=False) + + # Checkpoint name written for this configuration before gen_ai_attributes existed. + expected = ".adr-otel-delivery.5fae3b25f180fb0d10b1da8a0e95e774e8f1cb93f7db68fc9440d99c39858fa6.json" + assert DeliveryCheckpoint(tmp_path, config).path.name == expected + + +def test_enabling_gen_ai_attributes_resends_sessions(tmp_path, config, event): + checkpoint = DeliveryCheckpoint(tmp_path, config) + checkpoint.pending_entries([event]) + checkpoint.commit() + enabled = DeliveryCheckpoint(tmp_path, replace(config, gen_ai_attributes=True)) + + assert enabled.path != checkpoint.path + assert enabled.pending_entries([event]) == [event] + + def test_environment_authentication_change_resends_sessions(tmp_path, event, monkeypatch): config = OpenTelemetryConfig(endpoint="https://collector.example.invalid/v1/logs") monkeypatch.setenv("OTEL_EXPORTER_OTLP_LOGS_HEADERS", "Authorization=first-synthetic-token") diff --git a/Sensor/tests/test_exporter_config.py b/Sensor/tests/test_exporter_config.py index 9777382..cc060ef 100644 --- a/Sensor/tests/test_exporter_config.py +++ b/Sensor/tests/test_exporter_config.py @@ -25,6 +25,7 @@ def test_load_opentelemetry_config_defaults(tmp_path): assert config.timeout_seconds == 10.0 assert config.flush_timeout_seconds == 30.0 assert config.certificate_file is None + assert config.gen_ai_attributes is False def test_load_opentelemetry_config_all_fields(tmp_path): @@ -40,6 +41,7 @@ def test_load_opentelemetry_config_all_fields(tmp_path): "timeout_seconds": 5, "flush_timeout_seconds": 12.5, "certificate_file": certificate.name, + "gen_ai_attributes": True, }, ) ) @@ -49,6 +51,7 @@ def test_load_opentelemetry_config_all_fields(tmp_path): assert config.timeout_seconds == 5.0 assert config.flush_timeout_seconds == 12.5 assert config.certificate_file == str(certificate.resolve()) + assert config.gen_ai_attributes is True @pytest.mark.parametrize( @@ -60,6 +63,8 @@ def test_load_opentelemetry_config_all_fields(tmp_path): ({"endpoint": "http://localhost:4318/v1/logs", "headers": []}, "headers must be an object"), ({"endpoint": "http://localhost:4318/v1/logs", "timeout_seconds": 0}, "positive number"), ({"endpoint": "http://localhost:4318/v1/logs", "timeout_seconds": float("nan")}, "positive number"), + ({"endpoint": "http://localhost:4318/v1/logs", "gen_ai_attributes": "true"}, "must be a boolean"), + ({"endpoint": "http://localhost:4318/v1/logs", "gen_ai_attributes": 1}, "must be a boolean"), ({"endpoint": "http://localhost:4318/v1/logs", "unexpected": True}, "unknown"), ], ) diff --git a/Sensor/tests/test_opentelemetry_exporter.py b/Sensor/tests/test_opentelemetry_exporter.py index 6857fb6..4b5826d 100644 --- a/Sensor/tests/test_opentelemetry_exporter.py +++ b/Sensor/tests/test_opentelemetry_exporter.py @@ -1,6 +1,7 @@ """Tests for OTLP conversion of ADR Sensor records.""" import time +from dataclasses import replace from datetime import datetime, timezone from unittest.mock import patch @@ -86,6 +87,35 @@ def test_export_preserves_complete_agent_event_payload(): exporter.shutdown() +@pytest.mark.parametrize("model", ["gpt-test", None]) +def test_export_adds_gen_ai_attributes_when_enabled(model): + memory_exporter = InMemoryLogRecordExporter() + exporter = OpenTelemetryLogExporter( + OpenTelemetryConfig(endpoint="http://localhost:4318/v1/logs", gen_ai_attributes=True), + service_version="1.2.3", + _log_record_exporter=memory_exporter, + _processor_factory=SimpleLogRecordProcessor, + ) + event = replace(_event(), model=model) + + exporter.export([event], []) + + record = memory_exporter.get_finished_logs()[0] + assert record.log_record.body == event.get_non_null_fields() + assert record.log_record.attributes == { + "adr.event.type": "agent_session", + "adr.event.uuid": event.uuid, + "adr.schema.version": "1", + "adr.source": "codex", + "adr.session.id": "codex_session-1", + **({"adr.model": model} if model else {}), + "gen_ai.conversation.id": "codex_session-1", + "gen_ai.agent.name": "codex", + } + + exporter.shutdown() + + @pytest.mark.parametrize(("system", "expected"), [("Darwin", "darwin"), (None, None)]) def test_resource_includes_os_type_when_known(monkeypatch, system, expected): monkeypatch.setattr(host_identity, "host_os", lambda: system) From c1e019a1022628622ea7fa39ea22a977e27a9cb4 Mon Sep 17 00:00:00 2001 From: Edson Date: Fri, 2 Oct 2026 23:02:21 -0400 Subject: [PATCH 2/2] feat(sensor): use harness-native IDs for gen_ai.conversation.id gen_ai.conversation.id now carries each harness's own session ID instead of the prefixed ADR session ID, so ADR sessions join with the telemetry the harness reports itself. adr.session.id and the body are unchanged. The exporter maps each source back from the prefix its parser adds. Claude subagents get their parent's sessionId, Claude Desktop uses cliSessionId, and opencode gets its "ses_" prefix back. The attribute is omitted rather than guessed when the source is unknown, the prefix does not match, or the native ID is unavailable. The README documents the mapping per source. Tests run each parser on a fixture and check the exported ID, and fail if a new parser has no mapping entry. --- Sensor/README.md | 33 +- Sensor/adr_sensor/exporters/opentelemetry.py | 67 +++- Sensor/tests/test_gen_ai_mapping.py | 335 +++++++++++++++++++ Sensor/tests/test_opentelemetry_exporter.py | 2 +- 4 files changed, 426 insertions(+), 11 deletions(-) create mode 100644 Sensor/tests/test_gen_ai_mapping.py diff --git a/Sensor/README.md b/Sensor/README.md index 720a034..73824e7 100644 --- a/Sensor/README.md +++ b/Sensor/README.md @@ -421,11 +421,34 @@ results, usernames, hostnames, and local paths can be transmitted. Any normalization already performed by a source parser still applies. Set `"gen_ai_attributes": true` to also add OpenTelemetry GenAI semantic-convention -attributes to `adr.agent.session` records: `gen_ai.conversation.id` (the ADR -session ID, same value as `adr.session.id`) and `gen_ai.agent.name` (the source, -same value as `adr.source`). The body and `adr.*` attributes are unchanged. The -GenAI conventions are still in development status, so the option is off by default. -Enabling it is a destination change and resends sessions once. +attributes to `adr.agent.session` records. The option is off by default because the +GenAI conventions are still in development status. The body and all `adr.*` +attributes, including `adr.session.id`, are unchanged. Enabling it is a destination +change and resends sessions once. + +`gen_ai.agent.name` is the source (same value as `adr.source`). +`gen_ai.conversation.id` is the harness's own session ID, so ADR sessions can be +joined with the telemetry that harness reports itself. Each parser builds +`adr.session.id` by prefixing that ID, and the exporter maps it back as follows: + +| source | ADR `session_id` | Native ID the parser reads | `gen_ai.conversation.id` | +|---|---|---|---| +| `antigravity` | `antigravity_` | Conversation directory name | `` | +| `claude` | `claude_`, subagents `claude__agent_` | JSONL `sessionId` | ``; subagents get their parent's `sessionId`, as in Claude Code's own telemetry | +| `claude_desktop` | `claude_desktop_[dispatch_]` | Metadata `cliSessionId` (kept as `session_context.cli_session_id`) | `cliSessionId`; omitted when absent | +| `cline` | `cline_` | Task directory name (Cline's task ID) | `` | +| `codex` | `codex_` | `session_meta` `payload.id` | `` | +| `copilot` | `copilot_` | `session.start`/`session.resume` `sessionId`, else the session-state directory name | `` | +| `cursor` | `cursor_` | Composer ID from `composerData:`/`bubbleId:` keys | `` | +| `dsh` | `dsh_` | Session header `id` | `` | +| `gemini` | `gemini_` | `sessionId`; subagents keep their own | `` | +| `opencode` | `opencode_` | Session `id`, whose `ses_` prefix the parser strips | `ses_` | +| `warp` | `warp_` | `agent_conversations.conversation_id` | `` | + +`gen_ai.conversation.id` is omitted when it is unavailable: for an unlisted source, +a `session_id` without the expected prefix, a Claude subagent without a parent +session, or a Claude Desktop session without `cliSessionId`. It is never derived +from `adr.session.id`. New parsers need a row in this table. System-configuration records are sent as `adr.system.configuration` logs on each run. Sensor health logs are also sent on every run, even when all session snapshots diff --git a/Sensor/adr_sensor/exporters/opentelemetry.py b/Sensor/adr_sensor/exporters/opentelemetry.py index 9b5b9f9..c6199b6 100644 --- a/Sensor/adr_sensor/exporters/opentelemetry.py +++ b/Sensor/adr_sensor/exporters/opentelemetry.py @@ -4,7 +4,7 @@ import threading import time from datetime import datetime, timezone -from typing import Any, List, Optional, Tuple +from typing import Any, Callable, Dict, List, Optional, Tuple from .. import host_identity from ..diagnostics import sanitize_health_record @@ -208,10 +208,67 @@ def _emit( def _gen_ai_attributes(entry: AgentEvent) -> dict: """Map session identity onto OpenTelemetry GenAI semantic-convention attributes.""" - return { - "gen_ai.conversation.id": entry.session_id, - "gen_ai.agent.name": entry.source, - } + attributes = {"gen_ai.agent.name": entry.source} + conversation_id = _conversation_id(entry) + if conversation_id: + attributes["gen_ai.conversation.id"] = conversation_id + return attributes + + +def _conversation_id(entry: AgentEvent) -> Optional[str]: + """Return the harness's own session ID, or None when it cannot be recovered. + + Never falls back to ``entry.session_id``: ``adr.session.id`` already carries it. + """ + native_id = _CONVERSATION_ID_BY_SOURCE.get(entry.source) + return native_id(entry) if native_id else None + + +def _without_prefix(value: Any, prefix: str) -> Optional[str]: + if isinstance(value, str) and value.startswith(prefix) and len(value) > len(prefix): + return value[len(prefix) :] + return None + + +def _prefixed(prefix: str) -> Callable[[AgentEvent], Optional[str]]: + """Undo a parser's ``f"{prefix}{native_id}"`` session ID.""" + return lambda entry: _without_prefix(entry.session_id, prefix) + + +def _claude_conversation_id(entry: AgentEvent) -> Optional[str]: + context = entry.session_context or {} + if context.get("agent_id"): + # Subagent transcripts carry their parent's sessionId, which Claude Code also reports. + return _without_prefix(context.get("parent_session_id"), "claude_") + return _without_prefix(entry.session_id, "claude_") + + +def _claude_desktop_conversation_id(entry: AgentEvent) -> Optional[str]: + # The metadata sessionId prefix is stripped by the parser; cliSessionId is kept verbatim. + cli_session_id = (entry.session_context or {}).get("cli_session_id") + return cli_session_id if isinstance(cli_session_id, str) and cli_session_id else None + + +def _opencode_conversation_id(entry: AgentEvent) -> Optional[str]: + # The parser strips opencode's own "ses_" prefix, which every opencode session ID has. + native_id = _without_prefix(entry.session_id, "opencode_") + return f"ses_{native_id}" if native_id else None + + +# gen_ai.conversation.id for each AgentEvent.source; see "OpenTelemetry Logs Export" in Sensor/README.md. +_CONVERSATION_ID_BY_SOURCE: Dict[str, Callable[[AgentEvent], Optional[str]]] = { + "antigravity": _prefixed("antigravity_"), + "claude": _claude_conversation_id, + "claude_desktop": _claude_desktop_conversation_id, + "cline": _prefixed("cline_"), + "codex": _prefixed("codex_"), + "copilot": _prefixed("copilot_"), + "cursor": _prefixed("cursor_"), + "dsh": _prefixed("dsh_"), + "gemini": _prefixed("gemini_"), + "opencode": _opencode_conversation_id, + "warp": _prefixed("warp_"), +} def _datetime_to_unix_nanos(value: datetime) -> int: diff --git a/Sensor/tests/test_gen_ai_mapping.py b/Sensor/tests/test_gen_ai_mapping.py new file mode 100644 index 0000000..f9da6c3 --- /dev/null +++ b/Sensor/tests/test_gen_ai_mapping.py @@ -0,0 +1,335 @@ +"""gen_ai.conversation.id carries each harness's own session ID, taken from real parser output.""" + +import json +import sqlite3 +from datetime import datetime, timezone + +import pytest +from opentelemetry.sdk._logs.export import InMemoryLogRecordExporter, SimpleLogRecordProcessor + +from adr_sensor import parsers +from adr_sensor.exporters.config import OpenTelemetryConfig +from adr_sensor.exporters.opentelemetry import _CONVERSATION_ID_BY_SOURCE, OpenTelemetryLogExporter +from adr_sensor.parsers import ( + AntigravityParser, + ClaudeDesktopParser, + ClaudeParser, + ClineParser, + CodexParser, + CopilotParser, + CursorParser, + DshParser, + GeminiParser, + OpencodeParser, + WarpParser, +) +from adr_sensor.schemas.agent_event_schema import AgentEvent +from tests.test_antigravity_parser import _write_transcript +from tests.test_claude_parser import _record, _write_records +from tests.test_dsh_parser import write_session +from tests.test_gemini_parser import records as gemini_records +from tests.test_gemini_parser import write as write_gemini +from tests.test_parsers import ( + NOW_MS, + _build_opencode_db, + _build_warp_db, + _make_opencode_parser, + _write_agent_mode_session, +) + +PARSER_SOURCES = { + AntigravityParser: "antigravity", + ClaudeDesktopParser: "claude_desktop", + ClaudeParser: "claude", + ClineParser: "cline", + CodexParser: "codex", + CopilotParser: "copilot", + CursorParser: "cursor", + DshParser: "dsh", + GeminiParser: "gemini", + OpencodeParser: "opencode", + WarpParser: "warp", +} + + +def _attributes(entry: AgentEvent) -> dict: + memory_exporter = InMemoryLogRecordExporter() + exporter = OpenTelemetryLogExporter( + OpenTelemetryConfig(endpoint="http://localhost:4318/v1/logs", gen_ai_attributes=True), + service_version="1.2.3", + _log_record_exporter=memory_exporter, + _processor_factory=SimpleLogRecordProcessor, + ) + try: + assert exporter.export([entry], []) == 1 + record = memory_exporter.get_finished_logs()[0].log_record + assert record.body == entry.get_non_null_fields() + assert record.attributes["adr.session.id"] == entry.session_id + assert record.attributes["adr.source"] == entry.source + assert record.attributes["gen_ai.agent.name"] == entry.source + return dict(record.attributes) + finally: + exporter.shutdown() + + +def _only(entries: list) -> AgentEvent: + assert len(entries) == 1 + return entries[0] + + +def test_every_parser_has_a_conversation_id_mapping(): + exported = {getattr(parsers, name) for name in parsers.__all__} - {parsers.BaseParser} + + assert set(PARSER_SOURCES) == exported + assert set(PARSER_SOURCES.values()) == set(_CONVERSATION_ID_BY_SOURCE) + + +def test_claude_uses_transcript_session_id(tmp_path): + path = _write_records( + tmp_path / "session.jsonl", [_record("user", "Help me write a function", session_id="session1")] + ) + entry = _only(ClaudeParser().parse_jsonl_file(path)) + + assert entry.session_id == "claude_session1" + assert _attributes(entry)["gen_ai.conversation.id"] == "session1" + + +def test_claude_subagents_use_parent_session_id(tmp_path): + direct = tmp_path / "project" / "parent" / "subagents" / "agent-direct.jsonl" + nested = tmp_path / "project" / "parent" / "subagents" / "workflows" / "run-1" / "agent-nested.jsonl" + _write_records(tmp_path / "project" / "parent.jsonl", [_record("user", "parent", session_id="parent")]) + _write_records(direct, [_record("assistant", "direct", session_id="parent", agentId="direct", isSidechain=True)]) + _write_records(nested, [_record("assistant", "nested", session_id="parent", isSidechain=True)]) + parser = ClaudeParser() + parser.base_path = tmp_path + + entries = parser.parse_all() + + assert {entry.session_id for entry in entries} == { + "claude_parent", + "claude_parent_agent_direct", + "claude_parent_agent_nested", + } + assert {_attributes(entry)["gen_ai.conversation.id"] for entry in entries} == {"parent"} + + +def test_claude_desktop_uses_cli_session_id(tmp_path): + _write_agent_mode_session( + tmp_path / "user-1" / "org-1" / "agent", + "local_ditto_aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", + audit_lines=[{"type": "user", "uuid": "u1", "message": {"content": "run the nightly checks"}}], + metadata={ + "sessionId": "local_ditto_aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", + "lastActivityAt": NOW_MS, + "cliSessionId": "cli-99", + }, + ) + entry = _only(ClaudeDesktopParser(base_path=str(tmp_path)).parse_all()) + + assert entry.session_id == "claude_desktop_dispatch_aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" + assert _attributes(entry)["gen_ai.conversation.id"] == "cli-99" + + +def test_claude_desktop_without_cli_session_id_omits_conversation_id(tmp_path): + _write_agent_mode_session( + tmp_path / "user-1" / "org-1", + "local_11111111-2222-3333-4444-555555555555", + audit_lines=[{"type": "user", "uuid": "u1", "message": {"content": "read the config"}}], + metadata={"sessionId": "local_11111111-2222-3333-4444-555555555555", "lastActivityAt": NOW_MS}, + ) + entry = _only(ClaudeDesktopParser(base_path=str(tmp_path)).parse_all()) + + attributes = _attributes(entry) + assert "gen_ai.conversation.id" not in attributes + assert attributes["adr.session.id"] == "claude_desktop_11111111-2222-3333-4444-555555555555" + + +def test_codex_uses_session_meta_id(tmp_path): + path = tmp_path / "rollout-001.jsonl" + events = [ + {"type": "session_meta", "payload": {"id": "sess1", "timestamp": "2025-06-15T10:00:00Z", "cwd": "/tmp"}}, + { + "type": "response_item", + "payload": { + "type": "message", + "role": "user", + "content": [{"type": "input_text", "text": "List all Python files"}], + }, + }, + ] + path.write_text("".join(f"{json.dumps(event)}\n" for event in events), encoding="utf-8") + entry = CodexParser().parse_jsonl_file(path) + + assert entry.session_id == "codex_sess1" + assert _attributes(entry)["gen_ai.conversation.id"] == "sess1" + + +@pytest.mark.parametrize(("event_type", "directory"), [("session.start", "state-dir"), (None, "abc-session")]) +def test_copilot_uses_event_session_id_or_session_directory(tmp_path, event_type, directory): + session_dir = tmp_path / directory + session_dir.mkdir() + events = [ + {"type": "user.message", "timestamp": "2026-08-10T10:00:01.000Z", "data": {"content": "inspect the repo"}} + ] + if event_type: + events.insert( + 0, + { + "type": event_type, + "timestamp": "2026-08-10T10:00:00.000Z", + "data": {"sessionId": "abc-session", "startTime": "2026-08-10T10:00:00.000Z"}, + }, + ) + (session_dir / "events.jsonl").write_text("\n".join(json.dumps(event) for event in events) + "\n") + entry = CopilotParser().parse_session_dir(session_dir) + + assert entry.session_id == "copilot_abc-session" + assert _attributes(entry)["gen_ai.conversation.id"] == "abc-session" + + +def test_cursor_uses_composer_id(tmp_path): + db_path = tmp_path / "state.vscdb" + connection = sqlite3.connect(db_path) + connection.execute("CREATE TABLE cursorDiskKV (key TEXT, value TEXT)") + connection.executemany( + "INSERT INTO cursorDiskKV VALUES (?, ?)", + [ + ("composerData:composer-1", json.dumps({"lastUpdatedAt": NOW_MS})), + ("bubbleId:composer-1:bubble-1", json.dumps({"type": 1, "text": "Explain this function"})), + ], + ) + connection.commit() + connection.close() + parser = CursorParser() + parser.db_path = db_path + entry = _only(parser.parse_conversations_from_bubbles()) + + assert entry.session_id == "cursor_composer-1" + assert _attributes(entry)["gen_ai.conversation.id"] == "composer-1" + + +def test_cline_uses_task_id(tmp_path): + task_dir = tmp_path / "1234567890" + task_dir.mkdir() + (task_dir / "api_conversation_history.json").write_text( + json.dumps([{"role": "user", "content": [{"type": "text", "text": "Create a hello world script"}]}]) + ) + entry = ClineParser().parse_cline_log(task_dir) + + assert entry.session_id == "cline_1234567890" + assert _attributes(entry)["gen_ai.conversation.id"] == "1234567890" + + +def test_dsh_uses_session_header_id(tmp_path): + path = write_session( + tmp_path, + [ + {"type": "session", "version": 3, "id": "header-id", "createdAt": 1750000000000, "cwd": "/work"}, + { + "type": "user/message", + "seq": 1, + "time": 1750000001000, + "data": {"id": "u1", "role": "user", "content": [{"type": "text", "text": "inspect"}]}, + }, + ], + ) + entry = DshParser(base_path=tmp_path).parse_session_file(path) + + assert entry.session_id == "dsh_header-id" + assert _attributes(entry)["gen_ai.conversation.id"] == "header-id" + + +def test_gemini_main_and_subagent_use_their_own_session_ids(tmp_path): + write_gemini(tmp_path / "project/chats/session-main.jsonl", gemini_records()) + child = gemini_records("child-session") + child[0]["kind"] = "subagent" + write_gemini(tmp_path / "project/chats/main-session/child-session.jsonl", child) + events = {event.session_id: event for event in GeminiParser(base_path=tmp_path).parse_all()} + + assert set(events) == {"gemini_main-session", "gemini_child-session"} + assert _attributes(events["gemini_main-session"])["gen_ai.conversation.id"] == "main-session" + assert _attributes(events["gemini_child-session"])["gen_ai.conversation.id"] == "child-session" + + +def test_opencode_restores_ses_prefix(tmp_path): + _build_opencode_db( + tmp_path / "opencode.db", + sessions=[{"id": "ses_abc123", "directory": "/home/dev/project"}], + messages=[{"id": "msg_1", "session_id": "ses_abc123", "data": {"role": "user"}}], + parts=[ + { + "id": "prt_1", + "message_id": "msg_1", + "session_id": "ses_abc123", + "data": {"type": "text", "text": "list the files"}, + } + ], + ) + entry = _only(_make_opencode_parser(tmp_path).parse_all()) + + assert entry.session_id == "opencode_abc123" + assert _attributes(entry)["gen_ai.conversation.id"] == "ses_abc123" + + +def test_warp_uses_conversation_id(tmp_path): + now = datetime.now(timezone.utc).isoformat() + exchange = ( + "exchange-1", + now, + json.dumps([{"Query": {"text": "hello from exchange-1"}}]), + json.dumps({"Received": {"output": [{"Text": {"text": "reply"}}]}}), + ) + _build_warp_db( + tmp_path / "warp.sqlite", + [{"conversation_id": "conv-1", "last_modified_at": now, "exchanges": [exchange]}], + ) + parser = WarpParser() + parser.base_path = tmp_path + parser.db_path = tmp_path / "warp.sqlite" + entry = _only(parser.parse_all()) + + assert entry.session_id == "warp_conv-1" + assert _attributes(entry)["gen_ai.conversation.id"] == "conv-1" + + +def test_antigravity_uses_conversation_id(tmp_path): + path = _write_transcript( + tmp_path / "conv-uuid-123", [{"type": "USER_INPUT", "content": "Add unit tests for the auth service"}] + ) + entry = AntigravityParser(base_path=tmp_path).parse_transcript_file(path) + + assert entry.session_id == "antigravity_conv-uuid-123" + assert _attributes(entry)["gen_ai.conversation.id"] == "conv-uuid-123" + + +@pytest.mark.parametrize( + ("source", "session_id", "session_context"), + [ + ("unknown", "unknown_session-1", None), + ("codex", "session-1", None), + ("codex", "codex_", None), + ("claude", "claude_session-1_agent_a1", {"agent_id": "a1"}), + ], + ids=["unknown-source", "wrong-prefix", "empty-native-id", "subagent-without-parent"], +) +def test_unavailable_conversation_id_is_omitted(source, session_id, session_context): + entry = AgentEvent( + timestamp=datetime(2026, 9, 9, tzinfo=timezone.utc), + source=source, + session_id=session_id, + model="m", + session_context=session_context, + ) + + attributes = _attributes(entry) + + assert "gen_ai.conversation.id" not in attributes + assert attributes == { + "adr.event.type": "agent_session", + "adr.event.uuid": entry.uuid, + "adr.schema.version": "1", + "adr.source": source, + "adr.session.id": session_id, + "adr.model": "m", + "gen_ai.agent.name": source, + } diff --git a/Sensor/tests/test_opentelemetry_exporter.py b/Sensor/tests/test_opentelemetry_exporter.py index 4b5826d..8750896 100644 --- a/Sensor/tests/test_opentelemetry_exporter.py +++ b/Sensor/tests/test_opentelemetry_exporter.py @@ -109,7 +109,7 @@ def test_export_adds_gen_ai_attributes_when_enabled(model): "adr.source": "codex", "adr.session.id": "codex_session-1", **({"adr.model": model} if model else {}), - "gen_ai.conversation.id": "codex_session-1", + "gen_ai.conversation.id": "session-1", "gen_ai.agent.name": "codex", }