Skip to content

feat(llmobs): instrument openai realtime api for audio#18759

Merged
gh-worker-dd-mergequeue-cf854d[bot] merged 21 commits into
mainfrom
llmobs-openai-realtime
Jul 18, 2026
Merged

feat(llmobs): instrument openai realtime api for audio#18759
gh-worker-dd-mergequeue-cf854d[bot] merged 21 commits into
mainfrom
llmobs-openai-realtime

Conversation

@ZStriker19

Copy link
Copy Markdown
Collaborator

Description

LLM Observability: auto-instruments the OpenAI Realtime API (client.realtime.connect(...), sync and async), the only streaming-audio surface in the OpenAI SDK (chat-completions streaming carries no audio).

The Realtime API is a bidirectional WebSocket event stream, so it can't reuse the request/response streaming path. Instead, this wraps the connection's send/recv/close at the class level (every typed sub-resource send funnels through RealtimeConnection.send, and iteration funnels through recv) and feeds each observed event into a _RealtimeState machine that produces:

  • a workflow span for the session (connect → close), tagged with model, voice, input/output audio formats, and modalities; and
  • an llm child span per response turn (response.createdresponse.done), capturing the user and assistant transcripts as message content, token usage, and audio.

Audio is emitted as audio_parts only for renderable formats. The Realtime API's default raw PCM (audio/pcm|pcmu|pcma) is not renderable, so those turns keep the transcript as message content (the playable-audio path is generic and future-proof). A reusable 5 MB inline size guard (format_audio_part_with_guard) keeps audio under the per-span-event limit.

Builds on the audio audio_parts foundation (#18622) and the audio-in-chat work (#18695).

Testing

  • Unit tests for the new audio helpers (realtime_audio_format_to_mime, is_renderable_audio_mime, concat_base64_audio, format_audio_part_with_guard).
  • _RealtimeState unit tests driving full turns (PCM transcripts-only, renderable audio_parts, session metadata, failed-status error, idempotent close, audio-only fallback marker).
  • An integration test driving a real patched RealtimeConnection over a fake websocket, asserting the resulting session + per-response LLMObs spans.
  • lint (fmt/style/typing/spelling/suitespec/error-log) all pass.

Parametric/system tests are not included here, consistent with the audio-feature PRs that preceded this (#18622, #18695).

Risks

Low. Realtime instrumentation is gated on LLMObs being enabled and is fully wrapped in defensive try/except so it can never break a user's connection. Class-method wrapping is unpatched cleanly in unpatch(). No changes to existing chat/responses/completions paths beyond a backward-compatible activate kwarg on BaseLLMIntegration.trace and a _get_model_provider refactor (covered by existing tests).

Additional Notes

Claude session: 57bda7ea-70ec-482f-a5fe-973dbf97f8c6
Resume: claude --resume 57bda7ea-70ec-482f-a5fe-973dbf97f8c6

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.
@ZStriker19
ZStriker19 requested review from a team as code owners June 25, 2026 19:07
@cit-pr-commenter-54b7da

cit-pr-commenter-54b7da Bot commented Jun 25, 2026

Copy link
Copy Markdown

Codeowners resolved as

ddtrace/contrib/internal/openai/__init__.py                             @DataDog/ml-observability @DataDog/asm-python
ddtrace/contrib/internal/openai/_realtime.py                            @DataDog/ml-observability @DataDog/asm-python
ddtrace/contrib/internal/openai/patch.py                                @DataDog/ml-observability @DataDog/asm-python
ddtrace/internal/settings/_supported_configurations.py                  @DataDog/apm-sdk-capabilities-python @DataDog/apm-python
ddtrace/llmobs/_integrations/audio_utils.py                             @DataDog/ml-observability
ddtrace/llmobs/_integrations/base.py                                    @DataDog/ml-observability
ddtrace/llmobs/_integrations/openai.py                                  @DataDog/ml-observability
ddtrace/llmobs/_integrations/utils.py                                   @DataDog/ml-observability
docs/spelling_wordlist.txt                                              @DataDog/python-guild
releasenotes/notes/llmobs-openai-realtime-98f497c0d41f80b8.yaml         @DataDog/apm-python
supported-configurations.json                                           @DataDog/apm-sdk-capabilities-python @DataDog/apm-python
tests/contrib/openai/test_openai_patch.py                               @DataDog/ml-observability
tests/contrib/openai/test_openai_realtime.py                            @DataDog/ml-observability
tests/llmobs/test_integrations_utils.py                                 @DataDog/ml-observability

@datadog-datadog-prod-us1-2

datadog-datadog-prod-us1-2 Bot commented Jun 25, 2026

Copy link
Copy Markdown
Contributor

Tests

🎉 All green!

🧪 All tests passed
❄️ No new flaky tests detected

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

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: c45b55f71e

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread ddtrace/contrib/internal/openai/_realtime.py
Comment thread ddtrace/contrib/internal/openai/_realtime.py Outdated
Comment thread ddtrace/llmobs/_integrations/utils.py Outdated
- 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.
Comment thread mypy.ini Outdated
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.
@gh-worker-ownership-write-b05516
gh-worker-ownership-write-b05516 Bot removed the request for review from a team June 27, 2026 01:51
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.
Comment thread ddtrace/contrib/internal/openai/_realtime.py
Comment thread releasenotes/notes/llmobs-openai-realtime-98f497c0d41f80b8.yaml Outdated
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.
Comment thread releasenotes/notes/llmobs-openai-realtime-98f497c0d41f80b8.yaml
Comment thread ddtrace/contrib/internal/openai/_realtime.py Outdated
Comment thread ddtrace/contrib/internal/openai/_realtime.py
Comment thread tests/contrib/openai/test_openai_realtime.py
Comment thread tests/contrib/openai/test_openai_realtime.py
Comment thread ddtrace/contrib/internal/openai/patch.py
Comment thread ddtrace/llmobs/_integrations/utils.py Outdated
Comment thread ddtrace/contrib/internal/openai/_realtime.py
Comment thread tests/contrib/openai/test_openai_realtime.py
… 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.
Comment thread ddtrace/contrib/internal/openai/_realtime.py Outdated

@ncybul ncybul left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A couple of remaining nits (pls ignore if you already fixed and I just missed it)!

@brettlangdon brettlangdon left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

release note lgtm

…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>
@ZStriker19
ZStriker19 requested a review from a team as a code owner July 16, 2026 20:46
@ZStriker19
ZStriker19 requested a review from rachelyangdog July 16, 2026 20:46
# Conflicts:
#	ddtrace/llmobs/_integrations/utils.py
#	tests/llmobs/test_integrations_utils.py
@cit-pr-commenter-54b7da

cit-pr-commenter-54b7da Bot commented Jul 16, 2026

Copy link
Copy Markdown

Circular import analysis

⚠️ Existing circular imports

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

Show existing cycles (showing 5 of 45 shortest)
ddtrace.internal.core -> ddtrace._trace.span -> ddtrace.internal.core
ddtrace.internal.datastreams -> ddtrace.internal.datastreams.aiokafka -> ddtrace.internal.datastreams
ddtrace.internal.datastreams -> ddtrace.internal.datastreams.kombu -> ddtrace.internal.datastreams
ddtrace.internal.datastreams -> ddtrace.internal.datastreams.botocore -> ddtrace.internal.datastreams
ddtrace.internal.datastreams -> ddtrace.internal.datastreams.kafka -> 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

ZStriker19 and others added 6 commits July 16, 2026 16:53
The ast-grep `os-environ-fix-access` rule (and the ast-grep CI job) bans
direct os.environ access. Read DD_OPENAI_REALTIME_ENABLED through
ddtrace.internal.settings.env instead.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The pre-check spelling job (codespell) flags `datas` -> `data`. Rename the
local variable in test_realtime_integration_multi_turn_with_output_audio.

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The Sphinx (sphinxcontrib.spelling) build flags "Realtime" in the OpenAI
integration docstring. Add it to docs/spelling_wordlist.txt, alongside similar
product names like "Anthropic".

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@gh-worker-dd-mergequeue-cf854d
gh-worker-dd-mergequeue-cf854d Bot merged commit cfeacbb into main Jul 18, 2026
590 checks passed
@gh-worker-dd-mergequeue-cf854d
gh-worker-dd-mergequeue-cf854d Bot deleted the llmobs-openai-realtime branch 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.

4 participants