diff --git a/coding-agent/.opencode/prompts/tasks/durable-runtime-observability-correlation.md b/coding-agent/.opencode/prompts/tasks/durable-runtime-observability-correlation.md new file mode 100644 index 000000000..a7ea5047c --- /dev/null +++ b/coding-agent/.opencode/prompts/tasks/durable-runtime-observability-correlation.md @@ -0,0 +1,48 @@ +Goal: +Propagate durable runtime correlation identifiers into observability spans. + +Scope: +- Add safe, low-cardinality runtime correlation attributes to AgentKit pipeline + stage and LLM generation spans. +- Include `tape_id` from the active tape. +- Forward only whitelisted `AgentRunContext.trace_metadata` correlation keys. +- Bind HTTP root runs to `turn_id` and `tape_id` trace metadata from + `SessionManager`. +- Keep OTLP/Langfuse exporter privacy filtering intact. + +Out of scope: +- Do not add raw prompt, message, content, tool result, secret, or text values + to span attributes. +- Do not change trace id derivation. +- Do not change observability configuration defaults. +- Do not change runtime persistence or replay APIs. + +Context: +- ADRs: + - `docs/adr/0029-durable-runtime-identity.md` + - `docs/adr/0028-observability-and-langfuse-integration.md` +- Postmortem patterns consulted: + - `postmortem/patterns/PM-0001-address-code-review-issues.md` + - `postmortem/patterns/PM-0006-add-usage-event-fields-and-fix-tool-name-kwarg-in-pipeline.md` + - `postmortem/patterns/PM-0009-preserve-neutral-bare-anchor-semantics.md` + - `postmortem/patterns/PM-0010-route-incremental-context-append-through-tapeview.md` +- Relevant files: + - `src/agentkit/runtime/pipeline.py` + - `src/coding_agent/ui/session_manager.py` + - `tests/agentkit/runtime/test_pipeline.py` + - `tests/ui/test_session_manager_runtime.py` + - `tests/coding_agent/test_observability.py` + +Target tests: +- `uv run pytest tests/agentkit/runtime/test_pipeline.py -k "span" -v` +- `uv run pytest tests/agentkit/runtime/test_pipeline.py -v` +- `uv run pytest tests/agentkit/observability/test_core.py -v` +- `uv run pytest tests/ui/test_session_manager_runtime.py -k "run_id or approval or message_snapshot" -v` +- `uv run pytest tests/coding_agent/test_observability.py -v` +- `uv run ruff check src/agentkit/runtime/pipeline.py src/coding_agent/ui/session_manager.py tests/agentkit/runtime/test_pipeline.py tests/ui/test_session_manager_runtime.py tests/coding_agent/test_observability.py` +- `uv run ruff format --check src/agentkit/runtime/pipeline.py src/coding_agent/ui/session_manager.py tests/agentkit/runtime/test_pipeline.py tests/ui/test_session_manager_runtime.py tests/coding_agent/test_observability.py` + +Stop conditions: +- Stop with a blocker if the work requires exporting raw prompt/message/result + data or weakening the existing OTLP denylist. +- Stop with a blocker if trace metadata needs a broader public API decision. diff --git a/coding-agent/docs/durable_runtime/GOAL_PROGRESS.md b/coding-agent/docs/durable_runtime/GOAL_PROGRESS.md index d90dbc663..4a5fad804 100644 --- a/coding-agent/docs/durable_runtime/GOAL_PROGRESS.md +++ b/coding-agent/docs/durable_runtime/GOAL_PROGRESS.md @@ -14,7 +14,7 @@ Last updated: 2026-05-19 | G07 | Complete | `SessionManager` saves `{run_id}:latest` compacted message snapshots from `ctx.messages` when a runtime store is configured. | | G08 | Complete | HTTP replay APIs expose runtime run records, latest message snapshots, and runtime events with `last_event_id` filtering. | | G09 | Complete | `SessionManager` persists durable approval interaction records for pending requests, applied decisions, session auto-approvals, and approval timeouts when a runtime store is configured. | -| G10 | Pending | Not started. | +| G10 | Complete | AgentKit pipeline spans include safe runtime correlation attributes, and HTTP root runs bind `turn_id`/`tape_id` trace metadata without weakening OTLP privacy filtering. | | G11 | Pending | Not started. | ## G00 Verification @@ -128,3 +128,24 @@ Last updated: 2026-05-19 - `uv run pytest tests/approval/test_coordinator.py tests/approval/test_store.py -v` - `uv run pytest tests/coding_agent/test_pg_runtime_store.py -k "interaction" -v` - `uv run ruff check src/coding_agent/ui/session_manager.py tests/ui/test_session_manager_runtime.py` + +## G10 Verification + +- Added `.opencode/prompts/tasks/durable-runtime-observability-correlation.md`. +- Updated `src/agentkit/runtime/pipeline.py`. +- Updated `src/coding_agent/ui/session_manager.py`. +- Updated `tests/agentkit/runtime/test_pipeline.py`. +- Updated `tests/ui/test_session_manager_runtime.py`. +- Updated `tests/coding_agent/test_observability.py`. +- Postmortem patterns consulted: + - `postmortem/patterns/PM-0001-address-code-review-issues.md` + - `postmortem/patterns/PM-0006-add-usage-event-fields-and-fix-tool-name-kwarg-in-pipeline.md` + - `postmortem/patterns/PM-0009-preserve-neutral-bare-anchor-semantics.md` + - `postmortem/patterns/PM-0010-route-incremental-context-append-through-tapeview.md` +- Target tests: + - `uv run pytest tests/agentkit/runtime/test_pipeline.py -k "span" -v` + - `uv run pytest tests/agentkit/runtime/test_pipeline.py -v` + - `uv run pytest tests/agentkit/observability/test_core.py -v` + - `uv run pytest tests/ui/test_session_manager_runtime.py -k "run_id or approval or message_snapshot" -v` + - `uv run pytest tests/coding_agent/test_observability.py -v` + - `uv run ruff check src/agentkit/runtime/pipeline.py src/coding_agent/ui/session_manager.py tests/agentkit/runtime/test_pipeline.py tests/ui/test_session_manager_runtime.py tests/coding_agent/test_observability.py` diff --git a/coding-agent/src/agentkit/runtime/pipeline.py b/coding-agent/src/agentkit/runtime/pipeline.py index 0c02fb221..300cf9745 100644 --- a/coding-agent/src/agentkit/runtime/pipeline.py +++ b/coding-agent/src/agentkit/runtime/pipeline.py @@ -68,6 +68,16 @@ "render": "runtime.stage.apply_directives", "dispatch": "runtime.stage.dispatch", } +_TRACE_METADATA_ATTRIBUTE_KEYS = frozenset( + { + "turn_id", + "tape_id", + "tool_call_id", + "interaction_id", + "event_id", + "checkpoint_id", + } +) StructuredToolResultScopeFactory = Callable[[bool], AbstractContextManager[None]] @@ -108,8 +118,31 @@ def _stage_span_attributes( } if ctx.session_id: attributes["session_id"] = ctx.session_id + attributes.update(_runtime_correlation_attributes(ctx)) + return attributes + + +def _safe_trace_metadata_attributes(run_context: AgentRunContext) -> dict[str, Any]: + attributes: dict[str, Any] = {} + for key, value in run_context.trace_metadata.items(): + if key not in _TRACE_METADATA_ATTRIBUTE_KEYS: + continue + if isinstance(value, bool | int | float | str): + attributes[key] = value + return attributes + + +def _runtime_correlation_attributes(ctx: "PipelineContext") -> dict[str, Any]: + attributes: dict[str, Any] = {} if ctx.run_context is not None: + attributes.update(_safe_trace_metadata_attributes(ctx.run_context)) attributes["run_id"] = ctx.run_context.run_id + if ctx.run_context.agent_id is not None: + attributes["agent_id"] = ctx.run_context.agent_id + if ctx.run_context.parent_run_id is not None: + attributes["parent_run_id"] = ctx.run_context.parent_run_id + if ctx.tape.tape_id: + attributes["tape_id"] = ctx.tape.tape_id return attributes @@ -125,8 +158,7 @@ def _llm_generation_attributes( } if ctx.session_id: attributes["session_id"] = ctx.session_id - if ctx.run_context is not None: - attributes["run_id"] = ctx.run_context.run_id + attributes.update(_runtime_correlation_attributes(ctx)) model = ctx.config.get("model") if isinstance(model, str) and model: attributes["model"] = model diff --git a/coding-agent/src/coding_agent/ui/session_manager.py b/coding-agent/src/coding_agent/ui/session_manager.py index b84c1e5f6..1bcea991f 100644 --- a/coding-agent/src/coding_agent/ui/session_manager.py +++ b/coding-agent/src/coding_agent/ui/session_manager.py @@ -1073,11 +1073,15 @@ def _bind_root_run_identity(self, session: Session, ctx: Any, run_id: str) -> No if run_context is not None: if not isinstance(run_context, AgentRunContext): raise TypeError("runtime context run_context must be AgentRunContext") + trace_metadata = dict(run_context.trace_metadata) + trace_metadata["turn_id"] = run_id + trace_metadata["tape_id"] = ctx.tape.tape_id ctx.run_context = replace( run_context, session_id=session.id, run_id=run_id, parent_run_id=None, + trace_metadata=trace_metadata, ) def _run_metadata_for_session(self, session: Session) -> JSONObject: diff --git a/coding-agent/tests/agentkit/runtime/test_pipeline.py b/coding-agent/tests/agentkit/runtime/test_pipeline.py index c20fa9932..31b5c70f3 100644 --- a/coding-agent/tests/agentkit/runtime/test_pipeline.py +++ b/coding-agent/tests/agentkit/runtime/test_pipeline.py @@ -376,14 +376,19 @@ async def mock_stream(messages, tools=None, **kwargs): plugin._mock_llm.stream = mock_stream ctx = PipelineContext( - tape=Tape(), + tape=Tape(tape_id="tape-1"), session_id="session-1", run_context=AgentRunContext( session_id="session-1", - run_id="turn-1", - agent_id=None, + run_id="run-1", + agent_id="root", environment=RuntimeContextEnvironment(), context_budget=ContextBudget(), + trace_metadata={ + "turn_id": "run-1", + "interaction_id": "interaction-1", + "prompt": "do-not-record", + }, ), config={"observation_sink": sink}, ) @@ -407,10 +412,15 @@ async def mock_stream(messages, tools=None, **kwargs): assert build_context_span.attributes == { "stage": "build_context", "session_id": "session-1", - "run_id": "turn-1", + "run_id": "run-1", + "agent_id": "root", + "tape_id": "tape-1", + "turn_id": "run-1", + "interaction_id": "interaction-1", "entry_count_before": 0, "entry_count_after": 0, } + assert "prompt" not in build_context_span.attributes model_span = stage_spans[3] assert model_span.attributes["entry_count_before"] == 0 assert model_span.attributes["entry_count_after"] == 1 @@ -436,14 +446,15 @@ async def mock_stream(messages, tools=None, **kwargs): plugin._mock_llm.stream = mock_stream ctx = PipelineContext( - tape=Tape(), + tape=Tape(tape_id="tape-1"), session_id="session-1", run_context=AgentRunContext( session_id="session-1", - run_id="turn-1", + run_id="run-1", agent_id=None, environment=RuntimeContextEnvironment(), context_budget=ContextBudget(), + trace_metadata={"turn_id": "run-1"}, ), config={ "model": "test-model", @@ -458,7 +469,9 @@ async def mock_stream(messages, tools=None, **kwargs): assert llm_spans[0].status == "ok" assert llm_spans[0].attributes == { "session_id": "session-1", - "run_id": "turn-1", + "run_id": "run-1", + "tape_id": "tape-1", + "turn_id": "run-1", "message_count": 2, "tool_schema_count": 0, "model": "test-model", diff --git a/coding-agent/tests/coding_agent/test_observability.py b/coding-agent/tests/coding_agent/test_observability.py index 2d4ee652f..891438b77 100644 --- a/coding-agent/tests/coding_agent/test_observability.py +++ b/coding-agent/tests/coding_agent/test_observability.py @@ -71,7 +71,12 @@ def test_otlp_sink_posts_span_without_prompt_or_output_content() -> None: attributes={ "session_id": "session-1", "input_tokens": 10, + "content": "content-do-not-send", + "message": "message-do-not-send", "prompt": "do-not-send", + "result": "result-do-not-send", + "secret": "secret-do-not-send", + "text": "text-do-not-send", }, start_time=1.0, end_time=2.0, @@ -86,6 +91,20 @@ def test_otlp_sink_posts_span_without_prompt_or_output_content() -> None: assert "llm.generation" in body assert "input_tokens" in body assert "do-not-send" not in body + assert "content-do-not-send" not in body + assert "message-do-not-send" not in body + assert "result-do-not-send" not in body + assert "secret-do-not-send" not in body + assert "text-do-not-send" not in body + span = _first_span(json.loads(body)) + exported_keys = { + item["key"] + for item in span.get("attributes", []) + if isinstance(item, dict) and isinstance(item.get("key"), str) + } + assert {"content", "message", "prompt", "result", "secret", "text"}.isdisjoint( + exported_keys + ) def test_otlp_sink_groups_spans_by_session_and_run() -> None: diff --git a/coding-agent/tests/ui/test_session_manager_runtime.py b/coding-agent/tests/ui/test_session_manager_runtime.py index 0a3732261..beb7eff8d 100644 --- a/coding-agent/tests/ui/test_session_manager_runtime.py +++ b/coding-agent/tests/ui/test_session_manager_runtime.py @@ -249,6 +249,10 @@ def fake_create_agent(**kwargs): assert session.runtime_ctx.run_context.session_id == session_id assert session.runtime_ctx.run_context.run_id == run_id assert session.runtime_ctx.run_context.parent_run_id is None + assert session.runtime_ctx.run_context.trace_metadata == { + "turn_id": run_id, + "tape_id": session.runtime_ctx.tape.tape_id, + } @pytest.mark.asyncio