Skip to content

Commit 58823cc

Browse files
authored
feat(tracing): Gate create_span gen_ai attributes via data_collection (#7295)
Route input/output attribute collection for AI_AGENT, AI_CHAT, and AI_TOOL span templates through the `data_collection.gen_ai` option when enabled, falling back to `send_default_pii` otherwise. Prompts recorded via `prompt`/`system_prompt` kwargs stay ungated when `data_collection` is not configured, preserving pre-existing behavior. Fixes PY-2749 Fixes #7293
1 parent c2d2956 commit 58823cc

2 files changed

Lines changed: 159 additions & 50 deletions

File tree

sentry_sdk/tracing_utils.py

Lines changed: 59 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,16 @@
4343

4444
if TYPE_CHECKING:
4545
from types import FrameType
46-
from typing import Any, Dict, Generator, Iterator, Optional, Tuple, Union
46+
from typing import (
47+
Any,
48+
Dict,
49+
Generator,
50+
Iterator,
51+
Literal,
52+
Optional,
53+
Tuple,
54+
Union,
55+
)
4756

4857
from sentry_sdk._types import Attributes
4958
from sentry_sdk.utils import ParsedUrl
@@ -1080,7 +1089,6 @@ def create_span_decorator(
10801089
use cases.
10811090
:type template: :py:class:`sentry_sdk.consts.SPANTEMPLATE`
10821091
"""
1083-
from sentry_sdk.scope import should_send_default_pii
10841092

10851093
def span_decorator(f: "Any") -> "Any":
10861094
"""
@@ -1110,20 +1118,21 @@ async def async_wrapper(*args: "Any", **kwargs: "Any") -> "Any":
11101118
span_op = op or _get_span_op(template)
11111119
function_name = name or qualname_from_function(f) or ""
11121120
span_name = _get_span_name(template, function_name, kwargs)
1113-
send_pii = should_send_default_pii()
1121+
collect_inputs = _should_collect_gen_ai("inputs")
1122+
collect_outputs = _should_collect_gen_ai("outputs")
11141123

11151124
with current_span.start_child(
11161125
op=span_op,
11171126
name=span_name,
11181127
) as span:
11191128
span.update_data(attributes or {})
11201129
_set_input_attributes(
1121-
span, template, send_pii, function_name, f, args, kwargs
1130+
span, template, collect_inputs, function_name, f, args, kwargs
11221131
)
11231132

11241133
result = await f(*args, **kwargs)
11251134

1126-
_set_output_attributes(span, template, send_pii, result)
1135+
_set_output_attributes(span, template, collect_outputs, result)
11271136

11281137
return result
11291138

@@ -1155,20 +1164,21 @@ def sync_wrapper(*args: "Any", **kwargs: "Any") -> "Any":
11551164
span_op = op or _get_span_op(template)
11561165
function_name = name or qualname_from_function(f) or ""
11571166
span_name = _get_span_name(template, function_name, kwargs)
1158-
send_pii = should_send_default_pii()
1167+
collect_inputs = _should_collect_gen_ai("inputs")
1168+
collect_outputs = _should_collect_gen_ai("outputs")
11591169

11601170
with current_span.start_child(
11611171
op=span_op,
11621172
name=span_name,
11631173
) as span:
11641174
span.update_data(attributes or {})
11651175
_set_input_attributes(
1166-
span, template, send_pii, function_name, f, args, kwargs
1176+
span, template, collect_inputs, function_name, f, args, kwargs
11671177
)
11681178

11691179
result = f(*args, **kwargs)
11701180

1171-
_set_output_attributes(span, template, send_pii, result)
1181+
_set_output_attributes(span, template, collect_outputs, result)
11721182

11731183
return result
11741184

@@ -1373,9 +1383,22 @@ def _get_span_op(template: "Union[str, SPANTEMPLATE]") -> str:
13731383
return str(op)
13741384

13751385

1386+
_AI_TEMPLATES = frozenset(
1387+
{SPANTEMPLATE.AI_AGENT, SPANTEMPLATE.AI_CHAT, SPANTEMPLATE.AI_TOOL}
1388+
)
1389+
1390+
1391+
def _should_collect_gen_ai(kind: 'Literal["inputs", "outputs"]') -> bool:
1392+
client = sentry_sdk.get_client()
1393+
if has_data_collection_enabled(client.options):
1394+
return bool(client.options["data_collection"]["gen_ai"][kind])
1395+
1396+
return client.should_send_default_pii()
1397+
1398+
13761399
def _get_input_attributes(
13771400
template: "Union[str, SPANTEMPLATE]",
1378-
send_pii: bool,
1401+
collect_inputs: bool,
13791402
args: "tuple[Any, ...]",
13801403
kwargs: "dict[str, Any]",
13811404
) -> "dict[str, Any]":
@@ -1384,7 +1407,7 @@ def _get_input_attributes(
13841407
"""
13851408
attributes: "dict[str, Any]" = {}
13861409

1387-
if template in [SPANTEMPLATE.AI_AGENT, SPANTEMPLATE.AI_TOOL, SPANTEMPLATE.AI_CHAT]:
1410+
if template in _AI_TEMPLATES:
13881411
mapping = {
13891412
"model": (SPANDATA.GEN_AI_REQUEST_MODEL, str),
13901413
"model_name": (SPANDATA.GEN_AI_REQUEST_MODEL, str),
@@ -1404,22 +1427,25 @@ def _set_from_key(key: str, value: "Any") -> None:
14041427
if value is not None and isinstance(value, data_type):
14051428
attributes[attribute] = value
14061429

1407-
for key, value in list(kwargs.items()):
1408-
if key == "prompt" and isinstance(value, str):
1409-
attributes.setdefault(SPANDATA.GEN_AI_REQUEST_MESSAGES, []).append(
1410-
{"role": "user", "content": value}
1411-
)
1412-
continue
1430+
# Pre-data collection, prompts were always recorded here, so they stay
1431+
# ungated until `send_default_pii` is removed.
1432+
collect_messages = True
1433+
if has_data_collection_enabled(sentry_sdk.get_client().options):
1434+
collect_messages = collect_inputs
14131435

1414-
if key == "system_prompt" and isinstance(value, str):
1415-
attributes.setdefault(SPANDATA.GEN_AI_REQUEST_MESSAGES, []).append(
1416-
{"role": "system", "content": value}
1417-
)
1436+
roles = {"prompt": "user", "system_prompt": "system"}
1437+
1438+
for key, value in list(kwargs.items()):
1439+
if key in roles:
1440+
if collect_messages and isinstance(value, str):
1441+
attributes.setdefault(SPANDATA.GEN_AI_REQUEST_MESSAGES, []).append(
1442+
{"role": roles[key], "content": value}
1443+
)
14181444
continue
14191445

14201446
_set_from_key(key, value)
14211447

1422-
if template == SPANTEMPLATE.AI_TOOL and send_pii:
1448+
if template == SPANTEMPLATE.AI_TOOL and collect_inputs:
14231449
attributes[SPANDATA.GEN_AI_TOOL_INPUT] = safe_repr(
14241450
{"args": args, "kwargs": kwargs}
14251451
)
@@ -1462,14 +1488,14 @@ def _set_from_keys(attribute: str, keys: "tuple[str, ...]") -> None:
14621488

14631489

14641490
def _get_output_attributes(
1465-
template: "Union[str, SPANTEMPLATE]", send_pii: bool, result: "Any"
1491+
template: "Union[str, SPANTEMPLATE]", collect_outputs: bool, result: "Any"
14661492
) -> "dict[str, Any]":
14671493
"""
14681494
Get output attributes for the given span template.
14691495
"""
14701496
attributes: "dict[str, Any]" = {}
14711497

1472-
if template in [SPANTEMPLATE.AI_AGENT, SPANTEMPLATE.AI_TOOL, SPANTEMPLATE.AI_CHAT]:
1498+
if template in _AI_TEMPLATES:
14731499
with capture_internal_exceptions():
14741500
# Usage from result, result.usage, and result.metadata.usage
14751501
usage_candidates = [result]
@@ -1495,7 +1521,7 @@ def _get_output_attributes(
14951521
attributes[SPANDATA.GEN_AI_RESPONSE_MODEL] = model_name
14961522

14971523
# Tool output
1498-
if template == SPANTEMPLATE.AI_TOOL and send_pii:
1524+
if template == SPANTEMPLATE.AI_TOOL and collect_outputs:
14991525
attributes[SPANDATA.GEN_AI_TOOL_OUTPUT] = safe_repr(result)
15001526

15011527
return attributes
@@ -1504,7 +1530,7 @@ def _get_output_attributes(
15041530
def _set_input_attributes(
15051531
span: "Span",
15061532
template: "Union[str, SPANTEMPLATE]",
1507-
send_pii: bool,
1533+
collect_inputs: bool,
15081534
name: str,
15091535
f: "Any",
15101536
args: "tuple[Any, ...]",
@@ -1515,7 +1541,7 @@ def _set_input_attributes(
15151541
15161542
:param span: The span to set attributes on.
15171543
:param template: The template to use to set attributes on the span.
1518-
:param send_pii: Whether to send PII data.
1544+
:param collect_inputs: Whether gen_ai inputs may be collected.
15191545
:param f: The wrapped function.
15201546
:param args: The arguments to the wrapped function.
15211547
:param kwargs: The keyword arguments to the wrapped function.
@@ -1541,22 +1567,25 @@ def _set_input_attributes(
15411567
if docstring is not None:
15421568
attributes[SPANDATA.GEN_AI_TOOL_DESCRIPTION] = docstring
15431569

1544-
attributes.update(_get_input_attributes(template, send_pii, args, kwargs))
1570+
attributes.update(_get_input_attributes(template, collect_inputs, args, kwargs))
15451571
span.update_data(attributes or {})
15461572

15471573

15481574
def _set_output_attributes(
1549-
span: "Span", template: "Union[str, SPANTEMPLATE]", send_pii: bool, result: "Any"
1575+
span: "Span",
1576+
template: "Union[str, SPANTEMPLATE]",
1577+
collect_outputs: bool,
1578+
result: "Any",
15501579
) -> None:
15511580
"""
15521581
Set span output attributes based on the given span template.
15531582
15541583
:param span: The span to set attributes on.
15551584
:param template: The template to use to set attributes on the span.
1556-
:param send_pii: Whether to send PII data.
1585+
:param collect_outputs: Whether gen_ai outputs may be collected.
15571586
:param result: The result of the wrapped function.
15581587
"""
1559-
span.update_data(_get_output_attributes(template, send_pii, result) or {})
1588+
span.update_data(_get_output_attributes(template, collect_outputs, result) or {})
15601589

15611590

15621591
def _should_continue_trace(baggage: "Optional[Baggage]") -> bool:

tests/tracing/test_decorator.py

Lines changed: 100 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -814,32 +814,112 @@ def my_agent(*args, **kwargs):
814814
with sentry_sdk.start_transaction(name="test-transaction"):
815815
my_agent(22, 33, arg1=44, arg2=55)
816816

817-
(_, tool_span, _) = (item.payload for item in items)
817+
(_, tool_span, chat_span) = (item.payload for item in items)
818+
tool_data = tool_span["attributes"]
819+
chat_data = chat_span["attributes"]
820+
else:
821+
events = capture_events()
818822

819-
if send_default_pii:
820-
assert (
821-
tool_span["attributes"]["gen_ai.tool.input"]
822-
== "{'args': (1, 2), 'kwargs': {'tool_arg1': '3', 'tool_arg2': '4'}}"
823-
)
824-
assert tool_span["attributes"]["gen_ai.tool.output"] == "'tool_output'"
825-
else:
826-
assert "gen_ai.tool.input" not in tool_span["attributes"]
827-
assert "gen_ai.tool.output" not in tool_span["attributes"]
823+
with sentry_sdk.start_transaction(name="test-transaction"):
824+
my_agent(22, 33, arg1=44, arg2=55)
825+
826+
(event,) = events
827+
(_, tool_span, chat_span) = event["spans"]
828+
tool_data = tool_span["data"]
829+
chat_data = chat_span["data"]
830+
831+
if send_default_pii:
832+
assert (
833+
tool_data["gen_ai.tool.input"]
834+
== "{'args': (1, 2), 'kwargs': {'tool_arg1': '3', 'tool_arg2': '4'}}"
835+
)
836+
assert tool_data["gen_ai.tool.output"] == "'tool_output'"
837+
else:
838+
assert "gen_ai.tool.input" not in tool_data
839+
assert "gen_ai.tool.output" not in tool_data
840+
841+
# Without `data_collection`, prompts are recorded regardless of `send_default_pii`.
842+
assert chat_data["gen_ai.request.messages"] == (
843+
"[{'role': 'user', 'content': 'What is the weather in Tokyo?'}, "
844+
"{'role': 'system', 'content': 'You are a helpful assistant that can answer "
845+
"questions about the weather.'}]"
846+
)
847+
848+
849+
@pytest.mark.parametrize("stream_gen_ai_spans", [True, False])
850+
@pytest.mark.parametrize("collect_outputs", [True, False])
851+
@pytest.mark.parametrize("collect_inputs", [True, False])
852+
def test_span_templates_ai_data_collection(
853+
sentry_init,
854+
capture_events,
855+
capture_items,
856+
collect_inputs,
857+
collect_outputs,
858+
stream_gen_ai_spans,
859+
):
860+
@sentry_sdk.trace(template=SPANTEMPLATE.AI_TOOL)
861+
def my_tool(arg1, arg2, **kwargs):
862+
"""This is a tool function."""
863+
return "tool_output"
864+
865+
@sentry_sdk.trace(template=SPANTEMPLATE.AI_CHAT)
866+
def my_chat(model=None, **kwargs):
867+
return "chat_output"
868+
869+
@sentry_sdk.trace(template=SPANTEMPLATE.AI_AGENT)
870+
def my_agent(*args, **kwargs):
871+
my_tool(1, 2, tool_arg1="3", tool_arg2="4")
872+
my_chat(
873+
model="my-gpt-4o-mini",
874+
prompt="What is the weather in Tokyo?",
875+
system_prompt="You are a helpful assistant.",
876+
)
877+
return "agent_output"
878+
879+
sentry_init(
880+
traces_sample_rate=1.0,
881+
stream_gen_ai_spans=stream_gen_ai_spans,
882+
_experiments={
883+
"data_collection": {
884+
"gen_ai": {"inputs": collect_inputs, "outputs": collect_outputs}
885+
}
886+
},
887+
)
888+
889+
if stream_gen_ai_spans:
890+
items = capture_items("span")
891+
892+
with sentry_sdk.start_transaction(name="test-transaction"):
893+
my_agent(22, 33, arg1=44, arg2=55)
894+
895+
(_, tool_span, chat_span) = (item.payload for item in items)
896+
tool_data = tool_span["attributes"]
897+
chat_data = chat_span["attributes"]
828898
else:
829899
events = capture_events()
830900

831901
with sentry_sdk.start_transaction(name="test-transaction"):
832902
my_agent(22, 33, arg1=44, arg2=55)
833903

834904
(event,) = events
835-
(_, tool_span, _) = event["spans"]
905+
(_, tool_span, chat_span) = event["spans"]
906+
tool_data = tool_span["data"]
907+
chat_data = chat_span["data"]
836908

837-
if send_default_pii:
838-
assert (
839-
tool_span["data"]["gen_ai.tool.input"]
840-
== "{'args': (1, 2), 'kwargs': {'tool_arg1': '3', 'tool_arg2': '4'}}"
841-
)
842-
assert tool_span["data"]["gen_ai.tool.output"] == "'tool_output'"
843-
else:
844-
assert "gen_ai.tool.input" not in tool_span["data"]
845-
assert "gen_ai.tool.output" not in tool_span["data"]
909+
if collect_inputs:
910+
assert (
911+
tool_data["gen_ai.tool.input"]
912+
== "{'args': (1, 2), 'kwargs': {'tool_arg1': '3', 'tool_arg2': '4'}}"
913+
)
914+
assert chat_data["gen_ai.request.messages"] == (
915+
"[{'role': 'user', 'content': 'What is the weather in Tokyo?'}, "
916+
"{'role': 'system', 'content': 'You are a helpful assistant.'}]"
917+
)
918+
else:
919+
assert "gen_ai.tool.input" not in tool_data
920+
assert "gen_ai.request.messages" not in chat_data
921+
922+
if collect_outputs:
923+
assert tool_data["gen_ai.tool.output"] == "'tool_output'"
924+
else:
925+
assert "gen_ai.tool.output" not in tool_data

0 commit comments

Comments
 (0)