|
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 |
|
30 | 31 | ) |
31 | 32 | from sentry_sdk.serializer import serialize |
32 | 33 | from sentry_sdk.tracing import trace |
| 34 | +from sentry_sdk.traces import SpanStatus |
33 | 35 | from sentry_sdk.tracing_utils import has_span_streaming_enabled |
34 | 36 | from sentry_sdk.transport import ( |
35 | 37 | HttpTransportCore, |
|
38 | 40 | ) |
39 | 41 | from sentry_sdk.consts import ( |
40 | 42 | SPANDATA, |
| 43 | + SPANSTATUS, |
41 | 44 | DEFAULT_MAX_VALUE_LENGTH, |
42 | 45 | DEFAULT_OPTIONS, |
43 | 46 | INSTRUMENTER, |
|
47 | 50 | from sentry_sdk.integrations import _DEFAULT_INTEGRATIONS, setup_integrations |
48 | 51 | from sentry_sdk.integrations.dedupe import DedupeIntegration |
49 | 52 | from sentry_sdk.sessions import SessionFlusher |
50 | | -from sentry_sdk.envelope import Envelope |
| 53 | +from sentry_sdk.envelope import Envelope, Item, PayloadRef |
51 | 54 | from sentry_sdk.profiler.continuous_profiler import setup_continuous_profiler |
52 | 55 | from sentry_sdk.profiler.transaction_profiler import ( |
53 | 56 | has_profiling_enabled, |
|
56 | 59 | ) |
57 | 60 | from sentry_sdk.scrubber import EventScrubber |
58 | 61 | from sentry_sdk.monitor import Monitor |
| 62 | +from sentry_sdk.utils import datetime_from_isoformat |
59 | 63 |
|
60 | 64 | if TYPE_CHECKING: |
61 | 65 | from typing import Any |
|
66 | 70 | from typing import Union |
67 | 71 | from typing import TypeVar |
68 | 72 |
|
69 | | - from sentry_sdk._types import Event, Hint, SDKInfo, Log, Metric, EventDataCategory |
| 73 | + from sentry_sdk._types import ( |
| 74 | + Event, |
| 75 | + Hint, |
| 76 | + SDKInfo, |
| 77 | + Log, |
| 78 | + Metric, |
| 79 | + EventDataCategory, |
| 80 | + SerializedAttributeValue, |
| 81 | + ) |
70 | 82 | from sentry_sdk.integrations import Integration |
71 | 83 | from sentry_sdk.scope import Scope |
72 | 84 | from sentry_sdk.session import Session |
|
89 | 101 | } |
90 | 102 |
|
91 | 103 |
|
| 104 | +def _serialized_v1_attribute_to_serialized_v2_attribute( |
| 105 | + attribute_value: "Any", |
| 106 | +) -> "Optional[SerializedAttributeValue]": |
| 107 | + if isinstance(attribute_value, bool): |
| 108 | + return { |
| 109 | + "value": attribute_value, |
| 110 | + "type": "boolean", |
| 111 | + } |
| 112 | + |
| 113 | + if isinstance(attribute_value, int): |
| 114 | + return { |
| 115 | + "value": attribute_value, |
| 116 | + "type": "integer", |
| 117 | + } |
| 118 | + |
| 119 | + if isinstance(attribute_value, float): |
| 120 | + return { |
| 121 | + "value": attribute_value, |
| 122 | + "type": "double", |
| 123 | + } |
| 124 | + |
| 125 | + if isinstance(attribute_value, str): |
| 126 | + return { |
| 127 | + "value": attribute_value, |
| 128 | + "type": "string", |
| 129 | + } |
| 130 | + |
| 131 | + if isinstance(attribute_value, list): |
| 132 | + if not attribute_value: |
| 133 | + return {"value": [], "type": "array"} |
| 134 | + |
| 135 | + ty = type(attribute_value[0]) |
| 136 | + if ty in (int, str, bool, float) and all( |
| 137 | + type(v) is ty for v in attribute_value |
| 138 | + ): |
| 139 | + return { |
| 140 | + "value": attribute_value, |
| 141 | + "type": "array", |
| 142 | + } |
| 143 | + |
| 144 | + # Types returned when the serializer for V1 span attributes recurses into some container types. |
| 145 | + if isinstance(attribute_value, (dict, list)): |
| 146 | + return { |
| 147 | + "value": json.dumps(attribute_value), |
| 148 | + "type": "string", |
| 149 | + } |
| 150 | + |
| 151 | + return None |
| 152 | + |
| 153 | + |
| 154 | +def _serialized_v1_span_to_serialized_v2_span( |
| 155 | + span: "dict[str, Any]", event: "Event" |
| 156 | +) -> "dict[str, Any]": |
| 157 | + # See SpanBatcher._to_transport_format() for analogous population of all entries except "attributes". |
| 158 | + res: "dict[str, Any]" = { |
| 159 | + "status": SpanStatus.OK.value, |
| 160 | + "is_segment": False, |
| 161 | + } |
| 162 | + |
| 163 | + if "trace_id" in span: |
| 164 | + res["trace_id"] = span["trace_id"] |
| 165 | + |
| 166 | + if "span_id" in span: |
| 167 | + res["span_id"] = span["span_id"] |
| 168 | + |
| 169 | + if "description" in span: |
| 170 | + description = span["description"] |
| 171 | + |
| 172 | + if description is None and "op" in span: |
| 173 | + description = span["op"] |
| 174 | + |
| 175 | + res["name"] = description |
| 176 | + |
| 177 | + if "start_timestamp" in span: |
| 178 | + start_timestamp = None |
| 179 | + try: |
| 180 | + start_timestamp = datetime_from_isoformat(span["start_timestamp"]) |
| 181 | + except Exception: |
| 182 | + pass |
| 183 | + |
| 184 | + if start_timestamp is not None: |
| 185 | + res["start_timestamp"] = start_timestamp.timestamp() |
| 186 | + |
| 187 | + if "timestamp" in span: |
| 188 | + end_timestamp = None |
| 189 | + try: |
| 190 | + end_timestamp = datetime_from_isoformat(span["timestamp"]) |
| 191 | + except Exception: |
| 192 | + pass |
| 193 | + |
| 194 | + if end_timestamp is not None: |
| 195 | + res["end_timestamp"] = end_timestamp.timestamp() |
| 196 | + |
| 197 | + if "parent_span_id" in span: |
| 198 | + res["parent_span_id"] = span["parent_span_id"] |
| 199 | + |
| 200 | + if "status" in span and span["status"] != SPANSTATUS.OK: |
| 201 | + res["status"] = "error" |
| 202 | + |
| 203 | + attributes: "Dict[str, Any]" = {} |
| 204 | + |
| 205 | + if "op" in span: |
| 206 | + attributes["sentry.op"] = span["op"] |
| 207 | + if "origin" in span: |
| 208 | + attributes["sentry.origin"] = span["origin"] |
| 209 | + |
| 210 | + span_data = span.get("data") |
| 211 | + if isinstance(span_data, dict): |
| 212 | + attributes.update(span_data) |
| 213 | + |
| 214 | + span_tags = span.get("tags") |
| 215 | + if isinstance(span_tags, dict): |
| 216 | + attributes.update(span_tags) |
| 217 | + |
| 218 | + # See Scope._apply_user_attributes_to_telemetry() for user attributes. |
| 219 | + user = event.get("user") |
| 220 | + if isinstance(user, dict): |
| 221 | + if "id" in user: |
| 222 | + attributes["user.id"] = user["id"] |
| 223 | + if "username" in user: |
| 224 | + attributes["user.name"] = user["username"] |
| 225 | + if "email" in user: |
| 226 | + attributes["user.email"] = user["email"] |
| 227 | + |
| 228 | + # See Scope.set_global_attributes() for release, environment, and SDK metadata. |
| 229 | + if "release" in event: |
| 230 | + attributes["sentry.release"] = event["release"] |
| 231 | + if "environment" in event: |
| 232 | + attributes["sentry.environment"] = event["environment"] |
| 233 | + if "transaction" in event: |
| 234 | + attributes["sentry.segment.name"] = event["transaction"] |
| 235 | + |
| 236 | + trace_context = event.get("contexts", {}).get("trace", {}) |
| 237 | + if "span_id" in trace_context: |
| 238 | + attributes["sentry.segment.id"] = trace_context["span_id"] |
| 239 | + |
| 240 | + sdk_info = event.get("sdk") |
| 241 | + if isinstance(sdk_info, dict): |
| 242 | + if "name" in sdk_info: |
| 243 | + attributes["sentry.sdk.name"] = sdk_info["name"] |
| 244 | + if "version" in sdk_info: |
| 245 | + attributes["sentry.sdk.version"] = sdk_info["version"] |
| 246 | + |
| 247 | + if not attributes: |
| 248 | + return res |
| 249 | + |
| 250 | + res["attributes"] = {} |
| 251 | + for key, value in attributes.items(): |
| 252 | + converted_value = _serialized_v1_attribute_to_serialized_v2_attribute(value) |
| 253 | + if converted_value is None: |
| 254 | + continue |
| 255 | + |
| 256 | + res["attributes"][key] = converted_value |
| 257 | + |
| 258 | + # Remove redundant attribute, as status is stored in the status field. |
| 259 | + if "status" in res["attributes"]: |
| 260 | + del res["attributes"]["status"] |
| 261 | + |
| 262 | + return res |
| 263 | + |
| 264 | + |
| 265 | +def _split_gen_ai_spans( |
| 266 | + event_opt: "Event", |
| 267 | +) -> "Optional[tuple[List[Dict[str, object]], List[Dict[str, object]]]]": |
| 268 | + if "spans" not in event_opt: |
| 269 | + return None |
| 270 | + |
| 271 | + spans: "Any" = event_opt["spans"] |
| 272 | + if isinstance(spans, AnnotatedValue): |
| 273 | + spans = spans.value |
| 274 | + |
| 275 | + if not isinstance(spans, Iterable): |
| 276 | + return None |
| 277 | + |
| 278 | + non_gen_ai_spans = [] |
| 279 | + gen_ai_spans = [] |
| 280 | + for span in spans: |
| 281 | + if not isinstance(span, dict): |
| 282 | + non_gen_ai_spans.append(span) |
| 283 | + continue |
| 284 | + |
| 285 | + span_op = span.get("op") |
| 286 | + if isinstance(span_op, str) and span_op.startswith("gen_ai."): |
| 287 | + gen_ai_spans.append(span) |
| 288 | + else: |
| 289 | + non_gen_ai_spans.append(span) |
| 290 | + |
| 291 | + return non_gen_ai_spans, gen_ai_spans |
| 292 | + |
| 293 | + |
92 | 294 | def _get_options(*args: "Optional[str]", **kwargs: "Any") -> "Dict[str, Any]": |
93 | 295 | if args and (isinstance(args[0], (bytes, str)) or args[0] is None): |
94 | 296 | dsn: "Optional[str]" = args[0] |
@@ -874,6 +1076,8 @@ def capture_event( |
874 | 1076 | event_id = event.get("event_id") |
875 | 1077 | if event_id is None: |
876 | 1078 | event["event_id"] = event_id = uuid.uuid4().hex |
| 1079 | + |
| 1080 | + span_recorder_has_gen_ai_span = event.pop("_has_gen_ai_span", False) |
877 | 1081 | event_opt = self._prepare_event(event, hint, scope) |
878 | 1082 | if event_opt is None: |
879 | 1083 | return None |
@@ -909,10 +1113,43 @@ def capture_event( |
909 | 1113 |
|
910 | 1114 | envelope = Envelope(headers=headers) |
911 | 1115 |
|
912 | | - if is_transaction: |
913 | | - if isinstance(profile, Profile): |
914 | | - envelope.add_profile(profile.to_json(event_opt, self.options)) |
| 1116 | + if is_transaction and isinstance(profile, Profile): |
| 1117 | + envelope.add_profile(profile.to_json(event_opt, self.options)) |
| 1118 | + |
| 1119 | + if is_transaction and not span_recorder_has_gen_ai_span: |
915 | 1120 | envelope.add_transaction(event_opt) |
| 1121 | + elif is_transaction: |
| 1122 | + split_spans = _split_gen_ai_spans(event_opt) |
| 1123 | + if split_spans is None or not split_spans[1]: |
| 1124 | + envelope.add_transaction(event_opt) |
| 1125 | + else: |
| 1126 | + non_gen_ai_spans, gen_ai_spans = split_spans |
| 1127 | + |
| 1128 | + event_opt["spans"] = non_gen_ai_spans |
| 1129 | + envelope.add_transaction(event_opt) |
| 1130 | + |
| 1131 | + converted_gen_ai_spans = [ |
| 1132 | + _serialized_v1_span_to_serialized_v2_span(span, event_opt) |
| 1133 | + for span in gen_ai_spans |
| 1134 | + if isinstance(span, dict) |
| 1135 | + ] |
| 1136 | + |
| 1137 | + envelope.add_item( |
| 1138 | + Item( |
| 1139 | + type=SpanBatcher.TYPE, |
| 1140 | + content_type=SpanBatcher.CONTENT_TYPE, |
| 1141 | + headers={ |
| 1142 | + "item_count": len(converted_gen_ai_spans), |
| 1143 | + }, |
| 1144 | + payload=PayloadRef( |
| 1145 | + json={ |
| 1146 | + "version": 2, |
| 1147 | + "items": converted_gen_ai_spans, |
| 1148 | + }, |
| 1149 | + ), |
| 1150 | + ) |
| 1151 | + ) |
| 1152 | + |
916 | 1153 | elif is_checkin: |
917 | 1154 | envelope.add_checkin(event_opt) |
918 | 1155 | else: |
|
0 commit comments