diff --git a/Sensor/README.md b/Sensor/README.md index 75af748..73824e7 100644 --- a/Sensor/README.md +++ b/Sensor/README.md @@ -420,6 +420,36 @@ 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. 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 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..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 @@ -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,71 @@ def _emit( self._flush() +def _gen_ai_attributes(entry: AgentEvent) -> dict: + """Map session identity onto OpenTelemetry GenAI semantic-convention attributes.""" + 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: 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_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 6857fb6..8750896 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": "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)