Skip to content

feat(llmobs): realtime audio-timing metadata for full-conversation playback#19112

Draft
ZStriker19 wants to merge 17 commits into
mainfrom
llmobs-openai-realtime-audio-timing
Draft

feat(llmobs): realtime audio-timing metadata for full-conversation playback#19112
ZStriker19 wants to merge 17 commits into
mainfrom
llmobs-openai-realtime-audio-timing

Conversation

@ZStriker19

@ZStriker19 ZStriker19 commented Jul 16, 2026

Copy link
Copy Markdown
Collaborator

Draft / eval branch — not intended to merge as-is. Stacks on #18759 (base branch llmobs-openai-realtime).

What

Adds absolute (unix ns) audio-timing metadata to each OpenAI Realtime turn span so a full-conversation audio playback experience can place each turn's audio on one shared timeline and measure time-to-response. The base realtime integration captures audio + transcripts but no timing.

New span metadata keys, under the internal _dd. prefix so they are hidden from the metadata UI (omitted when a turn has no audio):

  • _dd.llmobs.audio.input.start_time_unix_nano — first user-audio chunk of the turn
  • _dd.llmobs.audio.output.start_time_unix_nano — first response.audio.delta of the turn
  • _dd.llmobs.audio.input.speech_end_time_unix_nanoinput_audio_buffer.committed

Timestamps are captured on the tracer's own clock as each segment is observed (provider-agnostic), and emitted as metadata, not tags (timestamps are high-cardinality).

How

  • _AudioAccumulator.start_ns (set on first chunk) gives both the input and output segment starts; _InputTurn.speech_end_ns set on commit.
  • _audio_timing(turn) assembles the keys; threaded through _llmobs_set_tags_from_realtime_response, which merges them into span metadata.

Tests

  • test_realtime_state_audio_turn_carries_timing_anchors — three keys present, input.start <= speech_end <= output.start.
  • test_realtime_state_text_only_turn_has_no_audio_timing — no keys on a text-only turn.

Ran pytest tests/contrib/openai/test_openai_realtime.py (editable ddtrace, openai 2.45.0): 24 passed including both new tests. The 5 errored cases are the pre-existing test_realtime_integration_* tests, which need the full ddtrace test-harness fixtures (DummyWriter tracer / pytest-asyncio) not present in the minimal venv used here — unrelated to this change. Also verified end-to-end by driving the real state machine and confirming a createRealtimeResponse span with the _dd.llmobs.audio.* metadata POSTs to the staging intake (HTTP 202).

Part of the full-conversation-audio-playback effort (RFC + spec authored separately).

🤖 Generated with Claude Code

ZStriker19 and others added 14 commits June 25, 2026 15:03
Auto-instruments client.realtime.connect (sync + async): a workflow span for the
session and an llm child span per response turn, capturing input/output transcripts,
token usage, session config, and audio_parts for renderable formats. Realtime audio is
raw PCM by default (not renderable), so those turns keep the transcript as content.

Adds reusable audio helpers (realtime_audio_format_to_mime, is_renderable_audio_mime,
concat_base64_audio, format_audio_part_with_guard with a 5MB inline size guard) and an
activate flag on BaseLLMIntegration.trace so long-lived/child spans don't disturb the
active trace context.
- Tag in-flight response spans on close instead of finishing them empty.
- Record client send events only after the underlying send succeeds.
- Observe server events via parse_event (covers recv, iteration, and the manual
  recv_bytes()+parse_event() path) instead of recv.
- Handle input_audio_buffer.clear/cleared to drop discarded audio.
- Prune the input-transcript map (pop on assign, clear on close).
- Skip non-user items in conversation.item.create input absorption.
Records the deferred-by-design gaps (late input transcription after response.done,
out-of-band response.create inline input, single pending-input turn) in the module
docstring.
Wrap raw PCM16 realtime audio (the API default, 24kHz mono) in a WAV container so it is
emitted as a playable audio/wav audio_part instead of being dropped as non-renderable.
The 4MB inline size guard runs on the WAV bytes and debug-logs when oversize audio is
dropped (transcript is kept). G.711 (pcmu/pcma) stays unwrapped and falls back to the
[audio] marker. Captures the input/output audio sample rate from the session config.
…ate transcripts

From live testing feedback:
- Drop the realtime session workflow span; each turn is its own llm span (its own trace)
  carrying the session config as metadata, and all turns of a connection share a session_id
  so the UI groups them. Keeps each trace one-turn small (per-event size headroom) and renders
  cleanly.
- Capture input-audio transcriptions that arrive after response.done: hold the turn span open
  until the transcript lands (matched by item_id), with fallbacks on the next response.created
  and on close so spans never leak.
openai>=1.66's RealtimeConnection.recv_bytes() asserts the underlying recv(decode=False)
returns bytes; the test double returned the scripted JSON as str, failing the integration
test on the openai==1.66.0 CI venv. Encode str messages to bytes.
Address PR review:
- Type-annotate ddtrace/contrib/internal/openai/_realtime.py fully and drop the
  per-module mypy override (the new code is now type-safe).
- The inline-audio size guard now measures the base64-encoded size (what actually
  rides the span event, ~4/3 the raw bytes) instead of the raw byte count, so it
  can't admit audio that exceeds the per-span-event limit after encoding.
From a second Codex + subagent review pass:
- Only defer a turn's span for a late input transcription when transcription is actually
  enabled in the session config; otherwise finalize at response.done (no transcript is
  coming, so waiting needlessly delayed every turn and held the last turn until close).
- Handle conversation.item.input_audio_transcription.failed: finalize any turn awaiting
  that item so its span can't hang on an idle session.
- Stop the input-transcript cache from leaking: pop each turn's entry on finalize (every
  finalize path) instead of only when the transcript was unset.
- Finalize the session when the connection closes mid-iteration without with/close():
  the recv wrappers detect ConnectionClosed and call finish_session (idempotent).
- Derive total_tokens from input+output when usage omits it (matches chat/responses).
Adds tests for each plus an async-connection integration test.
The module docstring is never rendered as Markdown/RST, so drop the inline-literal
backticks and bold/italic markup for plain-text readability (review nit).
G.711 (audio/pcmu / audio/pcma) is the codec for Realtime phone-call integrations
(Twilio/SIP). Decode it to PCM16 with a dependency-free mu-law/A-law decoder (the stdlib
wave module can't emit G.711 WAV and audioop was removed in 3.13) and WAV-wrap at 8kHz so
those turns produce a playable audio/wav audio_part instead of an [audio] marker.
Realtime agents call tools, but turns only captured audio/transcripts before. Parse
response.done output items for function_call and mcp_call and attach them as tool_calls on
the turn's llm span (MCP results, which run server-side, ride along as tool_results). The
function_call_output the app feeds back is captured as a tool_result on the next turn's
input. Tool-call-only turns (no audio/text) still emit a span.
The function_call_output event the app returns carries only call_id + output (no name), so
the tool result showed as 'unknown' in the UI. Correlate the call_id back to the originating
function_call's name and set it on the ToolResult.
… shadow metrics

- Move audio helpers to a dedicated ddtrace/llmobs/_integrations/audio_utils.py
  (re-exported from utils.py) so the growing audio surface lives on its own.
- Add a DD_OPENAI_REALTIME_ENABLED kill-switch to patch_realtime().
- Cap accumulated realtime audio via _AudioAccumulator so a long turn can't grow
  unbounded before the size guard runs; free chunks once oversize.
- Apply shadow metrics on realtime LLM spans for APM parity.
- Add multi-turn output-audio, tool-call, and patch/unpatch tests.
…ation playback

Realtime turn spans now carry absolute (unix ns) audio-timing anchors in metadata
(dd.llmobs.audio.input/output.start_time_unix_nano, input.speech_end_time_unix_nano),
captured on the tracer's own clock as each segment is observed. These let a
full-conversation audio playback UI place each turn's audio on one shared timeline
and measure time-to-response. Text-only and tool-only turns carry no timing keys.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@cit-pr-commenter-54b7da

Copy link
Copy Markdown

Codeowners resolved as

ddtrace/contrib/internal/openai/_realtime.py                            @DataDog/ml-observability @DataDog/asm-python
ddtrace/llmobs/_integrations/openai.py                                  @DataDog/ml-observability
releasenotes/notes/llmobs-realtime-audio-timing-a1b2c3d4e5f60718.yaml   @DataDog/apm-python
tests/contrib/openai/test_openai_realtime.py                            @DataDog/ml-observability

@datadog-prod-us1-6

datadog-prod-us1-6 Bot commented Jul 16, 2026

Copy link
Copy Markdown

Pipelines  Tests

Unblock PR with BitsAI

⚠️ Warnings

🚦 13 Pipeline jobs failed

Changelog | Validate changelog   View in Datadog   GitHub Actions

DataDog/apm-reliability/dd-trace-py | ast-grep rules   View in Datadog   GitLab

DataDog/apm-reliability/dd-trace-py | build linux serverless: [amd64, cp315-cp315, v113741238-d2b8243-manylinux2014_x86_64, 1]   View in Datadog   GitLab

View all 13 failed jobs.

ℹ️ Info

No other issues found (see more)

🧪 All tests passed
❄️ No new flaky tests detected

Useful? React with 👍 / 👎

This comment will be updated automatically if new data arrives.
🔗 Commit SHA: 03b3b4f | Docs | Datadog PR Page | Give us feedback!

ZStriker19 and others added 3 commits July 16, 2026 15:12
Rename the realtime audio-timing metadata keys from dd.llmobs.audio.* to
_dd.llmobs.audio.* so they are treated as internal and hidden from the LLMObs
metadata UI, while still available to the full-conversation-playback feature.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ch config

Address PR review follow-ups for the OpenAI Realtime API instrumentation:
- add realtime wrapped-method assertions to the canonical OpenAI patch test
- document DD_OPENAI_REALTIME_ENABLED in the release note
- register DD_OPENAI_REALTIME_ENABLED in supported-configurations.json
  (and regenerate _supported_configurations.py) so the config-registry
  check passes

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…e-audio-timing

# Conflicts:
#	ddtrace/llmobs/_integrations/utils.py
#	tests/llmobs/test_integrations_utils.py
@cit-pr-commenter-54b7da

Copy link
Copy Markdown

Circular import analysis

⚠️ Existing circular imports

There are 61 circular imports that already exist on the base branch and have not been changed by this PR.

Show existing cycles (showing 5 of 61 shortest)
ddtrace.internal.datastreams -> ddtrace.internal.datastreams.kafka -> ddtrace.internal.datastreams
ddtrace.internal.datastreams -> ddtrace.internal.datastreams.kombu -> ddtrace.internal.datastreams
ddtrace.internal.datastreams -> ddtrace.internal.datastreams.aiokafka -> ddtrace.internal.datastreams
ddtrace.internal.core -> ddtrace._trace.span -> ddtrace.internal.core
ddtrace.internal.datastreams -> ddtrace.internal.datastreams.google_cloud_pubsub -> ddtrace.internal.datastreams

To see all cycles, download the cycles-base.json and cycles-pr.json artifacts from this CI job and run:

uv run --script scripts/import-analysis/cycles.py compare cycles-base.json cycles-pr.json

✅ Circular imports removed

4913 circular import(s) have been removed by this PR.

Show removed cycles (showing 5 of 4913 shortest)
ddtrace -> ddtrace.trace -> ddtrace._trace.tracer -> ddtrace.internal.atexit -> ddtrace
ddtrace.trace -> ddtrace._trace.tracer -> ddtrace.internal.writer -> ddtrace.internal.writer.writer -> ddtrace.trace
ddtrace -> ddtrace.internal.settings._config -> ddtrace.internal.schema -> ddtrace.internal.schema.span_attribute_schema -> ddtrace
ddtrace -> ddtrace.internal.datastreams -> ddtrace.internal.datastreams.processor -> ddtrace.internal.atexit -> ddtrace
ddtrace -> ddtrace.internal.datastreams -> ddtrace.internal.datastreams.processor -> ddtrace.internal.process_tags -> ddtrace

To see all cycles, download the cycles-base.json and cycles-pr.json artifacts from this CI job and run:

uv run --script scripts/import-analysis/cycles.py compare cycles-base.json cycles-pr.json

@pr-commenter

pr-commenter Bot commented Jul 16, 2026

Copy link
Copy Markdown

Benchmarks

Benchmark execution time: 2026-07-16 21:09:17

Comparing candidate commit 03b3b4f in PR branch llmobs-openai-realtime-audio-timing with baseline commit 2c95256 in branch main.

Found 0 performance improvements and 4 performance regressions! Performance is the same for 443 metrics, 1 unstable metrics.

scenario:iast_aspects-re_expand_aspect

  • 🟥 execution_time [+277.956µs; +321.386µs] or [+7.724%; +8.931%]

scenario:span-start

  • 🟥 execution_time [+1.429ms; +1.619ms] or [+9.206%; +10.431%]

scenario:telemetryaddmetric-1-count-metric-1-times

  • 🟥 execution_time [+219.758ns; +259.111ns] or [+10.627%; +12.530%]

scenario:tracer-small

  • 🟥 execution_time [+27.141µs; +29.408µs] or [+7.733%; +8.379%]

Base automatically changed from llmobs-openai-realtime to main July 18, 2026 17:47
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