Skip to content

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

Draft
NiteshDhanpal wants to merge 3 commits into
nextfrom
feat/obs-correlation-edge
Draft

feat(tracing): correlate business spans with obs via dedicated wrapper span#484
NiteshDhanpal wants to merge 3 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

NiteshDhanpal and others added 3 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>
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