Skip to content

Commit d0e102a

Browse files
authored
Merge branch 'master' into ivana/span-first-before-send-span
2 parents 177cc5b + 253a4d4 commit d0e102a

15 files changed

Lines changed: 20301 additions & 7901 deletions

File tree

sentry_sdk/_types.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -243,6 +243,7 @@ class SDKInfo(TypedDict):
243243
"type": Literal["check_in", "transaction"],
244244
"user": dict[str, object],
245245
"_dropped_spans": int,
246+
"_has_gen_ai_span": bool,
246247
},
247248
total=False,
248249
)

sentry_sdk/client.py

Lines changed: 243 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,12 @@
22
import uuid
33
import random
44
import socket
5-
from collections.abc import Mapping
5+
from collections.abc import Mapping, Iterable
66
from datetime import datetime, timezone
77
from importlib import import_module
88
from typing import TYPE_CHECKING, List, Dict, cast, overload
99
import warnings
10+
import json
1011

1112
from sentry_sdk._compat import check_uwsgi_thread_support
1213
from sentry_sdk._metrics_batcher import MetricsBatcher
@@ -32,6 +33,7 @@
3233
from sentry_sdk.serializer import serialize
3334
from sentry_sdk.traces import StreamedSpan
3435
from sentry_sdk.tracing import trace
36+
from sentry_sdk.traces import SpanStatus
3537
from sentry_sdk.tracing_utils import has_span_streaming_enabled
3638
from sentry_sdk.transport import (
3739
HttpTransportCore,
@@ -40,6 +42,7 @@
4042
)
4143
from sentry_sdk.consts import (
4244
SPANDATA,
45+
SPANSTATUS,
4346
DEFAULT_MAX_VALUE_LENGTH,
4447
DEFAULT_OPTIONS,
4548
INSTRUMENTER,
@@ -49,7 +52,7 @@
4952
from sentry_sdk.integrations import _DEFAULT_INTEGRATIONS, setup_integrations
5053
from sentry_sdk.integrations.dedupe import DedupeIntegration
5154
from sentry_sdk.sessions import SessionFlusher
52-
from sentry_sdk.envelope import Envelope
55+
from sentry_sdk.envelope import Envelope, Item, PayloadRef
5356
from sentry_sdk.profiler.continuous_profiler import setup_continuous_profiler
5457
from sentry_sdk.profiler.transaction_profiler import (
5558
has_profiling_enabled,
@@ -58,6 +61,7 @@
5861
)
5962
from sentry_sdk.scrubber import EventScrubber
6063
from sentry_sdk.monitor import Monitor
64+
from sentry_sdk.utils import datetime_from_isoformat
6165

6266
if TYPE_CHECKING:
6367
from typing import Any
@@ -68,7 +72,15 @@
6872
from typing import Union
6973
from typing import TypeVar
7074

71-
from sentry_sdk._types import Event, Hint, SDKInfo, Log, Metric, EventDataCategory
75+
from sentry_sdk._types import (
76+
Event,
77+
Hint,
78+
SDKInfo,
79+
Log,
80+
Metric,
81+
EventDataCategory,
82+
SerializedAttributeValue,
83+
)
7284
from sentry_sdk.integrations import Integration
7385
from sentry_sdk.scope import Scope
7486
from sentry_sdk.session import Session
@@ -90,6 +102,196 @@
90102
}
91103

92104

105+
def _serialized_v1_attribute_to_serialized_v2_attribute(
106+
attribute_value: "Any",
107+
) -> "Optional[SerializedAttributeValue]":
108+
if isinstance(attribute_value, bool):
109+
return {
110+
"value": attribute_value,
111+
"type": "boolean",
112+
}
113+
114+
if isinstance(attribute_value, int):
115+
return {
116+
"value": attribute_value,
117+
"type": "integer",
118+
}
119+
120+
if isinstance(attribute_value, float):
121+
return {
122+
"value": attribute_value,
123+
"type": "double",
124+
}
125+
126+
if isinstance(attribute_value, str):
127+
return {
128+
"value": attribute_value,
129+
"type": "string",
130+
}
131+
132+
if isinstance(attribute_value, list):
133+
if not attribute_value:
134+
return {"value": [], "type": "array"}
135+
136+
ty = type(attribute_value[0])
137+
if ty in (int, str, bool, float) and all(
138+
type(v) is ty for v in attribute_value
139+
):
140+
return {
141+
"value": attribute_value,
142+
"type": "array",
143+
}
144+
145+
# Types returned when the serializer for V1 span attributes recurses into some container types.
146+
if isinstance(attribute_value, (dict, list)):
147+
return {
148+
"value": json.dumps(attribute_value),
149+
"type": "string",
150+
}
151+
152+
return None
153+
154+
155+
def _serialized_v1_span_to_serialized_v2_span(
156+
span: "dict[str, Any]", event: "Event"
157+
) -> "dict[str, Any]":
158+
# See SpanBatcher._to_transport_format() for analogous population of all entries except "attributes".
159+
res: "dict[str, Any]" = {
160+
"status": SpanStatus.OK.value,
161+
"is_segment": False,
162+
}
163+
164+
if "trace_id" in span:
165+
res["trace_id"] = span["trace_id"]
166+
167+
if "span_id" in span:
168+
res["span_id"] = span["span_id"]
169+
170+
if "description" in span:
171+
description = span["description"]
172+
173+
if description is None and "op" in span:
174+
description = span["op"]
175+
176+
res["name"] = description
177+
178+
if "start_timestamp" in span:
179+
start_timestamp = None
180+
try:
181+
start_timestamp = datetime_from_isoformat(span["start_timestamp"])
182+
except Exception:
183+
pass
184+
185+
if start_timestamp is not None:
186+
res["start_timestamp"] = start_timestamp.timestamp()
187+
188+
if "timestamp" in span:
189+
end_timestamp = None
190+
try:
191+
end_timestamp = datetime_from_isoformat(span["timestamp"])
192+
except Exception:
193+
pass
194+
195+
if end_timestamp is not None:
196+
res["end_timestamp"] = end_timestamp.timestamp()
197+
198+
if "parent_span_id" in span:
199+
res["parent_span_id"] = span["parent_span_id"]
200+
201+
if "status" in span and span["status"] != SPANSTATUS.OK:
202+
res["status"] = "error"
203+
204+
attributes: "Dict[str, Any]" = {}
205+
206+
if "op" in span:
207+
attributes["sentry.op"] = span["op"]
208+
if "origin" in span:
209+
attributes["sentry.origin"] = span["origin"]
210+
211+
span_data = span.get("data")
212+
if isinstance(span_data, dict):
213+
attributes.update(span_data)
214+
215+
span_tags = span.get("tags")
216+
if isinstance(span_tags, dict):
217+
attributes.update(span_tags)
218+
219+
# See Scope._apply_user_attributes_to_telemetry() for user attributes.
220+
user = event.get("user")
221+
if isinstance(user, dict):
222+
if "id" in user:
223+
attributes["user.id"] = user["id"]
224+
if "username" in user:
225+
attributes["user.name"] = user["username"]
226+
if "email" in user:
227+
attributes["user.email"] = user["email"]
228+
229+
# See Scope.set_global_attributes() for release, environment, and SDK metadata.
230+
if "release" in event:
231+
attributes["sentry.release"] = event["release"]
232+
if "environment" in event:
233+
attributes["sentry.environment"] = event["environment"]
234+
if "transaction" in event:
235+
attributes["sentry.segment.name"] = event["transaction"]
236+
237+
trace_context = event.get("contexts", {}).get("trace", {})
238+
if "span_id" in trace_context:
239+
attributes["sentry.segment.id"] = trace_context["span_id"]
240+
241+
sdk_info = event.get("sdk")
242+
if isinstance(sdk_info, dict):
243+
if "name" in sdk_info:
244+
attributes["sentry.sdk.name"] = sdk_info["name"]
245+
if "version" in sdk_info:
246+
attributes["sentry.sdk.version"] = sdk_info["version"]
247+
248+
if not attributes:
249+
return res
250+
251+
res["attributes"] = {}
252+
for key, value in attributes.items():
253+
converted_value = _serialized_v1_attribute_to_serialized_v2_attribute(value)
254+
if converted_value is None:
255+
continue
256+
257+
res["attributes"][key] = converted_value
258+
259+
# Remove redundant attribute, as status is stored in the status field.
260+
if "status" in res["attributes"]:
261+
del res["attributes"]["status"]
262+
263+
return res
264+
265+
266+
def _split_gen_ai_spans(
267+
event_opt: "Event",
268+
) -> "Optional[tuple[List[Dict[str, object]], List[Dict[str, object]]]]":
269+
if "spans" not in event_opt:
270+
return None
271+
272+
spans: "Any" = event_opt["spans"]
273+
if isinstance(spans, AnnotatedValue):
274+
spans = spans.value
275+
276+
if not isinstance(spans, Iterable):
277+
return None
278+
279+
non_gen_ai_spans = []
280+
gen_ai_spans = []
281+
for span in spans:
282+
if not isinstance(span, dict):
283+
non_gen_ai_spans.append(span)
284+
continue
285+
286+
span_op = span.get("op")
287+
if isinstance(span_op, str) and span_op.startswith("gen_ai."):
288+
gen_ai_spans.append(span)
289+
else:
290+
non_gen_ai_spans.append(span)
291+
292+
return non_gen_ai_spans, gen_ai_spans
293+
294+
93295
def _get_options(*args: "Optional[str]", **kwargs: "Any") -> "Dict[str, Any]":
94296
if args and (isinstance(args[0], (bytes, str)) or args[0] is None):
95297
dsn: "Optional[str]" = args[0]
@@ -875,6 +1077,8 @@ def capture_event(
8751077
event_id = event.get("event_id")
8761078
if event_id is None:
8771079
event["event_id"] = event_id = uuid.uuid4().hex
1080+
1081+
span_recorder_has_gen_ai_span = event.pop("_has_gen_ai_span", False)
8781082
event_opt = self._prepare_event(event, hint, scope)
8791083
if event_opt is None:
8801084
return None
@@ -910,10 +1114,43 @@ def capture_event(
9101114

9111115
envelope = Envelope(headers=headers)
9121116

913-
if is_transaction:
914-
if isinstance(profile, Profile):
915-
envelope.add_profile(profile.to_json(event_opt, self.options))
1117+
if is_transaction and isinstance(profile, Profile):
1118+
envelope.add_profile(profile.to_json(event_opt, self.options))
1119+
1120+
if is_transaction and not span_recorder_has_gen_ai_span:
9161121
envelope.add_transaction(event_opt)
1122+
elif is_transaction:
1123+
split_spans = _split_gen_ai_spans(event_opt)
1124+
if split_spans is None or not split_spans[1]:
1125+
envelope.add_transaction(event_opt)
1126+
else:
1127+
non_gen_ai_spans, gen_ai_spans = split_spans
1128+
1129+
event_opt["spans"] = non_gen_ai_spans
1130+
envelope.add_transaction(event_opt)
1131+
1132+
converted_gen_ai_spans = [
1133+
_serialized_v1_span_to_serialized_v2_span(span, event_opt)
1134+
for span in gen_ai_spans
1135+
if isinstance(span, dict)
1136+
]
1137+
1138+
envelope.add_item(
1139+
Item(
1140+
type=SpanBatcher.TYPE,
1141+
content_type=SpanBatcher.CONTENT_TYPE,
1142+
headers={
1143+
"item_count": len(converted_gen_ai_spans),
1144+
},
1145+
payload=PayloadRef(
1146+
json={
1147+
"version": 2,
1148+
"items": converted_gen_ai_spans,
1149+
},
1150+
),
1151+
)
1152+
)
1153+
9171154
elif is_checkin:
9181155
envelope.add_checkin(event_opt)
9191156
else:

sentry_sdk/consts.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1222,6 +1222,7 @@ def __init__(
12221222
before_send_metric: "Optional[Callable[[Metric, Hint], Optional[Metric]]]" = None,
12231223
org_id: "Optional[str]" = None,
12241224
strict_trace_continuation: bool = False,
1225+
stream_gen_ai_spans: bool = False,
12251226
) -> None:
12261227
"""Initialize the Sentry SDK with the given parameters. All parameters described here can be used in a call to `sentry_sdk.init()`.
12271228
@@ -1637,6 +1638,9 @@ def __init__(
16371638
but you can provide it explicitly for self-hosted and Relay setups. This value is used for
16381639
trace propagation and for features like `strict_trace_continuation`.
16391640
1641+
:param stream_gen_ai_spans: When set, generative AI spans are sent in a new transport format to
1642+
reduce downstream data loss.
1643+
16401644
:param _experiments:
16411645
"""
16421646
pass

sentry_sdk/tracing.py

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1040,11 +1040,23 @@ def finish(
10401040

10411041
return None
10421042

1043-
finished_spans = [
1044-
span.to_json()
1045-
for span in self._span_recorder.spans
1046-
if span.timestamp is not None
1047-
]
1043+
finished_spans = []
1044+
has_gen_ai_span = False
1045+
if client.options.get("stream_gen_ai_spans", False):
1046+
for span in self._span_recorder.spans:
1047+
if span.timestamp is None:
1048+
continue
1049+
1050+
if isinstance(span.op, str) and span.op.startswith("gen_ai."):
1051+
has_gen_ai_span = True
1052+
1053+
finished_spans.append(span.to_json())
1054+
else:
1055+
finished_spans = [
1056+
span.to_json()
1057+
for span in self._span_recorder.spans
1058+
if span.timestamp is not None
1059+
]
10481060

10491061
len_diff = len(self._span_recorder.spans) - len(finished_spans)
10501062
dropped_spans = len_diff + self._span_recorder.dropped_spans
@@ -1076,6 +1088,9 @@ def finish(
10761088
if dropped_spans > 0:
10771089
event["_dropped_spans"] = dropped_spans
10781090

1091+
if has_gen_ai_span:
1092+
event["_has_gen_ai_span"] = True
1093+
10791094
if self._profile is not None and self._profile.valid():
10801095
event["profile"] = self._profile
10811096
self._profile = None

0 commit comments

Comments
 (0)