Skip to content

feat(observability): build Trajectory from OTel spans instead of hand-written record_step() - #10

Merged
mattekudacy merged 1 commit into
mainfrom
claude/serene-ritchie-g11dsf
Sep 11, 2026
Merged

mattekudacy merged 1 commit into
mainfrom
claude/serene-ritchie-g11dsf

Conversation

@mattekudacy

Copy link
Copy Markdown
Owner

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 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.

What's in this PR:

  • triage/observability/otel_ingest.pytrajectory_from_spans(spans, *, include_all=False) -> Trajectory, 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: I verified directly against the live semantic-conventions-genai repo that these are Development-stability, not stable.
    • A span carrying the stable http.response.status_code (or the older http.status_code) gets copied into Step.metadata["http_status"] — the same field RulesClassifier'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 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.
    • 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, not just unit-level mocks. Registered in mkdocs.yml's nav; mkdocs build --strict passes.

  • README.md (an "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), 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.

Related issue

None


Type of change

  • Bug fix
  • New feature (failure type, recovery strategy, classifier, adapter, checkpoint store, ...)
  • Breaking change
  • Documentation
  • Classifier / corpus change (triage/classifier/rules.py, triage/scorer/, tests/data/error_corpus_*.json)

Checklist

  • No AI-attribution trailers or badges anywhere in this PR — commit messages and this
    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=short passes locally (801 passed, up from 784 — +17 new tests, 0 skipped)
  • ruff check ., ruff format --check ., and mypy triage/ --strict are all clean
  • No new imports of openai/anthropic/langchain/langgraph/opentelemetry/etc. inside
    triage/ core — the only OTel import is inside triage/observability/otel_ingest.py,
    the designated optional-dependency location (same pattern as otel.py/metrics.py),
    lazily imported behind a try/except ImportError
  • If this adds or changes public API: docs/concepts isn't the right home (it's an
    example-first feature) so docs/examples/otel-trajectory.md + examples/otel_trajectory.py
    cover it instead, and CHANGELOG.md (under [Unreleased]) is updated. No Step/
    FailureType/RecoveryAction/Agent.__init__ signature changed — purely additive.
  • If this adds a FailureType or RecoveryAction — N/A

If this touches rules.py or an error corpus — N/A, no changes to rules.py or 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 for rules.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

…-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
@mattekudacy
mattekudacy merged commit 25dbd48 into main Sep 11, 2026
7 checks passed
@mattekudacy
mattekudacy deleted the claude/serene-ritchie-g11dsf branch September 11, 2026 17:34
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants