Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 17 additions & 2 deletions ddtrace/contrib/internal/anthropic/patch.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
from ddtrace.internal._exceptions import DDBlockException
from ddtrace.internal.logger import get_logger
from ddtrace.internal.utils.version import parse_version
from ddtrace.llmobs._constants import AI_GUARD_BLOCKED
from ddtrace.llmobs._integrations import AnthropicIntegration


Expand Down Expand Up @@ -64,7 +65,14 @@ def traced_chat_model_generate(func: Callable[..., Any], instance: Any, args: An
return handle_streamed_response(integration, resp, args, kwargs, ctx)
try:
core.dispatch("anthropic.messages.create.after", (kwargs, resp), allow_raise=True)
except (DDBlockException, Exception):
except (DDBlockException, Exception) as e:
# An AI Guard block after the model call still has a valid response;
# attach it and flag the span so LLMObs records the model output
# even though the span is errored by the block (APPSEC-68147).
if isinstance(e, DDBlockException):
event.response = resp
if ctx.span is not None:
ctx.span._set_ctx_item(AI_GUARD_BLOCKED, True)
ctx.dispatch_ended_event(*sys.exc_info())
raise
event.response = resp

@Yun-Kim Yun-Kim Jun 12, 2026

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.

Can we just always set the event.response = resp before we do the AI Guard after core dispatch? This way we can decouple AI Guard errors from non-none responses.

Expand Down Expand Up @@ -98,7 +106,14 @@ async def traced_async_chat_model_generate(func: Callable[..., Any], instance: A
return handle_streamed_response(integration, resp, args, kwargs, ctx)
try:
core.dispatch("anthropic.messages.create.after", (kwargs, resp), allow_raise=True)
except (DDBlockException, Exception):
except (DDBlockException, Exception) as e:
# An AI Guard block after the model call still has a valid response;
# attach it and flag the span so LLMObs records the model output
# even though the span is errored by the block (APPSEC-68147).
if isinstance(e, DDBlockException):
event.response = resp
if ctx.span is not None:
ctx.span._set_ctx_item(AI_GUARD_BLOCKED, True)
ctx.dispatch_ended_event(*sys.exc_info())
raise
event.response = resp
Expand Down
6 changes: 6 additions & 0 deletions ddtrace/contrib/internal/openai/patch.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
from ddtrace.internal.logger import get_logger
from ddtrace.internal.utils.formats import deep_getattr
from ddtrace.internal.utils.version import parse_version
from ddtrace.llmobs._constants import AI_GUARD_BLOCKED
from ddtrace.llmobs._integrations import OpenAIIntegration
from ddtrace.trace import tracer

Expand Down Expand Up @@ -223,6 +224,11 @@ def _traced_endpoint(endpoint_hook, integration, instance, args, kwargs):
# Record any error information
if err is not None:
span.set_exc_info(*sys.exc_info())
# An AI Guard block fires after a successful model call, so the
# response is valid; flag the span so LLMObs still records the
# model output despite the span being errored (APPSEC-68147).
if isinstance(err, DDBlockException) and resp is not None:
span._set_ctx_item(AI_GUARD_BLOCKED, True)

# Pass the response and the error to the hook
try:
Expand Down
5 changes: 5 additions & 0 deletions ddtrace/llmobs/_constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,11 @@ class LLMObsSamplingDecision(str, Enum):

REQUEST_BASE_URL = "llmobs.request_base_url"

# Span ctx-item flag set when AI Guard blocks *after* a model call: the model
# already produced a response, so LLMObs must still record the output even
# though the span is errored by the block (APPSEC-68147).
AI_GUARD_BLOCKED = "_ml_obs.ai_guard_blocked"

# experiment span baggage keys to be propagated across boundaries
EXPERIMENT_ID_KEY = "_ml_obs.experiment_id"
EXPERIMENT_RUN_ID_KEY = "_ml_obs.experiment_run_id"
Expand Down
6 changes: 5 additions & 1 deletion ddtrace/llmobs/_integrations/anthropic.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from typing import Union

from ddtrace.internal.logger import get_logger
from ddtrace.llmobs._constants import AI_GUARD_BLOCKED
from ddtrace.llmobs._constants import CACHE_READ_INPUT_TOKENS_METRIC_KEY
from ddtrace.llmobs._constants import CACHE_WRITE_1H_INPUT_TOKENS_METRIC_KEY
from ddtrace.llmobs._constants import CACHE_WRITE_5M_INPUT_TOKENS_METRIC_KEY
Expand Down Expand Up @@ -68,7 +69,10 @@ def _llmobs_set_tags(
input_messages = self._extract_input_message(list(messages) if messages else [], system_prompt)

output_messages: list[Message] = [Message(content="")]
if not span.error and response is not None:
# Record output when a response exists. ``span.error`` normally
# suppresses output, but an AI Guard block after the model call errors
# the span while still having a valid response (APPSEC-68147).
if response is not None and (not span.error or span._get_ctx_item(AI_GUARD_BLOCKED)):

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.

we should just gate this on if response is not None, it should be independent of span.error or ai guard blocked being true

output_messages = self._extract_output_message(response)
span_kind = "workflow" if span._get_ctx_item(PROXY_REQUEST) else "llm"

Expand Down
9 changes: 7 additions & 2 deletions ddtrace/llmobs/_integrations/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
from ddtrace.internal import core
from ddtrace.internal.logger import get_logger
from ddtrace.internal.utils.formats import format_trace_id
from ddtrace.llmobs._constants import AI_GUARD_BLOCKED
from ddtrace.llmobs._constants import DISPATCH_ON_LLM_TOOL_CHOICE
from ddtrace.llmobs._constants import DISPATCH_ON_TOOL_CALL_OUTPUT_USED
from ddtrace.llmobs._constants import FILE_FALLBACK_MARKER
Expand Down Expand Up @@ -395,7 +396,9 @@ def openai_set_meta_tags_from_chat(
span, input_messages=input_messages, metadata=parameters, tool_definitions=tool_definitions
)

if span.error or not messages:
# ``span.error`` normally suppresses output, but an AI Guard block after the
# model call errors the span while a valid response exists (APPSEC-68147).
if (span.error and not span._get_ctx_item(AI_GUARD_BLOCKED)) or not messages:

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.

what's stopping us from just gating this as if not messages?

_annotate_llmobs_span_data(span, output_messages=[Message(content="")])
return

Expand Down Expand Up @@ -1023,7 +1026,9 @@ def openai_set_meta_tags_from_response(
prompt=validated_prompt,
)

if span.error or not response:
# ``span.error`` normally suppresses output, but an AI Guard block after the
# model call errors the span while a valid response exists (APPSEC-68147).
if (span.error and not span._get_ctx_item(AI_GUARD_BLOCKED)) or not response:
_annotate_llmobs_span_data(span, output_messages=[Message(content="")])
return

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
fixes:
- |
LLM Observability: This fix resolves an issue where the OpenAI and Anthropic
integrations did not record the model output on the LLM Observability span
when AI Guard blocked the request after the model call completed. The model
response is now captured even though the span is marked as errored by the
block.
61 changes: 61 additions & 0 deletions tests/appsec/ai_guard/anthropic/test_anthropic.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,23 @@

import json
import os
from types import SimpleNamespace
from unittest.mock import patch

import pytest

from ddtrace import config
import ddtrace.appsec._ai_guard as ai_guard_mod
from ddtrace.appsec._ai_guard._anthropic import _anthropic_messages_create_after
from ddtrace.appsec._ai_guard._anthropic import _anthropic_messages_create_before
from ddtrace.appsec._ai_guard._anthropic import _convert_anthropic_messages
from ddtrace.appsec._ai_guard._anthropic import _convert_anthropic_response
from ddtrace.appsec.ai_guard import AIGuardAbortError
from ddtrace.contrib.internal.anthropic.patch import ANTHROPIC_VERSION
from ddtrace.llmobs._constants import AI_GUARD_BLOCKED
from ddtrace.llmobs._integrations import AnthropicIntegration
from ddtrace.llmobs._utils import get_llmobs_output_messages
from ddtrace.trace import tracer
from tests.appsec.ai_guard.utils import mock_evaluate_response
from tests.appsec.ai_guard.utils import override_ai_guard_config

Expand Down Expand Up @@ -1606,3 +1612,58 @@ def test_response_text_and_image():
assert isinstance(result[0]["content"], list)
assert result[0]["content"][0]["type"] == "text"
assert result[0]["content"][1]["type"] == "image_url"


# ---------------------------------------------------------------------------
# APPSEC-68147: model output must still be recorded in LLMObs when AI Guard
# blocks AFTER the model call (the response exists; the block errors the span).
# ---------------------------------------------------------------------------


def _fake_anthropic_response(text="ok"):
"""Minimal Anthropic Message-shaped object for the output extractor."""
block = SimpleNamespace(type="text", text=text)
return SimpleNamespace(role="assistant", content=[block], usage=SimpleNamespace(input_tokens=1, output_tokens=1))


def _anthropic_output_contents(span):
return [m.get("content") for m in (get_llmobs_output_messages(span) or [])]


def test_output_recorded_when_ai_guard_blocked():
"""A blocked span (error=1) WITH the AI Guard marker still records output."""
integration = AnthropicIntegration(integration_config=config.anthropic)
integration._base_url = None # normally set during trace(); not needed for this unit test
kwargs = {"messages": _user_messages(), "model": CHAT_MODEL}
with tracer.trace("anthropic.request", span_type="llm") as span:
span.error = 1
span._set_ctx_item(AI_GUARD_BLOCKED, True)
integration._llmobs_set_tags(span, [], kwargs, _fake_anthropic_response("blocked body"), "")
assert _anthropic_output_contents(span) == ["blocked body"]


def test_output_suppressed_on_plain_error():
"""Without the marker, an errored span still blanks output (unchanged)."""
integration = AnthropicIntegration(integration_config=config.anthropic)
integration._base_url = None # normally set during trace(); not needed for this unit test
kwargs = {"messages": _user_messages(), "model": CHAT_MODEL}
with tracer.trace("anthropic.request", span_type="llm") as span:
span.error = 1
integration._llmobs_set_tags(span, [], kwargs, _fake_anthropic_response("should not appear"), "")
assert _anthropic_output_contents(span) == [""]


@patch("ddtrace.appsec.ai_guard._api_client.AIGuardClient._execute_request")
def test_after_block_flags_span_for_output(mock_execute_request, anthropic_client_mock, test_spans):
"""End-to-end: an after-model block (ALLOW then DENY) flags the anthropic span
with AI_GUARD_BLOCKED so LLMObs records the model output (APPSEC-68147).
"""
mock_execute_request.side_effect = [mock_evaluate_response("ALLOW"), mock_evaluate_response("DENY")]

with pytest.raises(AIGuardAbortError):
anthropic_client_mock.messages.create(messages=_user_messages(), **CHAT_PARAMS)

assert mock_execute_request.call_count == 2
llm_span = _find_anthropic_llm_span(test_spans)
assert llm_span.error == 1
assert llm_span._get_ctx_item(AI_GUARD_BLOCKED) is True
92 changes: 92 additions & 0 deletions tests/appsec/ai_guard/openai/test_openai.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import asyncio
import gc
import threading
from types import SimpleNamespace
from unittest.mock import patch
import warnings

Expand All @@ -16,6 +17,12 @@
from ddtrace.appsec._ai_guard._openai_chat import _convert_openai_response
from ddtrace.appsec._ai_guard._openai_chat import _openai_chat_completion_before
from ddtrace.appsec.ai_guard import AIGuardAbortError
from ddtrace.llmobs._constants import AI_GUARD_BLOCKED
from ddtrace.llmobs._integrations.utils import openai_set_meta_tags_from_chat
from ddtrace.llmobs._integrations.utils import openai_set_meta_tags_from_response
from ddtrace.llmobs._utils import _annotate_llmobs_span_data
from ddtrace.llmobs._utils import get_llmobs_output_messages
from ddtrace.trace import tracer
from tests.appsec.ai_guard.openai._span_helpers import assert_block_emits_both_spans
from tests.appsec.ai_guard.utils import mock_evaluate_response
from tests.appsec.ai_guard.utils import override_ai_guard_config
Expand Down Expand Up @@ -1504,3 +1511,88 @@ def test_chat_sync_before_block_records_request_model_on_llm_span(
assert llm_span.get_tag("openai.request.model") == CHAT_MODEL
assert llm_span.get_tag("openai.request.endpoint") == "/v1/chat/completions"
assert llm_span.get_tag("openai.request.method") == "POST"


# ---------------------------------------------------------------------------
# APPSEC-68147: model output must still be recorded in LLMObs when AI Guard
# blocks AFTER the model call (the response exists; the block errors the span).
# ---------------------------------------------------------------------------


def _fake_chat_response(content="ok"):
"""Minimal non-streamed ChatCompletion-shaped object for the chat extractor."""
message = SimpleNamespace(role="assistant", content=content, tool_calls=None, reasoning_content=None)
return SimpleNamespace(choices=[SimpleNamespace(message=message)], model=CHAT_MODEL)


def _fake_response_api_response(text="ok"):
"""Minimal Responses-API-shaped object for the response extractor."""
content = SimpleNamespace(type="output_text", text=text, refusal=None)
item = SimpleNamespace(type="message", role="assistant", content=[content])
return SimpleNamespace(output=[item], model=CHAT_MODEL)


def _output_contents(span):
return [m.get("content") for m in (get_llmobs_output_messages(span) or [])]


def _llm_span():
"""An LLM-kind span (kind is normally set by the integration at span start)."""
span = tracer.trace("openai.request", span_type="llm")
_annotate_llmobs_span_data(span, kind="llm")
return span


def test_chat_output_recorded_when_ai_guard_blocked():
"""A blocked span (error=1) WITH the AI Guard marker still records output."""
kwargs = {"messages": _user_messages(), "model": CHAT_MODEL}
with _llm_span() as span:
span.error = 1
span._set_ctx_item(AI_GUARD_BLOCKED, True)
openai_set_meta_tags_from_chat(span, kwargs, _fake_chat_response("blocked body"))
assert _output_contents(span) == ["blocked body"]


def test_chat_output_suppressed_on_plain_error():
"""Without the marker, an errored span still blanks output (unchanged)."""
kwargs = {"messages": _user_messages(), "model": CHAT_MODEL}
with _llm_span() as span:
span.error = 1
openai_set_meta_tags_from_chat(span, kwargs, _fake_chat_response("should not appear"))
assert _output_contents(span) == [""]


def test_response_output_recorded_when_ai_guard_blocked():
"""Responses API: blocked span with the marker still records output."""
kwargs = {"input": "hi", "model": CHAT_MODEL}
with _llm_span() as span:
span.error = 1
span._set_ctx_item(AI_GUARD_BLOCKED, True)
openai_set_meta_tags_from_response(span, kwargs, _fake_response_api_response("blocked body"), None)
assert _output_contents(span) == ["blocked body"]


def test_response_output_suppressed_on_plain_error():
kwargs = {"input": "hi", "model": CHAT_MODEL}
with _llm_span() as span:
span.error = 1
openai_set_meta_tags_from_response(span, kwargs, _fake_response_api_response("nope"), None)
assert _output_contents(span) == [""]


@patch("ddtrace.appsec.ai_guard._api_client.AIGuardClient._execute_request")
def test_chat_after_block_flags_span_for_output(mock_execute_request, openai_client_mock, test_spans):
"""End-to-end: an after-model block (ALLOW then DENY) flags the openai span
with AI_GUARD_BLOCKED so LLMObs records the model output (APPSEC-68147).
"""
import openai

mock_execute_request.side_effect = [mock_evaluate_response("ALLOW"), mock_evaluate_response("DENY")]

with pytest.raises(openai.UnprocessableEntityError):
openai_client_mock.chat.completions.create(messages=_user_messages(), **CHAT_PARAMS)

assert mock_execute_request.call_count == 2
llm_span = _find_openai_llm_span(test_spans)
assert llm_span.error == 1
assert llm_span._get_ctx_item(AI_GUARD_BLOCKED) is True
Loading