Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions QA_REPORT.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# QA_REPORT — OtelTraceSink enhancement

**Verdict: PASS (green check)**

Independent review pass (no separate `code-reviewer` subagent type available in
this environment; reviewer performed a fresh diff-only read against the task
spec, WORK_PLAN, RESEARCH, and repo guardrails).

## Checks

| Check | Result |
|---|---|
| Backward compatibility | PASS — `resource_attributes` defaults `None`; 9 original tests pass unchanged |
| `start_span(links=)` correctness | PASS — empty list when no links; real OTel accepts the kwarg |
| Thread safety | PASS — `_build_links` uses `self._lock`, consistent with file |
| `Link` import guarded | PASS — added inside existing `_HAS_OTEL` try-block |
| Malformed input handling | PASS — non-list `links`, non-dict entries, missing span_id, unknown span_id, and non-dict attrs are all skipped safely |
| Scope vs NARROW mandate | PASS — no new module, no new dependency, no metrics, stays in `otel.py` |
| Secrets added | NONE |
| Denylist paths touched | NONE (only `sdk/agentguard/sinks/otel.py`, `sdk/tests/test_otel_sink.py`, repo-root MD artifacts) |
| Test coverage regression | NONE — 752-test suite passes; malformed-link regression coverage is included |
| Diff matches WORK_PLAN scope | PASS |
| Diff size | ~297 lines, well under 400 LOC gate |

## Notes
- Pre-existing ruff F401 unused imports in `test_otel_sink.py` (`call`,
`MagicMock`, `patch`, `sys`) were left untouched — out of scope. CI avoids
them because the lint command targets production paths, not because Ruff
globally excludes tests. Production file `otel.py` passes `ruff check`
cleanly.
- `sdk/traces.jsonl` is a stray artifact from an unrelated test file; not
staged, not part of this change.

## Issues
None blocking. Approved to proceed to PR.
30 changes: 30 additions & 0 deletions RESEARCH.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
# RESEARCH — OtelTraceSink enhancement

## Verified facts
- `opentelemetry.trace.Link` exists in opentelemetry-api 1.x and takes
`(context: SpanContext, attributes: dict)`. Imported alongside the existing
`SpanKind`/`StatusCode` import, guarded by `_HAS_OTEL`.
- `Tracer.start_span(...)` accepts a `links` kwarg (list of `Link`). The fake
tracer in `tests/test_otel_sink.py` was extended to accept it.
- `Span.get_span_context()` returns the `SpanContext` a `Link` needs.
- Existing sink stores OTel spans by `span_id` in `self._spans` (otel.py:80).
Span links reuse that same registry — link targets must already be tracked.
- The repo lint path targets production code, so the 4 pre-existing F401 unused
imports in `test_otel_sink.py` are not CI-checked and are out of scope for
this task. The production file `agentguard/sinks/otel.py` passes `ruff check`
cleanly.
- Roadmap `ops/03-ROADMAP_NOW_NEXT_LATER.md:76` explicitly lists this exact
Later item: "OtelTraceSink supports custom resource attributes and span links
without pulling the SDK toward generic observability positioning."

## Decisions
- Resource attributes are stamped as per-span attributes (`agentguard.resource.*`)
rather than constructing an OTel `Resource`, because the `Resource` belongs to
the `TracerProvider` the caller owns — the sink does not create the provider.
- All values coerced to `str` to avoid OTel attribute-type validation failures
when a caller passes ints/bools.

## Test evidence
- `python -m pytest tests/test_otel_sink.py -q` → 20 passed.
- `python -m pytest tests/ -q` → 752 passed (no regressions).
- `python -m ruff check agentguard/sinks/otel.py` → All checks passed.
39 changes: 39 additions & 0 deletions WORK_PLAN.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
# WORK_PLAN — Narrow OtelTraceSink enhancement

## Problem statement
The roadmap Later bucket sanctions enhancing the existing `OtelTraceSink` so it
supports custom resource attributes and span links, without pulling AgentGuard
toward a broad observability product. This task adds those two capabilities
inside the existing sink only — no new exporter module, no new dependency.

## Approach
- `OtelTraceSink.__init__` gains an optional `resource_attributes` dict. Entries
are coerced to strings and stamped onto every span this sink emits, prefixed
`agentguard.resource.*`. Per-span attribute prefixing is the API-compatible
way to surface resource-level context without owning the TracerProvider's
OTel `Resource` (which would push the sink into provider-setup territory).
- Span links: a span-start event may carry a `links` list of
`{"span_id": ..., "attributes": {...}}` entries. On span start, links that
reference an already-tracked AgentGuard span are converted to OTel `Link`
objects via the span's `get_span_context()` and passed to `start_span(links=)`.
Links to unknown/malformed entries are skipped.

## Files touched
- `sdk/agentguard/sinks/otel.py` — implementation + docstrings.
- `sdk/tests/test_otel_sink.py` — fake OTel `Link`, fake span context, new tests.

## What "done" looks like
- [x] `resource_attributes` constructor arg, backward-compatible (defaults None).
- [x] Resource attrs stamped on every span; non-string values coerced.
- [x] `links` on span-start produce OTel `Link` objects to tracked spans.
- [x] Malformed/unknown links skipped without crashing, including truthy
non-list `links` values and non-dict link attributes.
- [x] All 9 original tests still pass; new tests cover both features.
- [x] No new dependencies; core SDK stays zero-dep.

## Risks / assumptions
- Assumes OTel `Link` is importable from `opentelemetry.trace` (it is, since
opentelemetry-api 1.x) — guarded behind the existing `_HAS_OTEL` try/except.
- Assumes `start_span` accepts a `links` kwarg (it does in opentelemetry-api).
- Span links only resolve to spans this sink currently tracks; cross-process
links are out of scope (and out of the narrow mandate).
62 changes: 59 additions & 3 deletions sdk/agentguard/sinks/otel.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,24 +15,30 @@
from agentguard.sinks.otel import OtelTraceSink
from agentguard import Tracer

sink = OtelTraceSink(provider)
sink = OtelTraceSink(provider, resource_attributes={"deployment.env": "prod"})
tracer = Tracer(sink=sink, service="my-agent")

with tracer.trace("agent.run") as ctx:
ctx.event("step", data={"thought": "search docs"})

Custom resource attributes are stamped onto every span this sink emits, so
downstream collectors can filter on deployment/host/region without a hosted
control plane. Span links let one span point at sibling spans it consumed
(e.g. a fan-in step referencing the spans it merged); supply a ``links`` list
on the span-start event.

Requires ``opentelemetry-api`` and ``opentelemetry-sdk`` (optional deps).
Core SDK remains zero-dep.
"""
from __future__ import annotations

import threading
from typing import Any, Dict, Optional
from typing import Any, Dict, List, Optional

from agentguard.tracing import TraceSink

try:
from opentelemetry.trace import SpanKind, StatusCode
from opentelemetry.trace import Link, SpanKind, StatusCode

_HAS_OTEL = True
except ImportError:
Expand All @@ -50,6 +56,9 @@ class OtelTraceSink(TraceSink):
Args:
tracer_provider: An OpenTelemetry TracerProvider instance.
tracer_name: Name for the OTel tracer. Defaults to ``"agentguard"``.
resource_attributes: Optional flat dict of attributes stamped onto
every span this sink emits (e.g. ``{"deployment.env": "prod"}``).
Values are coerced to strings. Defaults to no extra attributes.

Raises:
ImportError: If ``opentelemetry-api`` is not installed.
Expand All @@ -59,6 +68,7 @@ def __init__(
self,
tracer_provider: Any,
tracer_name: str = "agentguard",
resource_attributes: Optional[Dict[str, Any]] = None,
) -> None:
if not _HAS_OTEL:
raise ImportError(
Expand All @@ -68,6 +78,12 @@ def __init__(
self._otel_tracer = tracer_provider.get_tracer(tracer_name)
self._lock = threading.Lock()
self._spans: Dict[str, Any] = {} # span_id -> OTel Span
# Stamp resource-level attributes onto every span. Coerced to str so a
# caller passing ints/bools cannot break OTel attribute validation.
self._resource_attributes: Dict[str, str] = {
f"agentguard.resource.{k}": str(v)
for k, v in (resource_attributes or {}).items()
}

def emit(self, event: Dict[str, Any]) -> None:
"""Process an AgentGuard event and map to OTel.
Expand All @@ -87,6 +103,41 @@ def emit(self, event: Dict[str, Any]) -> None:
elif kind == "event":
self._add_event(event, name, span_id)

def _build_links(self, event: Dict[str, Any]) -> List[Any]:
"""Build OTel Link objects from an event's ``links`` field.

Each link entry is a dict ``{"span_id": ..., "attributes": {...}}``
referencing another AgentGuard span this sink already tracks. Links to
unknown span_ids are skipped (the referenced span may have ended or
never started). Returns an empty list when there are no resolvable
links.
"""
raw_links = event.get("links")
if not raw_links:
return []
if not isinstance(raw_links, (list, tuple)):
return []

links: List[Any] = []
for entry in raw_links:
Comment thread
bmdhodl marked this conversation as resolved.
if not isinstance(entry, dict):
continue
ref_span_id = entry.get("span_id")
if not ref_span_id:
continue
with self._lock:
ref_span = self._spans.get(ref_span_id)
if ref_span is None:
continue
raw_attrs = entry.get("attributes")
if not isinstance(raw_attrs, dict):
raw_attrs = {}
Comment thread
bmdhodl marked this conversation as resolved.
link_attrs = {
str(k): str(v)[:256] for k, v in raw_attrs.items()
}
links.append(Link(ref_span.get_span_context(), attributes=link_attrs))
return links

def _start_span(
self, event: Dict[str, Any], name: str, span_id: Optional[str]
) -> None:
Expand All @@ -103,10 +154,13 @@ def _start_span(

ctx = set_span_in_context(parent_span)

links = self._build_links(event)

span = self._otel_tracer.start_span(
name=name,
kind=SpanKind.INTERNAL,
context=ctx,
links=links,
)

# Set initial attributes
Expand All @@ -115,6 +169,8 @@ def _start_span(
"agentguard.span_id": span_id or "",
"agentguard.service": event.get("service", ""),
}
# Resource-level attributes stamped on every span.
attrs.update(self._resource_attributes)
if parent_id:
attrs["agentguard.parent_id"] = parent_id
if event.get("metadata"):
Expand Down
Loading