Skip to content

feat(tracing): correlate business spans with obs via dedicated wrapper span - #484

Open
NiteshDhanpal wants to merge 6 commits into
nextfrom
feat/obs-correlation-edge
Open

feat(tracing): correlate business spans with obs via dedicated wrapper span#484
NiteshDhanpal wants to merge 6 commits into
nextfrom
feat/obs-correlation-edge

Conversation

@NiteshDhanpal

@NiteshDhanpal NiteshDhanpal commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

What & why

Model the business ↔ observability correlation edge on the emit side, so a persisted business span can pivot to its Tempo/Datadog trace and back. No schema migration — the ids ride the existing operation_metadata JSONB (→ ClickHouse metadata_raw).

Two worlds today: business spans (trace_id = task id, stored in application_trace_span + CH) and obs traces (OTel/ddtrace in Tempo/Datadog) share no id. This adds the link.

Changes

  • obs_ids.py — correlation keys standardized to obs_trace_id/obs_span_id (underscored → Postgres JSON-path addressable); removed the non-working dual mode (it needed an in-process ddtrace↔OTel bridge that can't exist; degrades to dd_only); obs_correlation() hardened to never raise.
  • obs_span.py (new) — dedicated per-business-span wrapper obs span:
    • Opens a real span named for the step and makes it active, so obs_span_id is stable/meaningful (not an arbitrary innermost httpx span); nested instrumentation parents under it.
    • Backends: OTel (lgtm) / ddtrace (dd_only, only when a request trace is active — avoids orphan roots).
    • Reverse tag: stamps agentex.business_span_id / agentex.business_trace_id on the obs span → bidirectional pivot.
    • Error status propagated to the obs span (failed step is not a false green).
    • child_of nesting (ddtrace): ddtrace's start_span does not auto-parent — passes child_of=current_trace_context() so a turn's spans roll up into ONE trace instead of N roots.
  • trace.py — wraps start_span/end_span (sync + async). Observability can never fail an app call (guarded; no-op when unconfigured); the two backends never interfere.

Behavior (Turn 2 of the 3-turn example)

business step before (obs_span_id) with wrapper
get_state rB wB1
retrieve_docs rB wB2
chat_completion rB wB3
create_message rB wB4

Distinct, named obs_span_id per step; all under the one turn obs trace B.

✅ Live validation — infra-staging, dd_only, real rocket-mock turn (52 spans)

Deployed this branch (git-dep) to sgp-rocket-mock-agent on sgp-infra-staging, fired a real message/send, and read the spans straight out of egp application_trace_span.operation_metadata:

  • Edge populates: 52/52 business spans carry obs_trace_id + obs_span_id in operation_metadatano migration.

  • Wrapper works: obs_span_id is distinct per step (52 distinct) — a dedicated named span per business step, not the coarse request span.

  • child_of fix — before/after:

    DISTINCT obs_trace_id DISTINCT obs_span_id
    before (start_span no child_of) 52 (each a new root) 52
    after (child_of=current_ctx) 1 (per-turn roll-up) 52

    → all 52 spans now share the one request/distributed trace id (ca5e90d9…), each a distinct named wrapper span nested under it — the per-turn (TurnTrace) shape.

  • Sample operation_metadata: {"turn":0,"agent":"rocket_mock_agent","__source__":"agentex","obs_trace_id":"ca5e90d9…","obs_span_id":"…","__agent_id__":"c7a9692f…"}

Tests

test_obs_ids.py + test_obs_span.py: mode degrade + keys, both backends, non-interference, never-fails, reverse tag, error status, child_of nesting, and the Turn-2 example. Full tracing suite green.

Not in this PR (follow-ups)

  • lgtm/Tempo population needs an OTel TracerProvider in the agent (operator auto-instrumentation or app-level) — SDK side is complete and backend-agnostic.
  • The sgp-obs-tracing-middleware library still carries dual/bridge.py — separate cleanup.

🤖 Generated with Claude Code

Greptile Summary

Adds dedicated observability wrapper spans for business spans.

  • Selects OTel or Datadog according to the configured observability mode and records bidirectional correlation identifiers.
  • Integrates wrapper creation, error propagation, and closure into synchronous and asynchronous tracing.
  • Adds coverage for identifier formatting, backend isolation, lifecycle behavior, and cross-instance closure.

Confidence Score: 4/5

The synchronous startup cleanup failure should be fixed before merging because an ordinary processor exception can permanently leak an active wrapper span.

The new handle is registered before fallible synchronous processor callbacks, while the only normal cleanup path requires start_span to return successfully.

Files Needing Attention: src/agentex/lib/core/tracing/trace.py

Important Files Changed

Filename Overview
src/agentex/lib/core/tracing/obs_ids.py Standardizes correlation metadata keys, removes dual-mode selection, and makes identifier lookup best-effort.
src/agentex/lib/core/tracing/obs_span.py Implements backend-specific wrapper-span activation, correlation, reverse tags, error propagation, and closure.
src/agentex/lib/core/tracing/trace.py Integrates wrapper handles into both tracing paths, but synchronous processor startup failures can leak activated wrappers.

Sequence Diagram

sequenceDiagram
    participant App
    participant Trace
    participant Obs as OTel / Datadog
    participant Processor
    App->>Trace: start_span(name)
    Trace->>Obs: start and activate wrapper
    Obs-->>Trace: obs_trace_id, obs_span_id
    Trace->>Processor: on_span_start(business span)
    App->>Trace: end_span(span)
    Trace->>Obs: propagate error and close
    Trace->>Processor: on_span_end(span)
Loading

Comments Outside Diff (1)

  1. src/agentex/lib/core/tracing/trace.py, line 124-130 (link)

    P1 Processor failures leak wrapper spans

    When a synchronous on_span_start processor raises, the newly activated observability handle remains in _OBS_HANDLES because start_span never returns and the context manager cannot call end_span, leaving the wrapper unexported and its active context able to parent subsequent instrumentation incorrectly.

    Prompt To Fix With AI
    This is a comment left during a code review.
    Path: src/agentex/lib/core/tracing/trace.py
    Line: 124-130
    
    Comment:
    **Processor failures leak wrapper spans**
    
    When a synchronous `on_span_start` processor raises, the newly activated observability handle remains in `_OBS_HANDLES` because `start_span` never returns and the context manager cannot call `end_span`, leaving the wrapper unexported and its active context able to parent subsequent instrumentation incorrectly.
    
    
    
    ---
    
    For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

    Fix in Cursor Fix in Claude Code Fix in Codex

Fix All in Cursor Fix All in Claude Code Fix All in Codex

Prompt To Fix All With AI
### Issue 1
src/agentex/lib/core/tracing/trace.py:124-130
**Processor failures leak wrapper spans**

When a synchronous `on_span_start` processor raises, the newly activated observability handle remains in `_OBS_HANDLES` because `start_span` never returns and the context manager cannot call `end_span`, leaving the wrapper unexported and its active context able to parent subsequent instrumentation incorrectly.

```suggestion
        if obs_handle is not None:
            _OBS_HANDLES[span.id] = obs_handle

        try:
            for processor in self.processors:
                processor.on_span_start(span)
        except Exception:
            close_obs_span(_OBS_HANDLES.pop(span.id, None))
            raise

        return span
```

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Reviews (1): Last reviewed commit: "fix(tracing): satisfy pyright in obs-cor..." | Re-trigger Greptile

NiteshDhanpal and others added 4 commits August 1, 2026 10:58
…r span

Model the business<->observability correlation edge on the emit side, with no
schema migration (rides the existing operation_metadata JSONB).

- obs_ids: standardize the correlation keys to obs_trace_id/obs_span_id
  (underscored, JSON-path friendly); remove the non-working `dual` mode (it
  required an in-process ddtrace<->OTel bridge that can't exist -- you can't run
  ddtrace-run and the OTel operator together, and DD_TRACE_OTEL_ENABLED is a
  single tracer). `dual` now safely degrades to dd_only. Harden obs_correlation
  to never raise.
- obs_span (new): when the SDK creates a business span it opens a dedicated obs
  span named for that step and makes it active, so obs_span_id is stable and
  meaningful (a named span with its httpx call nested underneath) instead of an
  arbitrary innermost instrumentation span. Backends: OTel in lgtm; ddtrace in
  dd_only but only when a request trace is already active (avoids orphan root
  traces in un-instrumented agents). Reverse tag: stamps
  agentex.business_span_id / agentex.business_trace_id onto the obs span so the
  pivot is bidirectional.
- trace: wire the wrapper into start_span/end_span (sync + async). Observability
  can never fail an app call -- every path is guarded and is a no-op when the
  tracer isn't configured.
- tests: obs_ids (mode degrade + keys), obs_span (both backends,
  non-interference, never-fails, reverse tag), and the 3-turn mortgage Turn-2
  example pinned as an executable contract.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Observability review (P1): the dedicated wrapper obs span was ended/finished
without recording failure, so a failed business step (e.g. chat_completion)
showed green in Tempo/DD -- violating "observe both success and failure" and
undercutting the meaningful-obs_span_id goal.

close_obs_span now takes the business span's error (from get_span_error) and
marks the obs span before closing:
  - OTel:    span.set_status(Status(ERROR, msg)) + error.type attribute
  - ddtrace: span.error = 1 + error.type / error.message tags
end_span passes error=get_span_error(span) on both sync and async paths.

Success path is unchanged (no status set). Guarded so error-marking can never
break the close.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
ddtrace start_span does not auto-parent (unlike OTel): start_span(name) mints a
new ROOT trace every call, so a turn's business spans scattered across N Datadog
traces (verified live: 52 spans -> 52 distinct obs_trace_ids). Pass
child_of=current_trace_context() so wrappers nest under the request/turn trace
and roll up into one trace; obs_span_id stays distinct per step.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The obs wrapper span (opened in start_span, ended in end_span) was tracked
in an INSTANCE dict (self._obs_handles). But TracingService creates a fresh
Trace object for every call -- self._tracer.trace(trace_id) in BOTH
start_span and end_span -- so end_span ran on a different instance with an
empty dict: the handle was never found, close_obs_span(None) was a no-op,
and the OTel/ddtrace wrapper span was never .end()ed.

Consequence in lgtm mode: the wrapper span records and its ids are written
to Postgres (read at start), but since Simple/Batch span processors only
export on span end, the span never reaches Tempo -- the turn trace was
silently missing while everything looked correct (provider ours, sampler
ALWAYS_ON, recording=True, ids stored).

Fix: move the handle registry to module level, keyed by the uuid4 span id,
so it survives across Trace instances. Adds a regression test that starts a
span on one Trace instance and ends it on another.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
NiteshDhanpal and others added 2 commits August 2, 2026 22:30
Fixes the failing lint job on this PR (ruff I001 import ordering + format)
on the obs_ids / obs_span / trace correlation-edge files and their tests.
No logic change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Annotate fake ModuleType stubs as Any (pyright rejects attribute assignment
on ModuleType), widen the mock 'record' dicts to dict[str, Any], assert the
Optional resolver returns before unpacking, and narrow span.data with
isinstance before subscripting. Clears the pyright errors failing the lint
job. No behavior change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@NiteshDhanpal
NiteshDhanpal marked this pull request as ready for review August 3, 2026 05:46
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.

1 participant