Skip to content

feat(llmobs): capture inline base64 images for OpenAI chat completions and responses - #19690

Open
joizddog wants to merge 1 commit into
mainfrom
jose/mlob-6408-openai-image-parts
Open

feat(llmobs): capture inline base64 images for OpenAI chat completions and responses#19690
joizddog wants to merge 1 commit into
mainfrom
jose/mlob-6408-openai-image-parts

Conversation

@joizddog

@joizddog joizddog commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

Description

The OpenAI integration now captures inline base64 image inputs as typed image_parts on LLM span
messages, for both Chat Completions and the Responses API, so image inputs render in LLM
Observability. Builds on the merged image_parts foundation (#18809) and emits the same wire shape
dd-trace-js accepts as of DataDog/dd-trace-js#9684.

  • Captures inline data:image/…;base64,… URLs into ImageParts ({mime_type, content}). Text
    and other content parts are untouched. The backend offloads the inline bytes to an attachment.
  • Bytes-only. Images given as a remote URL or a file_id are not fetched and keep their
    existing reference text.
  • Size guard. A single inline image whose base64 exceeds the budget is left as
    [image omitted: too large]. Without it, one oversized image pushes the event past the 5 MB limit
    and _writer._truncate_span_event blanks the span's entire input and output — losing the prompt
    text and the model response along with the image. The budget is derived from
    config._llmobs_event_size_limit, so a lowered DD_LLMOBS_EVENT_SIZE_BYTES cannot admit an image
    larger than the active event allowance.

Because the two capture sites are shared helpers, the OpenAI Agents SDK, LiteLLM, Azure OpenAI and
streaming inherit this with no integration-specific code.

Three base64-into-span-text leaks fixed

The two APIs failed differently, and neither was benign:

  1. Responses API message content concatenated the entire data URL into the message string.
  2. Reusable-prompt variables recorded a data URL verbatim.
  3. Responses output itemsimage_generation_call (raw base64 result) and
    computer_call_output (screenshot image_url) fell through to a generic str(item), which on
    the SDK's pydantic models renders every field value. That catch-all is now bounded, so a future
    output item carrying binary cannot leak the same way.

Each degrades to a marker, or to the remote reference where one exists.

Scoped to OpenAI; the Anthropic half is #19148.

Testing

Deterministic, no credentials:

scripts/run-tests --venv 7667b27 -- -- -q tests/llmobs          # 40 image tests
scripts/run-tests --venv ec404a0 -- -- -q tests/contrib/openai  # 247 passed, 13 skipped

Unit coverage for the guard (at cap, over cap, encoded sizing for bytes, string measured directly,
empty/no-mime), capture (single, multiple, bare-string URL, ;charset=, uppercase scheme,
svg+xml, line-wrapped payload), non-capture (remote URL, file_id, empty payload, whitespace-only
payload, non-base64 payload, non-image mime, missing URL), and both output-item leaks. Two
invariants are pinned explicitly: an unparseable data URL never reaches the caller's reference text,
and leading whitespace of any length cannot hide the scheme. Verified on openai 2.46.0 and 1.66.0.

Precise scope of the oversize E2E test: the openai conftest replaces the span writer with a mock, so
it does not exercise _truncate_span_event. It proves the payload never enters
meta.input.messages and that the text and response survive.

Not covered: a Responses-path VCR cassette; live Agents SDK / LiteLLM / Azure / streaming with
images — that inheritance is established by call graph, not by test.

Before / After

Verified live on staging (Org 2) via the AI Gateway — the same script and the same image run twice,
swapping only the ddtrace build. BEFORE is released ddtrace 4.13.0; AFTER carries
git.commit.sha of the commit under review.

1. Chat Completions — BEFORE

trace 6a7f6e53…0d2729ce

meta.input.messages[0].content = "What is shown in this image? One short sentence.\n[image]"
56 chars, image bytes discarded.

Screenshot 2026-08-14 at 4 48 54 PM

2. Chat Completions — AFTER

trace 6a7f6f65…7557fb2f

image_parts = [{attachment_key: "input_message_image_0_0", mime_type: "image/png"}], content
back to the 48-char question — the image renders inline in the span panel.

Screenshot 2026-08-14 at 4 50 06 PM

3. Responses API — BEFORE

Same trace as (1), sibling span. meta.input.messages[0].content = 15,902 chars — the question
followed by the entire base64 payload (…sentence.data:image/png;base64,iVBORw0KGgo…).

4. Responses API — AFTER

trace 6a7f6f65…7557fb2f

48 chars plus image_parts — a 331× reduction in recorded message text for the same request.

A remote-URL image was run as a control and keeps its reference text in both, confirming the change
is scoped to inline bytes.

Risks

Low / additive. Text-only and non-image content are unchanged; capture is bytes-only with no network
fetch.

One risk worth stating plainly: for Chat Completions this introduces a size-drop mode that did not
exist before
. An image was always [image], so no chat span could be dropped for image size. Now
several in-budget inline images can exceed the per-event limit and cost the span its whole input and
output.

Known limitations:

  • The guard is per-image. Several images that each fit — or an image alongside inline audio, which
    has its own independent budget — can still collectively exceed the event limit. Pinned by
    test_multiple_in_budget_images_can_still_exceed_the_event_size_limit.
  • Images inside Responses function_call_output tool results remain uncaptured.
  • Generated images are still invisible: images.generate/edit/create_variation are patched
    for APM but never call llmobs_set_tags, so no LLMObs span exists; image_generation_call now
    emits a marker rather than an image_part.
  • The 4 MiB budget is reasoned against the 5 MB per-event limit. In the default APM export modes the
    event also rides the shared 20 MB trace-writer buffer; that interaction is unmeasured.

Additional Notes

A DD_-prefixed kill-switch was considered and declined. Unlike the realtime audio case, which
introduced a whole streaming state machine, this is a few dozen lines inside already-patched code
paths; a permanent public config surface is not warranted. To suppress image bytes today, strip
image_parts in a LLMObs.register_processor hook. Straightforward to add if reviewers disagree.

Motivating datapoint: the AI Gateway's own LLMObs spans, on released ddtrace, currently carry the
full base64 payload of every image routed through it — this is not a synthetic concern.

MLOB-6408

@datadog-official

datadog-official Bot commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

Pipelines  Tests

Unblock PR with BitsAI

⚠️ Warnings

🚦 9 Pipeline jobs failed

DataDog/apm-reliability/dd-trace-py | build linux serverless: [amd64, cp315-cp315, v126532274-233089d-musllinux_1_2_x86_64, 1]   View in Datadog   GitLab

DataDog/apm-reliability/dd-trace-py | build linux serverless: [arm64, cp315-cp315, v113741357-d2b8243-manylinux2014_aarch64, 1]   View in Datadog   GitLab

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

View all 9 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: a528b61 | Docs | Datadog PR Page | Give us feedback!

@pr-commenter

pr-commenter Bot commented Aug 13, 2026

Copy link
Copy Markdown

Benchmarks

Benchmark execution time: 2026-08-13 23:08:12

Comparing candidate commit 1796508 in PR branch jose/mlob-6408-openai-image-parts with baseline commit 41afee5 in branch main.

Found 0 performance improvements and 8 performance regressions! Performance is the same for 612 metrics, 10 unstable metrics.

scenario:httppropagationextract-datadog_tracecontext_tracestate_not_propagated_on_trace_id_no_match

  • 🟥 execution_time [+5.416µs; +5.769µs] or [+8.088%; +8.614%]

scenario:httppropagationinject-ids_only

  • 🟥 execution_time [+2.960µs; +3.195µs] or [+14.140%; +15.266%]

scenario:iastaspects-lower_aspect

  • 🟥 execution_time [+23.879µs; +29.700µs] or [+10.099%; +12.561%]

scenario:iastaspects-modulo_noaspect

  • 🟥 execution_time [+27.860µs; +32.265µs] or [+8.681%; +10.054%]

scenario:iastaspectsospath-ospathbasename_aspect

  • 🟥 execution_time [+86.017µs; +93.636µs] or [+21.470%; +23.372%]

scenario:span-start

  • 🟥 execution_time [+1.265ms; +1.464ms] or [+7.890%; +9.132%]

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

  • 🟥 execution_time [+350.017ns; +387.241ns] or [+13.415%; +14.842%]

scenario:tracer-small

  • 🟥 execution_time [+26.980µs; +29.866µs] or [+7.701%; +8.524%]

@joizddog
joizddog force-pushed the jose/mlob-6408-openai-image-parts branch from 1796508 to c358c4f Compare August 13, 2026 23:28
@cit-pr-commenter-54b7da

Copy link
Copy Markdown

Codeowners resolved as

Resolved from the full PR diff against main using the target branch CODEOWNERS file.
CODEOWNERS team requests not listed below are not required by the current file set.

ddtrace/llmobs/_constants.py                                            @DataDog/ml-observability
ddtrace/llmobs/_integrations/utils.py                                   @DataDog/ml-observability
releasenotes/notes/llmobs-openai-image-parts-edf32dd7ad1d69db.yaml      @DataDog/apm-python
tests/contrib/openai/test_openai_llmobs.py                              @DataDog/ml-observability
tests/llmobs/test_integrations_utils.py                                 @DataDog/ml-observability

@cit-pr-commenter-54b7da

cit-pr-commenter-54b7da Bot commented Aug 13, 2026

Copy link
Copy Markdown

Circular import analysis

⚠️ Existing circular imports

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

ddtrace.contrib.internal.django.patch -> ddtrace.contrib.internal.django.response -> ddtrace.contrib.internal.django.patch
ddtrace.contrib.internal.pytorch._distributed -> ddtrace.contrib.internal.pytorch._rank_root -> ddtrace.contrib.internal.pytorch._distributed
ddtrace.llmobs -> ddtrace.llmobs._evaluators -> ddtrace.llmobs._evaluators.format -> ddtrace.llmobs._experiment -> ddtrace.llmobs
ddtrace.errortracking._handled_exceptions.bytecode_injector -> ddtrace.errortracking._handled_exceptions.callbacks -> ddtrace.errortracking._handled_exceptions.collector -> ddtrace.errortracking._handled_exceptions.bytecode_reporting -> ddtrace.errortracking._handled_exceptions.bytecode_injector
ddtrace.appsec._asm_request_context -> ddtrace.appsec._iast._iast_request_context_base -> ddtrace.appsec._iast._iast_env -> ddtrace.appsec._iast.reporter -> ddtrace.appsec._exploit_prevention.stack_traces -> ddtrace.appsec._asm_request_context

@cit-pr-commenter-54b7da

cit-pr-commenter-54b7da Bot commented Aug 13, 2026

Copy link
Copy Markdown

Dependency direction analysis

⚠️ Existing dependency direction violations

There are 254 dependency direction violations that already exist on the base branch and have not been changed by this PR.

Show existing violations (showing 5 of 254 highest severity)
ddtrace.internal.tracemethods -×-> ddtrace.trace  (internal-core -> product:tracing, score=134)
ddtrace.aiguard._api_client -×-> ddtrace.trace  (product:aiguard -> product:tracing, score=132)
ddtrace.internal.test_visibility.api -×-> ddtrace.trace  (product:ci_visibility -> product:tracing, score=132)
ddtrace.debugging._signal.model -×-> ddtrace.trace  (product:debugging -> product:tracing, score=132)
ddtrace.debugging._debugger -×-> ddtrace.trace  (product:debugging -> product:tracing, score=132)

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

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

@joizddog
joizddog force-pushed the jose/mlob-6408-openai-image-parts branch from c358c4f to 47c2a73 Compare August 14, 2026 19:40
@joizddog
joizddog marked this pull request as ready for review August 14, 2026 19:41
@joizddog
joizddog requested review from a team as code owners August 14, 2026 19:41
@joizddog
joizddog requested a review from duncanista August 14, 2026 19:41

@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: 47c2a7394b

ℹ️ 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 on lines +521 to +522
if image_parts:
processed_message["image_parts"] = image_parts

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Document image parts in the integration skill

Update the LLMObs integration skill alongside this new extraction pattern: .claude/skills/llmobs-integrations/SKILL.md currently demonstrates only AudioPart, while its references/implementation-guide.md message-extraction section documents only audio attachments. Leaving both references unchanged means future integration work will miss the new ImagePart capture and size-guard conventions.

AGENTS.md reference: AGENTS.md:L19-L19

Useful? React with 👍 / 👎.

@joizddog joizddog Aug 14, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Skill file update remains blocked by classifier restrictions

Comment thread tests/llmobs/test_integrations_utils.py Outdated


def test_extract_content_parts_captures_inline_image_bare_string():
"""The URL may arrive as a bare string rather than the nested ``image_url.url`` object."""

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Remove rST markup from private test docstrings

Convert the double-backtick literals in this and the other newly added test docstrings to plain text. Test docstrings are explicitly non-rendered documentation in this repository, so adding rST markup such as image_url.url violates the documented editor-facing prose convention.

AGENTS.md reference: AGENTS.md:L42-L50

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Removed 15 remaining rST backticks from docstrings

Comment thread ddtrace/llmobs/_integrations/utils.py Outdated
Comment on lines +436 to +438
def _is_data_url(value: Any) -> bool:
"""Whether value carries its payload inline as a data: URL rather than by reference."""
return isinstance(value, str) and value[:32].lstrip().lower().startswith("data:")

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Detect data URLs after arbitrary leading whitespace

When a Responses API image URL has 28 or more leading whitespace characters, slicing to 32 characters removes part of the data: scheme, so this predicate returns false. _capture_inline_image() then treats the value as a remote reference, and _extract_image_reference() returns the complete data URL into message text, reintroducing the multi-megabyte base64 leak this change is intended to prevent. Inspect the scheme after stripping whitespace rather than stripping only a fixed prefix.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed _is_data_url whitespace leak

Comment thread ddtrace/llmobs/_integrations/utils.py Outdated
Comment thread ddtrace/llmobs/_integrations/utils.py Outdated
Comment on lines +456 to +457
# Measure the payload's span before materializing it. Stripping wrapped-line whitespace only
# shrinks it, so a span past double the budget cannot fit however it was wrapped, and bailing

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Measure wrapped payloads before rejecting them

When a valid base64 image is heavily line-wrapped, its raw payload span can exceed twice the 4 MiB budget even though removing permitted whitespace leaves less than 4 MiB of encoded content. This shortcut returns the too-large marker before performing that normalization, contradicting the regex's stated support for wrapped base64 and dropping an image that actually fits the guard. Count non-whitespace payload characters before deciding that the image is oversized.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Raised pre-filter limit from 2x to 8x to prevent false rejections of wrapped base64 payloads.

@joizddog
joizddog force-pushed the jose/mlob-6408-openai-image-parts branch 2 times, most recently from 7f91360 to 71a2d6c Compare August 14, 2026 20:40
Inline `data:image/...;base64,...` content is captured as typed `image_parts`
on LLM span messages for both Chat Completions and the Responses API.

Three paths previously wrote a raw base64 payload into span text instead:

- the Responses API concatenated the whole data URL into the message content,
  which also dropped an image-only message because no text remained;
- a reusable-prompt variable holding a data URL recorded it verbatim;
- image-generation results and computer-use screenshots fell through to a
  `str(item)` fallback that renders every field of the SDK model, base64
  included.

Each now degrades to a marker, or to the remote reference where one exists.
The catch-all is bounded so a future output item carrying binary cannot leak.

The size guard mirrors the audio helper and shares its base64 length
calculation, and derives its budget from the configured event-size limit so a
lowered `DD_LLMOBS_EVENT_SIZE_BYTES` cannot admit an oversize image. An
oversize image is left as a marker so the surrounding text and the model
response survive. Remote URLs and file_ids are not fetched.

MLOB-6408
@joizddog
joizddog force-pushed the jose/mlob-6408-openai-image-parts branch from 71a2d6c to a528b61 Compare August 14, 2026 20: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