|
2 | 2 | import uuid |
3 | 3 | import random |
4 | 4 | import socket |
5 | | -from collections.abc import Mapping |
| 5 | +from collections.abc import Mapping, Iterable |
6 | 6 | from datetime import datetime, timezone |
7 | 7 | from importlib import import_module |
8 | 8 | from typing import TYPE_CHECKING, List, Dict, cast, overload |
9 | 9 | import warnings |
| 10 | +import json |
10 | 11 |
|
11 | 12 | from sentry_sdk._compat import check_uwsgi_thread_support |
12 | 13 | from sentry_sdk._metrics_batcher import MetricsBatcher |
|
32 | 33 | from sentry_sdk.serializer import serialize |
33 | 34 | from sentry_sdk.traces import StreamedSpan |
34 | 35 | from sentry_sdk.tracing import trace |
| 36 | +from sentry_sdk.traces import SpanStatus |
35 | 37 | from sentry_sdk.tracing_utils import has_span_streaming_enabled |
36 | 38 | from sentry_sdk.transport import ( |
37 | 39 | HttpTransportCore, |
|
40 | 42 | ) |
41 | 43 | from sentry_sdk.consts import ( |
42 | 44 | SPANDATA, |
| 45 | + SPANSTATUS, |
43 | 46 | DEFAULT_MAX_VALUE_LENGTH, |
44 | 47 | DEFAULT_OPTIONS, |
45 | 48 | INSTRUMENTER, |
|
49 | 52 | from sentry_sdk.integrations import _DEFAULT_INTEGRATIONS, setup_integrations |
50 | 53 | from sentry_sdk.integrations.dedupe import DedupeIntegration |
51 | 54 | from sentry_sdk.sessions import SessionFlusher |
52 | | -from sentry_sdk.envelope import Envelope |
| 55 | +from sentry_sdk.envelope import Envelope, Item, PayloadRef |
53 | 56 | from sentry_sdk.profiler.continuous_profiler import setup_continuous_profiler |
54 | 57 | from sentry_sdk.profiler.transaction_profiler import ( |
55 | 58 | has_profiling_enabled, |
|
58 | 61 | ) |
59 | 62 | from sentry_sdk.scrubber import EventScrubber |
60 | 63 | from sentry_sdk.monitor import Monitor |
| 64 | +from sentry_sdk.utils import datetime_from_isoformat |
61 | 65 |
|
62 | 66 | if TYPE_CHECKING: |
63 | 67 | from typing import Any |
|
68 | 72 | from typing import Union |
69 | 73 | from typing import TypeVar |
70 | 74 |
|
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 | + ) |
72 | 84 | from sentry_sdk.integrations import Integration |
73 | 85 | from sentry_sdk.scope import Scope |
74 | 86 | from sentry_sdk.session import Session |
|
90 | 102 | } |
91 | 103 |
|
92 | 104 |
|
| 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 | + |
93 | 295 | def _get_options(*args: "Optional[str]", **kwargs: "Any") -> "Dict[str, Any]": |
94 | 296 | if args and (isinstance(args[0], (bytes, str)) or args[0] is None): |
95 | 297 | dsn: "Optional[str]" = args[0] |
@@ -875,6 +1077,8 @@ def capture_event( |
875 | 1077 | event_id = event.get("event_id") |
876 | 1078 | if event_id is None: |
877 | 1079 | event["event_id"] = event_id = uuid.uuid4().hex |
| 1080 | + |
| 1081 | + span_recorder_has_gen_ai_span = event.pop("_has_gen_ai_span", False) |
878 | 1082 | event_opt = self._prepare_event(event, hint, scope) |
879 | 1083 | if event_opt is None: |
880 | 1084 | return None |
@@ -910,10 +1114,43 @@ def capture_event( |
910 | 1114 |
|
911 | 1115 | envelope = Envelope(headers=headers) |
912 | 1116 |
|
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: |
916 | 1121 | 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 | + |
917 | 1154 | elif is_checkin: |
918 | 1155 | envelope.add_checkin(event_opt) |
919 | 1156 | else: |
|
0 commit comments