Skip to content

Commit d75120e

Browse files
[mq] [skip ddci] working branch - merge eab4cc8 on top of main at 7094318
{"baseBranch":"main","baseCommit":"709431845106bc9379b30b7d8807a1ab93bd6796","createdAt":"2026-07-18T16:42:35.012927Z","headSha":"eab4cc828f53bb1403287a2662b1eafa4b7c0bd3","id":"26200142-118e-4096-884a-98727b590988","nextMergeabilityCheckAt":"2026-07-18T18:14:00.382886Z","priority":"200","pullRequestNumber":"18759","queuedAt":"2026-07-18T17:14:03.981803Z","status":"STATUS_QUEUED"}
2 parents 78d1d93 + eab4cc8 commit d75120e

14 files changed

Lines changed: 2119 additions & 35 deletions

File tree

ddtrace/contrib/internal/openai/__init__.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,4 +57,12 @@
5757
The service name reported by default for OpenAI requests.
5858
5959
Alternatively, set this option with the ``DD_OPENAI_SERVICE`` environment variable.
60+
61+
62+
.. py:data:: DD_OPENAI_REALTIME_ENABLED
63+
64+
Enables instrumentation of the OpenAI Realtime API. Set to ``false`` to disable Realtime
65+
instrumentation while keeping the rest of the OpenAI integration enabled.
66+
67+
Default: ``true``
6068
""" # noqa: E501

ddtrace/contrib/internal/openai/_realtime.py

Lines changed: 775 additions & 0 deletions
Large diffs are not rendered by default.

ddtrace/contrib/internal/openai/patch.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88
from ddtrace import config
99
from ddtrace.contrib.internal.openai import _endpoint_hooks
10+
from ddtrace.contrib.internal.openai import _realtime
1011
from ddtrace.contrib.trace_utils import unwrap
1112
from ddtrace.contrib.trace_utils import wrap
1213
from ddtrace.internal import core
@@ -141,6 +142,8 @@ def patch():
141142
if deep_getattr(openai, async_method) is not None:
142143
wrap(openai, async_method, _patched_endpoint_async(endpoint_hook))
143144

145+
_realtime.patch_realtime()
146+
144147
openai.__datadog_patch = True
145148

146149
# Notify AI Guard (and any other plugin) that wrapping is complete so they
@@ -187,6 +190,8 @@ def unpatch():
187190
if async_resource is not None and hasattr(async_resource, method_name):
188191
unwrap(async_resource, method_name)
189192

193+
_realtime.unpatch_realtime()
194+
190195
delattr(openai, "_datadog_integration")
191196

192197

ddtrace/internal/settings/_supported_configurations.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -345,6 +345,7 @@
345345
"DD_MYSQL_SERVICE",
346346
"DD_MYSQL_TRACE_FETCH_METHODS",
347347
"DD_OPENAI_AGENTS_SERVICE",
348+
"DD_OPENAI_REALTIME_ENABLED",
348349
"DD_OPENAI_SERVICE",
349350
"DD_ORIGIN_DETECTION_ENABLED",
350351
"DD_PARTITION",
Lines changed: 211 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,211 @@
1+
"""Audio helpers shared by the LLM Observability integrations.
2+
3+
Building ``AudioPart``s, mapping provider audio formats to MIME types, and turning raw audio
4+
(PCM16, G.711) into playable WAV within the per-span-event size budget. Kept separate from the
5+
larger ``utils.py`` since the audio surface has grown (chat audio, realtime, telephony).
6+
"""
7+
8+
import base64
9+
import io
10+
import struct
11+
from typing import Any
12+
from typing import Optional
13+
from typing import Union
14+
import wave
15+
16+
from ddtrace.internal.logger import get_logger
17+
from ddtrace.llmobs._utils import _get_attr
18+
from ddtrace.llmobs.types import AudioPart
19+
20+
21+
logger = get_logger(__name__)
22+
23+
24+
def format_audio_part(data: Union[bytes, str], mime_type: str) -> AudioPart:
25+
"""Build an ``AudioPart`` from raw audio bytes (base64-encoded) or an existing base64 string."""
26+
content = base64.b64encode(data).decode("utf-8") if isinstance(data, bytes) else data
27+
return AudioPart(mime_type=mime_type, content=content)
28+
29+
30+
# OpenAI audio ``format`` values that don't map to ``audio/<format>``.
31+
_OPENAI_AUDIO_MIME_TYPES = {
32+
"mp3": "audio/mpeg",
33+
}
34+
35+
36+
def audio_mime_type_from_format(fmt: str) -> str:
37+
"""Map an OpenAI audio ``format`` (e.g. "wav", "mp3") to a MIME type."""
38+
fmt = (fmt or "").strip().lower()
39+
return _OPENAI_AUDIO_MIME_TYPES.get(fmt, "audio/{}".format(fmt) if fmt else "audio/wav")
40+
41+
42+
# Raw audio formats the UI cannot render as a player. For these we keep the transcript as the
43+
# message content and skip the inline audio_part (raw bytes would only bloat the payload).
44+
_NON_RENDERABLE_AUDIO_MIME_TYPES = frozenset(
45+
{
46+
"audio/pcm",
47+
"audio/pcm16",
48+
"audio/l16",
49+
"audio/pcmu",
50+
"audio/pcma",
51+
"audio/g711_ulaw",
52+
"audio/g711_alaw",
53+
"audio/basic",
54+
}
55+
)
56+
57+
# Budget for inline audio measured on the *base64-encoded* size (what actually rides the span
58+
# event), so the guard reflects the real payload after encoding. Kept below the 5 MB per-span-event
59+
# limit with headroom for the rest of the event (oversize events have their whole I/O dropped
60+
# backend-side). 4 MiB encoded ≈ 3 MiB of raw audio.
61+
LLMOBS_AUDIO_INLINE_MAX_BYTES = 4 * 1024 * 1024
62+
63+
64+
def _base64_encoded_len(num_bytes: int) -> int:
65+
"""Length of ``num_bytes`` after standard base64 encoding (4 chars per 3 bytes, padded)."""
66+
return ((num_bytes + 2) // 3) * 4
67+
68+
69+
# OpenAI Realtime audio ``format`` values (legacy string form) that don't map to ``audio/<format>``.
70+
_REALTIME_AUDIO_FORMAT_MIME_TYPES = {
71+
"pcm16": "audio/pcm",
72+
"pcm": "audio/pcm",
73+
"g711_ulaw": "audio/pcmu",
74+
"g711_alaw": "audio/pcma",
75+
}
76+
77+
78+
def realtime_audio_format_to_mime(fmt: Any) -> str:
79+
"""Map an OpenAI Realtime audio format to a MIME type.
80+
81+
Handles both the legacy string form (e.g. "pcm16", "g711_ulaw") and the newer
82+
discriminated-union object whose ``type`` is already a MIME type (e.g. "audio/pcm").
83+
"""
84+
if fmt is None:
85+
return ""
86+
type_attr = _get_attr(fmt, "type", None)
87+
if type_attr:
88+
fmt = type_attr
89+
normalized = str(fmt).strip().lower()
90+
if not normalized:
91+
return ""
92+
if normalized.startswith("audio/"):
93+
return normalized
94+
return _REALTIME_AUDIO_FORMAT_MIME_TYPES.get(normalized, "audio/{}".format(normalized))
95+
96+
97+
def is_renderable_audio_mime(mime_type: str) -> bool:
98+
"""Whether a MIME type can be rendered as an audio player in the UI (raw PCM cannot)."""
99+
return bool(mime_type) and mime_type.strip().lower() not in _NON_RENDERABLE_AUDIO_MIME_TYPES
100+
101+
102+
def concat_base64_audio(b64_chunks: "list[str]") -> bytes:
103+
"""Decode and concatenate a list of base64 audio chunks into raw bytes.
104+
105+
Chunks must be decoded before concatenation: directly joining base64 strings is invalid
106+
unless each chunk is aligned on a 3-byte boundary.
107+
"""
108+
buf = bytearray()
109+
for chunk in b64_chunks:
110+
if not chunk:
111+
continue
112+
try:
113+
buf.extend(base64.b64decode(chunk))
114+
except (ValueError, TypeError):
115+
continue
116+
return bytes(buf)
117+
118+
119+
# Raw little-endian PCM16 mime types. These aren't renderable on their own, but can be losslessly
120+
# wrapped in a WAV container (just a header) to produce a playable ``audio/wav`` part.
121+
_PCM16_MIME_TYPES = frozenset({"audio/pcm", "audio/pcm16", "audio/l16"})
122+
123+
124+
def is_pcm16_audio_mime(mime_type: str) -> bool:
125+
return bool(mime_type) and mime_type.strip().lower() in _PCM16_MIME_TYPES
126+
127+
128+
def pcm16_to_wav(pcm_bytes: bytes, sample_rate: int = 24000, channels: int = 1) -> bytes:
129+
"""Wrap raw little-endian PCM16 audio in a WAV container.
130+
131+
This is lossless and cheap (it only prepends a header), and turns raw PCM — which the UI can't
132+
render — into a playable ``audio/wav`` payload.
133+
"""
134+
buf = io.BytesIO()
135+
with wave.open(buf, "wb") as wav_file:
136+
wav_file.setnchannels(channels)
137+
wav_file.setsampwidth(2) # PCM16 = 2 bytes/sample
138+
wav_file.setframerate(sample_rate)
139+
wav_file.writeframes(pcm_bytes)
140+
return buf.getvalue()
141+
142+
143+
# G.711 telephony audio (8kHz, 8-bit companded). Realtime uses these for phone-call integrations
144+
# (Twilio/SIP). We decode to PCM16 so it can be WAV-wrapped and played in the UI; the stdlib `wave`
145+
# module can't emit G.711-tagged WAV, and `audioop` (ulaw2lin/alaw2lin) was removed in Python 3.13.
146+
_G711_MIME_TO_VARIANT = {
147+
"audio/pcmu": "ulaw",
148+
"audio/g711_ulaw": "ulaw",
149+
"audio/pcma": "alaw",
150+
"audio/g711_alaw": "alaw",
151+
}
152+
G711_SAMPLE_RATE = 8000 # G.711 is always 8kHz mono.
153+
154+
155+
def g711_variant(mime_type: str) -> Optional[str]:
156+
"""Return "ulaw"/"alaw" for a G.711 MIME type, else ``None``."""
157+
return _G711_MIME_TO_VARIANT.get((mime_type or "").strip().lower())
158+
159+
160+
def _ulaw_decode_sample(byte: int) -> int:
161+
"""Decode one G.711 μ-law byte to a signed 16-bit linear PCM sample (CCITT G.711)."""
162+
byte = ~byte & 0xFF
163+
sample = ((byte & 0x0F) << 3) + 0x84
164+
sample <<= (byte & 0x70) >> 4
165+
return (0x84 - sample) if (byte & 0x80) else (sample - 0x84)
166+
167+
168+
def _alaw_decode_sample(byte: int) -> int:
169+
"""Decode one G.711 A-law byte to a signed 16-bit linear PCM sample (CCITT G.711)."""
170+
byte ^= 0x55
171+
sample = (byte & 0x0F) << 4
172+
seg = (byte & 0x70) >> 4
173+
if seg == 0:
174+
sample += 8
175+
elif seg == 1:
176+
sample += 0x108
177+
else:
178+
sample = (sample + 0x108) << (seg - 1)
179+
return sample if (byte & 0x80) else -sample
180+
181+
182+
# Precompute 256-entry decode tables (the input domain is a single byte).
183+
_ULAW_TABLE = [_ulaw_decode_sample(i) for i in range(256)]
184+
_ALAW_TABLE = [_alaw_decode_sample(i) for i in range(256)]
185+
186+
187+
def g711_to_pcm16(data: bytes, variant: str) -> bytes:
188+
"""Decode G.711 ("ulaw"/"alaw") bytes to raw little-endian PCM16."""
189+
table = _ALAW_TABLE if variant == "alaw" else _ULAW_TABLE
190+
return b"".join(struct.pack("<h", table[b]) for b in data)
191+
192+
193+
def format_audio_part_with_guard(
194+
audio_bytes: bytes, mime_type: str, max_bytes: int = LLMOBS_AUDIO_INLINE_MAX_BYTES
195+
) -> Optional[AudioPart]:
196+
"""Build a playable ``AudioPart`` only for renderable formats within the size budget.
197+
198+
Returns ``None`` for non-renderable formats (e.g. raw PCM) or oversize audio; callers should
199+
fall back to the transcript as the message content in that case.
200+
"""
201+
if not audio_bytes or not is_renderable_audio_mime(mime_type):
202+
return None
203+
# Compare the *encoded* size: format_audio_part base64-encodes the bytes (~4/3 expansion), and
204+
# that encoded content is what counts against the per-span-event limit.
205+
encoded_len = _base64_encoded_len(len(audio_bytes))
206+
if encoded_len > max_bytes:
207+
logger.debug(
208+
"Audio (%d encoded bytes) exceeds inline budget %d; omitting inline audio content", encoded_len, max_bytes
209+
)
210+
return None
211+
return format_audio_part(audio_bytes, mime_type)

ddtrace/llmobs/_integrations/base.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@ def trace(self, operation_id: str, submit_to_llmobs: bool = False, **kwargs) ->
7676
child_of=parent_context,
7777
resource=operation_id,
7878
span_type=span_type,
79-
activate=True,
79+
activate=kwargs.get("activate", True),
8080
)
8181
service = int_service(None, self.integration_config) or ""
8282
set_service_and_source(span, service, self.integration_config)

ddtrace/llmobs/_integrations/openai.py

Lines changed: 48 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ def trace(self, operation_id: str, submit_to_llmobs: bool = False, **kwargs: dic
5151
"createResponse",
5252
"parseChatCompletion",
5353
"parseResponse",
54+
"createRealtimeResponse",
5455
)
5556
if operation_id in traced_operations:
5657
submit_to_llmobs = True
@@ -67,6 +68,15 @@ def _set_base_span_tags(self, span: Span, **kwargs) -> None:
6768
client = "Deepseek"
6869
span._set_attribute("openai.request.provider", client)
6970

71+
def _get_model_provider(self, span) -> str:
72+
if self._is_provider(span, "azure"):
73+
return "azure_openai"
74+
elif self._is_provider(span, "openai"):
75+
return "openai"
76+
elif self._is_provider(span, "deepseek"):
77+
return "deepseek"
78+
return UNKNOWN_MODEL_PROVIDER
79+
7080
def _is_provider(self, span, provider):
7181
"""Check if the traced operation is from the given provider."""
7282
base_url = None
@@ -97,13 +107,7 @@ def _llmobs_set_tags(
97107
)
98108
model_name = span.get_tag("openai.response.model") or span.get_tag("openai.request.model") or "unknown_model"
99109

100-
model_provider = UNKNOWN_MODEL_PROVIDER
101-
if self._is_provider(span, "azure"):
102-
model_provider = "azure_openai"
103-
elif self._is_provider(span, "openai"):
104-
model_provider = "openai"
105-
elif self._is_provider(span, "deepseek"):
106-
model_provider = "deepseek"
110+
model_provider = self._get_model_provider(span)
107111

108112
metrics = self._extract_llmobs_metrics_tags(span, response, span_kind, kwargs)
109113
provider = span.get_tag("openai.request.provider") or "OpenAI"
@@ -171,6 +175,42 @@ def _llmobs_set_tags_from_tool(span: Span, kwargs: dict[str, Any], response: Any
171175
metadata={"tool_id": tool_id},
172176
)
173177

178+
def _llmobs_set_tags_from_realtime_response(
179+
self,
180+
span: Span,
181+
model_name: Optional[str],
182+
input_messages: list[Any],
183+
output_messages: list[Any],
184+
metadata: Optional[dict[str, Any]],
185+
metrics: Optional[dict[str, Any]],
186+
session_id: Optional[str] = None,
187+
) -> None:
188+
"""Tag a per-turn Realtime span (llm kind) built by the realtime state machine.
189+
190+
Each turn is its own trace; ``session_id`` groups all turns of one connection into a single
191+
conversation in the UI (there is no parent session span).
192+
"""
193+
provider = span.get_tag("openai.request.provider") or "OpenAI"
194+
model_provider = self._get_model_provider(span)
195+
_annotate_llmobs_span_data(
196+
span,
197+
name="{}.{}".format(provider, span.resource) if span.resource else None,
198+
kind="llm",
199+
model_name=model_name or "unknown_model",
200+
model_provider=model_provider,
201+
input_messages=input_messages or None,
202+
output_messages=output_messages or None,
203+
metadata=metadata or {},
204+
metrics=metrics or None,
205+
session_id=session_id,
206+
)
207+
# Mirror the base llmobs_set_tags path: also stamp the LLMObs->APM shadow token metrics so
208+
# realtime spans are consistent with every other OpenAI span for APM-only users.
209+
try:
210+
self._apply_shadow_metrics(span, metrics, "llm", model_name=model_name, model_provider=model_provider)
211+
except Exception:
212+
log.debug("Error applying shadow metrics for realtime span %s", span, exc_info=True)
213+
174214
def _set_apm_shadow_tags(self, span, args, kwargs, response=None, operation=""):
175215
span_kind = (
176216
"workflow"
@@ -181,13 +221,7 @@ def _set_apm_shadow_tags(self, span, args, kwargs, response=None, operation=""):
181221
)
182222
metrics = self._extract_llmobs_metrics_tags(span, response, span_kind, kwargs)
183223
model_name = span.get_tag("openai.response.model") or span.get_tag("openai.request.model")
184-
model_provider = UNKNOWN_MODEL_PROVIDER
185-
if self._is_provider(span, "azure"):
186-
model_provider = "azure_openai"
187-
elif self._is_provider(span, "openai"):
188-
model_provider = "openai"
189-
elif self._is_provider(span, "deepseek"):
190-
model_provider = "deepseek"
224+
model_provider = self._get_model_provider(span)
191225
self._apply_shadow_metrics(
192226
span,
193227
metrics,

0 commit comments

Comments
 (0)