feat(observability): build Trajectory from OTel spans instead of hand-written record_step() - #10
Merged
Merged
Conversation
…-written record_step() Addresses the adoption-barrier critique from earlier in this project's roadmap discussion: every triage integration today requires the wrapped callable to construct Step objects by hand, one per tool call or error, before triage can tell you anything — a real, upfront cost for a probabilistic payoff. If the framework already emits OpenTelemetry spans for the same tool calls and errors (openllmetry/traceloop-style auto-instrumentation, or the OTel GenAI semantic conventions directly), that hand-instrumentation is pure duplication. Adds triage/observability/otel_ingest.py: - trajectory_from_spans(spans, *, include_all=False) -> Trajectory — pure conversion function, the inverse of triage.observability.otel (which emits triage's own spans). Duck-typed over span-like objects (.name/.attributes/.status/.events/.start_time/.end_time — exactly opentelemetry.sdk.trace.ReadableSpan's shape). - Error extraction (Step.error/exception_type) reads the span's "exception" event (exception.message/exception.type) — OTel's core exception-recording convention, stable for years. This is the most reliable part of the conversion. - Tool extraction (tool_called/tool_input/tool_output) reads gen_ai.tool.name and both known spellings of the arguments/result attributes (gen_ai.tool.call.arguments vs. gen_ai.tool.input — the spec renamed this between revisions). Flagged explicitly in the module docstring as best-effort: the GenAI semantic conventions are Development-stability, not stable. - A span carrying the stable http.response.status_code (or the older http.status_code) gets it copied into Step.metadata["http_status"] — the same field RulesClassifier's structured-error-code matching reads (shipped two PRs ago). A real HTTP client span with a 429/500/502/503/408/504 status now classifies correctly with zero extra code from the caller — this connects directly to that earlier work rather than duplicating it. - Default relevance filter: only spans with a gen_ai.* attribute, an HTTP status attribute, or an ERROR status become a Step, since a real trace tree also contains internal/infra spans that would just add noise. include_all=True bypasses this for callers who pre-filtered themselves. - Raises RuntimeError if opentelemetry isn't installed, unlike the emit-side helpers' no-op-when-absent pattern — there's no sensible empty-Trajectory fallback for spans you can't actually read. Deliberately NOT included: any Agent-level auto-capture. This is a pure function the caller loops over themselves (see the example) — wiring span capture directly into Agent.run()'s lifecycle needs its own design pass on timing and de-duplication against manual record_step() calls, and Agent.__init__'s stable arg list shouldn't grow for a mechanism that hasn't been used in anger yet. Documented as a deliberate scope cut in CLAUDE.md and the docs page, not a gap. tests/test_observability_otel_ingest.py: 17 tests, all against real opentelemetry-sdk span objects (TracerProvider + InMemorySpanExporter + real record_exception()/set_status() calls) — same discipline as tests/test_observability_otel.py, no hand-mocked dicts. Covers every extraction path, both tool-input key spellings, both HTTP status key spellings, the relevance filter (default and include_all), span ordering when handed out of order, and the no-OTel-installed error. examples/otel_trajectory.py + docs/examples/otel-trajectory.md: a complete runnable demo where the "framework" function is deliberately not triage-aware (no Step import, no record_step call) — it emits a real span with a real recorded exception on its first attempt, and the triage-wrapped agent classifies WRONG_TOOL_CALLED and recovers via retry_with_tool_manifest() entirely from spans it converts after the fact. Verified end-to-end by running it: the retry succeeds, proving classification worked from OTel-derived Steps alone. Registered in mkdocs.yml's nav; `mkdocs build --strict` passes. Also updates README.md (a "already have OpenTelemetry spans?" callout right where record_step's cost is introduced), CLAUDE.md (repo layout, a new design-decision entry, and a previously-undocumented otel extras row that was a pre-existing gap), and CHANGELOG.md. triage/observability/* is covered by an existing pyproject.toml coverage omit (OTel paths require the optional dep to cover meaningfully) — no config change needed, this file falls under it automatically. Verification: ruff check/format clean, pytest 801 passed (was 784 — +17 new tests, 0 skipped), mypy --strict clean, mkdocs build --strict clean, example script run end-to-end successfully. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M4WNEkbnKSx9mTg5Q1jX39
13 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What does this change and why?
Addresses the adoption-barrier critique from earlier in this project's roadmap discussion: every triage integration today requires the wrapped callable to construct
Stepobjects by hand, one per tool call or error, before triage can tell you anything — a real, upfront cost for a probabilistic payoff. If the framework already emits OpenTelemetry spans for the same tool calls and errors (openllmetry/traceloop-style auto-instrumentation, or the OTel GenAI semantic conventions directly), that hand-instrumentation is pure duplication.What's in this PR:
triage/observability/otel_ingest.py—trajectory_from_spans(spans, *, include_all=False) -> Trajectory, the inverse oftriage.observability.otel(which emits triage's own spans). Duck-typed over span-like objects (.name/.attributes/.status/.events/.start_time/.end_time— exactlyopentelemetry.sdk.trace.ReadableSpan's shape).Step.error/exception_type) reads the span'sexceptionevent (exception.message/exception.type) — OTel's core exception-recording convention, stable for years. This is the most reliable part of the conversion.tool_called/tool_input/tool_output) readsgen_ai.tool.nameand both known spellings of the arguments/result attributes (gen_ai.tool.call.argumentsvs.gen_ai.tool.input— the spec renamed this between revisions). Flagged explicitly in the module docstring as best-effort: I verified directly against the live semantic-conventions-genai repo that these are Development-stability, not stable.http.response.status_code(or the olderhttp.status_code) gets copied intoStep.metadata["http_status"]— the same fieldRulesClassifier's structured-error-code matching reads (shipped a couple PRs ago, feat(classifier): RulesClassifier checks structured error codes in Step.metadata #8/feat(classifier): score corpus E — structured error codes generalize for JSON-RPC, not HTTP #9). A real HTTP client span with a429/500/502/503/408/504status now classifies correctly with zero extra code from the caller — this connects directly to that earlier work rather than duplicating it.gen_ai.*attribute, an HTTP status attribute, or anERRORstatus become aStep, since a real trace tree also contains internal/infra spans that would just add noise.include_all=Truebypasses this.RuntimeErrorifopentelemetryisn't installed, unlike the emit-side helpers' no-op-when-absent pattern — there's no sensible empty-Trajectoryfallback for spans you can't actually read.Deliberately NOT included: any
Agent-level auto-capture. This is a pure function the caller loops over themselves (see the example). Wiring span capture directly intoAgent.run()'s lifecycle needs its own design pass on timing and de-duplication against manualrecord_step()calls, andAgent.__init__'s stable arg list shouldn't grow for a mechanism that hasn't been used in anger yet. Documented as a deliberate scope cut inCLAUDE.mdand the docs page, not a gap.tests/test_observability_otel_ingest.py— 17 tests, all against realopentelemetry-sdkspan objects (TracerProvider+InMemorySpanExporter+ realrecord_exception()/set_status()calls), same discipline astests/test_observability_otel.py— no hand-mocked dicts. Covers every extraction path, both tool-input key spellings, both HTTP status key spellings, the relevance filter (default andinclude_all), span ordering when handed out of order, and the no-OTel-installed error.examples/otel_trajectory.py+docs/examples/otel-trajectory.md— a complete runnable demo where the "framework" function is deliberately not triage-aware (noStepimport, norecord_stepcall) — it emits a real span with a real recorded exception on its first attempt, and the triage-wrapped agent classifiesWRONG_TOOL_CALLEDand recovers viaretry_with_tool_manifest()entirely from spans it converts after the fact. Verified end-to-end by running it: the retry succeeds, proving classification worked from OTel-derivedSteps alone, not just unit-level mocks. Registered inmkdocs.yml's nav;mkdocs build --strictpasses.README.md(an "already have OpenTelemetry spans?" callout right whererecord_step's cost is introduced),CLAUDE.md(repo layout, a new design-decision entry, and a previously-undocumentedotelextras row that was a pre-existing gap),CHANGELOG.md.triage/observability/*is covered by an existingpyproject.tomlcoverage omit (OTel paths require the optional dep to cover meaningfully) — no config change needed, this file falls under it automatically.Related issue
None
Type of change
triage/classifier/rules.py,triage/scorer/,tests/data/error_corpus_*.json)Checklist
description are clean. I am the author and I am responsible for this change.
I could not check this one — see note below, same tension as prior PRs on this branch.
pytest tests/ -x --tb=shortpasses locally (801 passed, up from 784 — +17 new tests, 0 skipped)ruff check .,ruff format --check ., andmypy triage/ --strictare all cleanopenai/anthropic/langchain/langgraph/opentelemetry/etc. insidetriage/core — the only OTel import is insidetriage/observability/otel_ingest.py,the designated optional-dependency location (same pattern as
otel.py/metrics.py),lazily imported behind a
try/except ImportErrordocs/conceptsisn't the right home (it's anexample-first feature) so
docs/examples/otel-trajectory.md+examples/otel_trajectory.pycover it instead, and
CHANGELOG.md(under[Unreleased]) is updated. NoStep/FailureType/RecoveryAction/Agent.__init__signature changed — purely additive.FailureTypeorRecoveryAction— N/AIf this touches
rules.pyor an error corpus — N/A, no changes torules.pyor any corpus file.Anything reviewers should look at closely?
Whether the default relevance filter (gen_ai.*/HTTP-status/ERROR-only) is the right default versus
include_all=True— I picked conservative-by-default to avoid noisy trajectories from a real trace tree's infra spans, but it's a judgment call, not something measured against real trace data (there's no corpus for this the way there is forrules.py).Attribution note (not from this repo's policy): same tension as prior PRs on this branch — this repository's own PR template asks for zero AI-attribution anywhere in a PR, and I'm bound by a separate, standing system-level instruction that requires exactly that trailer, which I can't override from inside a session. Surfacing it again rather than silently complying or silently ignoring either instruction.
🤖 Generated with Claude Code
https://claude.ai/code/session_01M4WNEkbnKSx9mTg5Q1jX39
Generated by Claude Code