Skip to content
Draft
Changes from 5 commits
Commits
Show all changes
36 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
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
156 changes: 154 additions & 2 deletions sentry_sdk/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
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
Expand All @@ -27,6 +27,7 @@
get_before_send_metric,
has_logs_enabled,
has_metrics_enabled,
serialize_attribute,
)
from sentry_sdk.serializer import serialize
from sentry_sdk.tracing import trace
Expand Down Expand Up @@ -56,6 +57,8 @@
)
from sentry_sdk.scrubber import EventScrubber
from sentry_sdk.monitor import Monitor
from sentry_sdk.envelope import Item, PayloadRef
from sentry_sdk.utils import datetime_from_isoformat

if TYPE_CHECKING:
from typing import Any
Expand Down Expand Up @@ -89,6 +92,125 @@
}


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": "ok",
"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:
res["name"] = span["description"]

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"] != "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 attributes:
res["attributes"] = {k: serialize_attribute(v) for k, v in attributes.items()}

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 = 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:
span_op = span.get("op")

Check warning on line 205 in sentry_sdk/client.py

View check run for this annotation

@sentry/warden / warden: find-bugs

Missing type check in _split_gen_ai_spans can crash event capture for malformed spans

The `_split_gen_ai_spans` function at line 205 calls `span.get("op")` without first verifying that `span` is a dict. While the function checks that `spans` is `Iterable` at line 199, any non-dict item in the iterable will cause an AttributeError when `.get()` is called. This could crash event capture for transactions containing malformed span data. The later check `if isinstance(span, dict)` at line 1061 happens too late - the crash would occur during the split operation.
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.
Expand Down Expand Up @@ -912,7 +1034,37 @@
if is_transaction:
if isinstance(profile, Profile):
envelope.add_profile(profile.to_json(event_opt, self.options))
envelope.add_transaction(event_opt)

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
envelope.add_transaction(event_opt)

envelope.add_item(
Item(
type=SpanBatcher.TYPE,
content_type=SpanBatcher.CONTENT_TYPE,
headers={
"item_count": len(gen_ai_spans),
},
payload=PayloadRef(
json={
"items": [
_serialized_v1_span_to_serialized_v2_span(
span, event

Check warning on line 1058 in sentry_sdk/client.py

View check run for this annotation

@sentry/warden / warden: code-review

V2 span conversion uses unprocessed event, potentially missing release, environment, and SDK metadata

The call to `_serialized_v1_span_to_serialized_v2_span(span, event)` at line 1058 passes the original `event` parameter instead of `event_opt`. The `_prepare_event` method (lines 729-735) populates `release`, `environment`, and `sdk` fields from options if not already present. Since `_serialized_v1_span_to_serialized_v2_span` extracts these fields (lines 165-181), using the unprocessed `event` may result in V2 spans missing critical attributes like `sentry.release`, `sentry.environment`, `sentry.sdk.name`, and `sentry.sdk.version`.
Comment thread
sentry-warden[bot] marked this conversation as resolved.
Outdated
)

Check warning on line 1059 in sentry_sdk/client.py

View check run for this annotation

@sentry/warden / warden: find-bugs

Wrong variable `event` used instead of `event_opt` causing incomplete span data

At line 1058, the code calls `_serialized_v1_span_to_serialized_v2_span(span, event)` using the original `event` parameter instead of the processed `event_opt`. The `_serialized_v1_span_to_serialized_v2_span` function (lines 155-184) extracts user info, release, environment, transaction name, trace context, and SDK info from the event parameter. Since `event_opt` is the result of `_prepare_event()` which applies scope processing (including user context, release, environment), using the original `event` may result in missing or incorrect metadata in the V2 spans.
for span in gen_ai_spans
if isinstance(span, dict)
]
Comment thread
sentry-warden[bot] marked this conversation as resolved.
Outdated
},
),
)
)

elif is_checkin:
envelope.add_checkin(event_opt)
else:
Expand Down
Loading