Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
78 commits
Select commit Hold shift + click to select a range
2be94ca
feat: Send GenAI spans as V2 envelope items
alexander-alderman-webb Apr 15, 2026
01f479a
.
alexander-alderman-webb Apr 15, 2026
80e6a10
.
alexander-alderman-webb Apr 15, 2026
0622cf4
.
alexander-alderman-webb Apr 15, 2026
7c75da1
.
alexander-alderman-webb Apr 15, 2026
54a9b07
update
alexander-alderman-webb Apr 15, 2026
d1aa07c
.
alexander-alderman-webb Apr 15, 2026
117a6c9
.
alexander-alderman-webb Apr 15, 2026
83c36b5
.
alexander-alderman-webb Apr 15, 2026
f71e0ce
openai tests
alexander-alderman-webb Apr 16, 2026
1fab632
anthropic tests
alexander-alderman-webb Apr 16, 2026
f44316d
google-genai tests
alexander-alderman-webb Apr 16, 2026
ff9c5ec
test litellm
alexander-alderman-webb Apr 17, 2026
b92ae36
test huggingface_hub
alexander-alderman-webb Apr 17, 2026
907ca1d
test langchain
alexander-alderman-webb Apr 17, 2026
b254297
test langgraph
alexander-alderman-webb Apr 17, 2026
6f7a054
accept any as sdk version
alexander-alderman-webb Apr 17, 2026
4f871a4
pydantic-ai tests
alexander-alderman-webb Apr 17, 2026
7befc7d
.
alexander-alderman-webb Apr 17, 2026
fb348bb
openai-agents tests
alexander-alderman-webb Apr 17, 2026
41e409d
fix openai-agents tests
alexander-alderman-webb Apr 17, 2026
8bf77f0
fix common tests
alexander-alderman-webb Apr 17, 2026
7c3da4f
client handle None
alexander-alderman-webb Apr 17, 2026
06c2a40
fix item_count
alexander-alderman-webb Apr 17, 2026
204b980
fix common tests
alexander-alderman-webb Apr 17, 2026
00733f9
fix common tests
alexander-alderman-webb Apr 17, 2026
a54cab4
common tests
alexander-alderman-webb Apr 17, 2026
4b0c47b
tests
alexander-alderman-webb Apr 17, 2026
6c5c812
add experimental v2 option
alexander-alderman-webb Apr 17, 2026
51a07ff
push experiment
alexander-alderman-webb Apr 17, 2026
bab7567
fix tests
alexander-alderman-webb Apr 17, 2026
3e55795
client changes
alexander-alderman-webb Apr 17, 2026
6d1d7ed
simplify client logic
alexander-alderman-webb Apr 17, 2026
6bf4006
Revert "add experimental v2 option"
alexander-alderman-webb Apr 17, 2026
700e8a1
retry adding experimental option to tests
alexander-alderman-webb Apr 17, 2026
9b20bd2
add experimental option to langgraph tests
alexander-alderman-webb Apr 17, 2026
88fc76e
cleanup
alexander-alderman-webb Apr 20, 2026
08af4b4
remove experimental option
alexander-alderman-webb Apr 20, 2026
7bd12ae
add constant again
alexander-alderman-webb Apr 20, 2026
ef843a0
add name fallback
alexander-alderman-webb Apr 20, 2026
4e3e2d0
remove remaining experimental option references
alexander-alderman-webb Apr 20, 2026
44b2c2d
update test with hardcoded version
alexander-alderman-webb Apr 21, 2026
240f123
merge master
alexander-alderman-webb May 5, 2026
137930c
merge master
alexander-alderman-webb May 11, 2026
307db73
merge fixes
alexander-alderman-webb May 11, 2026
efc37e1
adapt new test
alexander-alderman-webb May 11, 2026
bee6320
add parameter
alexander-alderman-webb May 12, 2026
ab47783
cleanup anthropic
alexander-alderman-webb May 12, 2026
75f4d3a
cleanup google-genai
alexander-alderman-webb May 12, 2026
8ba3d94
cleanup huggingface-hub
alexander-alderman-webb May 12, 2026
f156e92
cleanup langgraph
alexander-alderman-webb May 12, 2026
3b03ddf
cleanup litellm
alexander-alderman-webb May 12, 2026
261b9f0
cleanup openai
alexander-alderman-webb May 12, 2026
4f8a4c8
cleanup openai_agents
alexander-alderman-webb May 12, 2026
dcc3fbe
Merge branch 'master' into webb/gen-ai-v2
alexander-alderman-webb May 12, 2026
14e379f
fix pydantic-ai test
alexander-alderman-webb May 12, 2026
596db31
fix tracing tests
alexander-alderman-webb May 12, 2026
a2adf96
fix tests
alexander-alderman-webb May 12, 2026
449457b
do not leak new option and use event_opt
alexander-alderman-webb May 12, 2026
96f86e3
send version field in json
alexander-alderman-webb May 12, 2026
aba2cf1
fix op fallback
alexander-alderman-webb May 12, 2026
a48d701
fix logic
alexander-alderman-webb May 12, 2026
dcce855
simplify logic
alexander-alderman-webb May 12, 2026
43920b5
promote to top level option
alexander-alderman-webb May 12, 2026
9b4ad4b
add parameter
alexander-alderman-webb May 12, 2026
5889ad9
update tracing
alexander-alderman-webb May 12, 2026
cf04adb
update more tests
alexander-alderman-webb May 12, 2026
ec57859
test(langchain): Inline global state
alexander-alderman-webb May 13, 2026
854c9af
merge
alexander-alderman-webb May 13, 2026
7886629
add parameterization
alexander-alderman-webb May 13, 2026
e85dffe
remove None conversion
alexander-alderman-webb May 13, 2026
f8f98c1
update test with None attribute assertion
alexander-alderman-webb May 13, 2026
b46fd5f
mostly whitespace test cleanup
alexander-alderman-webb May 13, 2026
dde7bf4
restore type annotations in huggingface_hub tests
alexander-alderman-webb May 13, 2026
425ae27
cleanup one openai test
alexander-alderman-webb May 13, 2026
dbb0bd1
merge master
alexander-alderman-webb May 13, 2026
10b6d4b
fix pydantic-ai test
alexander-alderman-webb May 13, 2026
d46ae71
remove duplciate import
alexander-alderman-webb May 13, 2026
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
1 change: 1 addition & 0 deletions sentry_sdk/_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,7 @@ class SDKInfo(TypedDict):
"type": Literal["check_in", "transaction"],
"user": dict[str, object],
"_dropped_spans": int,
"_has_gen_ai_span": bool,
},
total=False,
)
Expand Down
249 changes: 243 additions & 6 deletions sentry_sdk/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,12 @@
import uuid
import random
import socket
from collections.abc import Mapping
from collections.abc import Mapping, Iterable
from datetime import datetime, timezone
from importlib import import_module
from typing import TYPE_CHECKING, List, Dict, cast, overload
import warnings
import json

from sentry_sdk._compat import check_uwsgi_thread_support
from sentry_sdk._metrics_batcher import MetricsBatcher
Expand All @@ -30,6 +31,7 @@
)
from sentry_sdk.serializer import serialize
from sentry_sdk.tracing import trace
from sentry_sdk.traces import SpanStatus
from sentry_sdk.tracing_utils import has_span_streaming_enabled
from sentry_sdk.transport import (
HttpTransportCore,
Expand All @@ -38,6 +40,7 @@
)
from sentry_sdk.consts import (
SPANDATA,
SPANSTATUS,
DEFAULT_MAX_VALUE_LENGTH,
DEFAULT_OPTIONS,
INSTRUMENTER,
Expand All @@ -47,7 +50,7 @@
from sentry_sdk.integrations import _DEFAULT_INTEGRATIONS, setup_integrations
from sentry_sdk.integrations.dedupe import DedupeIntegration
from sentry_sdk.sessions import SessionFlusher
from sentry_sdk.envelope import Envelope
from sentry_sdk.envelope import Envelope, Item, PayloadRef
from sentry_sdk.profiler.continuous_profiler import setup_continuous_profiler
from sentry_sdk.profiler.transaction_profiler import (
has_profiling_enabled,
Expand All @@ -56,6 +59,7 @@
)
from sentry_sdk.scrubber import EventScrubber
from sentry_sdk.monitor import Monitor
from sentry_sdk.utils import datetime_from_isoformat
Comment thread
cursor[bot] marked this conversation as resolved.

if TYPE_CHECKING:
from typing import Any
Expand All @@ -66,7 +70,15 @@
from typing import Union
from typing import TypeVar

from sentry_sdk._types import Event, Hint, SDKInfo, Log, Metric, EventDataCategory
from sentry_sdk._types import (
Event,
Hint,
SDKInfo,
Log,
Metric,
EventDataCategory,
SerializedAttributeValue,
)
from sentry_sdk.integrations import Integration
from sentry_sdk.scope import Scope
from sentry_sdk.session import Session
Expand All @@ -89,6 +101,196 @@
}


def _serialized_v1_attribute_to_serialized_v2_attribute(
attribute_value: "Any",
) -> "Optional[SerializedAttributeValue]":
if isinstance(attribute_value, bool):
return {
"value": attribute_value,
"type": "boolean",
}

if isinstance(attribute_value, int):
return {
"value": attribute_value,
"type": "integer",
}

if isinstance(attribute_value, float):
return {
"value": attribute_value,
"type": "double",
}

if isinstance(attribute_value, str):
return {
"value": attribute_value,
"type": "string",
}

if isinstance(attribute_value, list):
if not attribute_value:
return {"value": [], "type": "array"}

ty = type(attribute_value[0])
if ty in (int, str, bool, float) and all(
type(v) is ty for v in attribute_value
):
return {
"value": attribute_value,
"type": "array",
}

# Types returned when the serializer for V1 span attributes recurses into some container types.
if isinstance(attribute_value, (dict, list)):
return {
"value": json.dumps(attribute_value),
"type": "string",
}

return None


def _serialized_v1_span_to_serialized_v2_span(
span: "dict[str, Any]", event: "Event"
) -> "dict[str, Any]":
# See SpanBatcher._to_transport_format() for analogous population of all entries except "attributes".
res: "dict[str, Any]" = {
"status": SpanStatus.OK.value,
"is_segment": False,
}

if "trace_id" in span:
res["trace_id"] = span["trace_id"]

if "span_id" in span:
res["span_id"] = span["span_id"]

if "description" in span:
description = span["description"]

if description is None and "op" in span:
description = span["op"]

res["name"] = description
Comment thread
sentry[bot] marked this conversation as resolved.

if "start_timestamp" in span:
start_timestamp = None
try:
start_timestamp = datetime_from_isoformat(span["start_timestamp"])
except Exception:
pass

if start_timestamp is not None:
res["start_timestamp"] = start_timestamp.timestamp()

if "timestamp" in span:
end_timestamp = None
try:
end_timestamp = datetime_from_isoformat(span["timestamp"])
except Exception:
pass

if end_timestamp is not None:
res["end_timestamp"] = end_timestamp.timestamp()

if "parent_span_id" in span:
res["parent_span_id"] = span["parent_span_id"]

if "status" in span and span["status"] != SPANSTATUS.OK:
res["status"] = "error"

attributes: "Dict[str, Any]" = {}

if "op" in span:
attributes["sentry.op"] = span["op"]
if "origin" in span:
attributes["sentry.origin"] = span["origin"]

span_data = span.get("data")
if isinstance(span_data, dict):
attributes.update(span_data)

span_tags = span.get("tags")
if isinstance(span_tags, dict):
attributes.update(span_tags)

# See Scope._apply_user_attributes_to_telemetry() for user attributes.
user = event.get("user")
if isinstance(user, dict):
if "id" in user:
attributes["user.id"] = user["id"]
if "username" in user:
attributes["user.name"] = user["username"]
if "email" in user:
attributes["user.email"] = user["email"]

# See Scope.set_global_attributes() for release, environment, and SDK metadata.
if "release" in event:
attributes["sentry.release"] = event["release"]
if "environment" in event:
attributes["sentry.environment"] = event["environment"]
if "transaction" in event:
attributes["sentry.segment.name"] = event["transaction"]

trace_context = event.get("contexts", {}).get("trace", {})
if "span_id" in trace_context:
attributes["sentry.segment.id"] = trace_context["span_id"]

sdk_info = event.get("sdk")
if isinstance(sdk_info, dict):
if "name" in sdk_info:
attributes["sentry.sdk.name"] = sdk_info["name"]
if "version" in sdk_info:
attributes["sentry.sdk.version"] = sdk_info["version"]

if not attributes:
return res

res["attributes"] = {}
for key, value in attributes.items():
converted_value = _serialized_v1_attribute_to_serialized_v2_attribute(value)
if converted_value is None:
continue

res["attributes"][key] = converted_value

# Remove redundant attribute, as status is stored in the status field.
if "status" in res["attributes"]:
del res["attributes"]["status"]

return res


def _split_gen_ai_spans(
event_opt: "Event",
) -> "Optional[tuple[List[Dict[str, object]], List[Dict[str, object]]]]":
if "spans" not in event_opt:
return None

spans: "Any" = event_opt["spans"]
if isinstance(spans, AnnotatedValue):
spans = spans.value

if not isinstance(spans, Iterable):
return None

non_gen_ai_spans = []
gen_ai_spans = []
for span in spans:
if not isinstance(span, dict):
non_gen_ai_spans.append(span)
continue

span_op = span.get("op")
Comment thread
sentry-warden[bot] marked this conversation as resolved.
if isinstance(span_op, str) and span_op.startswith("gen_ai."):
gen_ai_spans.append(span)
else:
non_gen_ai_spans.append(span)

return non_gen_ai_spans, gen_ai_spans


def _get_options(*args: "Optional[str]", **kwargs: "Any") -> "Dict[str, Any]":
if args and (isinstance(args[0], (bytes, str)) or args[0] is None):
dsn: "Optional[str]" = args[0]
Comment thread
alexander-alderman-webb marked this conversation as resolved.
Comment thread
alexander-alderman-webb marked this conversation as resolved.
Expand Down Expand Up @@ -874,6 +1076,8 @@ def capture_event(
event_id = event.get("event_id")
if event_id is None:
event["event_id"] = event_id = uuid.uuid4().hex

span_recorder_has_gen_ai_span = event.pop("_has_gen_ai_span", False)
event_opt = self._prepare_event(event, hint, scope)
if event_opt is None:
return None
Expand Down Expand Up @@ -909,10 +1113,43 @@ def capture_event(

envelope = Envelope(headers=headers)

if is_transaction:
if isinstance(profile, Profile):
envelope.add_profile(profile.to_json(event_opt, self.options))
if is_transaction and isinstance(profile, Profile):
envelope.add_profile(profile.to_json(event_opt, self.options))

if is_transaction and not span_recorder_has_gen_ai_span:
envelope.add_transaction(event_opt)
elif is_transaction:
split_spans = _split_gen_ai_spans(event_opt)
if split_spans is None or not split_spans[1]:
envelope.add_transaction(event_opt)
else:
non_gen_ai_spans, gen_ai_spans = split_spans

event_opt["spans"] = non_gen_ai_spans
Comment thread
alexander-alderman-webb marked this conversation as resolved.
Comment thread
alexander-alderman-webb marked this conversation as resolved.
Comment thread
alexander-alderman-webb marked this conversation as resolved.
envelope.add_transaction(event_opt)
Comment thread
alexander-alderman-webb marked this conversation as resolved.
Comment thread
alexander-alderman-webb marked this conversation as resolved.

converted_gen_ai_spans = [
_serialized_v1_span_to_serialized_v2_span(span, event_opt)
for span in gen_ai_spans
if isinstance(span, dict)
]
Comment thread
sentry[bot] marked this conversation as resolved.

envelope.add_item(
Item(
type=SpanBatcher.TYPE,
content_type=SpanBatcher.CONTENT_TYPE,
headers={
"item_count": len(converted_gen_ai_spans),
},
payload=PayloadRef(
json={
"version": 2,
"items": converted_gen_ai_spans,
},
),
Comment thread
alexander-alderman-webb marked this conversation as resolved.
Comment thread
cursor[bot] marked this conversation as resolved.
)
)

elif is_checkin:
envelope.add_checkin(event_opt)
else:
Expand Down
4 changes: 4 additions & 0 deletions sentry_sdk/consts.py
Original file line number Diff line number Diff line change
Expand Up @@ -1218,6 +1218,7 @@ def __init__(
before_send_metric: "Optional[Callable[[Metric, Hint], Optional[Metric]]]" = None,
org_id: "Optional[str]" = None,
strict_trace_continuation: bool = False,
stream_gen_ai_spans: bool = False,
) -> None:
"""Initialize the Sentry SDK with the given parameters. All parameters described here can be used in a call to `sentry_sdk.init()`.

Expand Down Expand Up @@ -1633,6 +1634,9 @@ def __init__(
but you can provide it explicitly for self-hosted and Relay setups. This value is used for
trace propagation and for features like `strict_trace_continuation`.

:param stream_gen_ai_spans: When set, generative AI spans are sent in a new transport format to
reduce downstream data loss.

:param _experiments:
"""
pass
Expand Down
25 changes: 20 additions & 5 deletions sentry_sdk/tracing.py
Original file line number Diff line number Diff line change
Expand Up @@ -1040,11 +1040,23 @@ def finish(

return None

finished_spans = [
span.to_json()
for span in self._span_recorder.spans
if span.timestamp is not None
]
finished_spans = []
has_gen_ai_span = False
if client.options.get("stream_gen_ai_spans", False):
for span in self._span_recorder.spans:
if span.timestamp is None:
continue

if isinstance(span.op, str) and span.op.startswith("gen_ai."):
has_gen_ai_span = True

finished_spans.append(span.to_json())
else:
finished_spans = [
span.to_json()
for span in self._span_recorder.spans
if span.timestamp is not None
]

len_diff = len(self._span_recorder.spans) - len(finished_spans)
dropped_spans = len_diff + self._span_recorder.dropped_spans
Expand Down Expand Up @@ -1076,6 +1088,9 @@ def finish(
if dropped_spans > 0:
event["_dropped_spans"] = dropped_spans

if has_gen_ai_span:
event["_has_gen_ai_span"] = True

if self._profile is not None and self._profile.valid():
event["profile"] = self._profile
self._profile = None
Expand Down
Loading
Loading