sentry-sdk 0.7.5__py2.py3-none-any.whl → 2.46.0__py2.py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (193) hide show
  1. sentry_sdk/__init__.py +48 -30
  2. sentry_sdk/_compat.py +74 -61
  3. sentry_sdk/_init_implementation.py +84 -0
  4. sentry_sdk/_log_batcher.py +172 -0
  5. sentry_sdk/_lru_cache.py +47 -0
  6. sentry_sdk/_metrics_batcher.py +167 -0
  7. sentry_sdk/_queue.py +289 -0
  8. sentry_sdk/_types.py +338 -0
  9. sentry_sdk/_werkzeug.py +98 -0
  10. sentry_sdk/ai/__init__.py +7 -0
  11. sentry_sdk/ai/monitoring.py +137 -0
  12. sentry_sdk/ai/utils.py +144 -0
  13. sentry_sdk/api.py +496 -80
  14. sentry_sdk/attachments.py +75 -0
  15. sentry_sdk/client.py +1023 -103
  16. sentry_sdk/consts.py +1438 -66
  17. sentry_sdk/crons/__init__.py +10 -0
  18. sentry_sdk/crons/api.py +62 -0
  19. sentry_sdk/crons/consts.py +4 -0
  20. sentry_sdk/crons/decorator.py +135 -0
  21. sentry_sdk/debug.py +15 -14
  22. sentry_sdk/envelope.py +369 -0
  23. sentry_sdk/feature_flags.py +71 -0
  24. sentry_sdk/hub.py +611 -280
  25. sentry_sdk/integrations/__init__.py +276 -49
  26. sentry_sdk/integrations/_asgi_common.py +108 -0
  27. sentry_sdk/integrations/_wsgi_common.py +180 -44
  28. sentry_sdk/integrations/aiohttp.py +291 -42
  29. sentry_sdk/integrations/anthropic.py +439 -0
  30. sentry_sdk/integrations/argv.py +9 -8
  31. sentry_sdk/integrations/ariadne.py +161 -0
  32. sentry_sdk/integrations/arq.py +247 -0
  33. sentry_sdk/integrations/asgi.py +341 -0
  34. sentry_sdk/integrations/asyncio.py +144 -0
  35. sentry_sdk/integrations/asyncpg.py +208 -0
  36. sentry_sdk/integrations/atexit.py +17 -10
  37. sentry_sdk/integrations/aws_lambda.py +377 -62
  38. sentry_sdk/integrations/beam.py +176 -0
  39. sentry_sdk/integrations/boto3.py +137 -0
  40. sentry_sdk/integrations/bottle.py +221 -0
  41. sentry_sdk/integrations/celery/__init__.py +529 -0
  42. sentry_sdk/integrations/celery/beat.py +293 -0
  43. sentry_sdk/integrations/celery/utils.py +43 -0
  44. sentry_sdk/integrations/chalice.py +134 -0
  45. sentry_sdk/integrations/clickhouse_driver.py +177 -0
  46. sentry_sdk/integrations/cloud_resource_context.py +280 -0
  47. sentry_sdk/integrations/cohere.py +274 -0
  48. sentry_sdk/integrations/dedupe.py +48 -14
  49. sentry_sdk/integrations/django/__init__.py +584 -191
  50. sentry_sdk/integrations/django/asgi.py +245 -0
  51. sentry_sdk/integrations/django/caching.py +204 -0
  52. sentry_sdk/integrations/django/middleware.py +187 -0
  53. sentry_sdk/integrations/django/signals_handlers.py +91 -0
  54. sentry_sdk/integrations/django/templates.py +79 -5
  55. sentry_sdk/integrations/django/transactions.py +49 -22
  56. sentry_sdk/integrations/django/views.py +96 -0
  57. sentry_sdk/integrations/dramatiq.py +226 -0
  58. sentry_sdk/integrations/excepthook.py +50 -13
  59. sentry_sdk/integrations/executing.py +67 -0
  60. sentry_sdk/integrations/falcon.py +272 -0
  61. sentry_sdk/integrations/fastapi.py +141 -0
  62. sentry_sdk/integrations/flask.py +142 -88
  63. sentry_sdk/integrations/gcp.py +239 -0
  64. sentry_sdk/integrations/gnu_backtrace.py +99 -0
  65. sentry_sdk/integrations/google_genai/__init__.py +301 -0
  66. sentry_sdk/integrations/google_genai/consts.py +16 -0
  67. sentry_sdk/integrations/google_genai/streaming.py +155 -0
  68. sentry_sdk/integrations/google_genai/utils.py +576 -0
  69. sentry_sdk/integrations/gql.py +162 -0
  70. sentry_sdk/integrations/graphene.py +151 -0
  71. sentry_sdk/integrations/grpc/__init__.py +168 -0
  72. sentry_sdk/integrations/grpc/aio/__init__.py +7 -0
  73. sentry_sdk/integrations/grpc/aio/client.py +95 -0
  74. sentry_sdk/integrations/grpc/aio/server.py +100 -0
  75. sentry_sdk/integrations/grpc/client.py +91 -0
  76. sentry_sdk/integrations/grpc/consts.py +1 -0
  77. sentry_sdk/integrations/grpc/server.py +66 -0
  78. sentry_sdk/integrations/httpx.py +178 -0
  79. sentry_sdk/integrations/huey.py +174 -0
  80. sentry_sdk/integrations/huggingface_hub.py +378 -0
  81. sentry_sdk/integrations/langchain.py +1132 -0
  82. sentry_sdk/integrations/langgraph.py +337 -0
  83. sentry_sdk/integrations/launchdarkly.py +61 -0
  84. sentry_sdk/integrations/litellm.py +287 -0
  85. sentry_sdk/integrations/litestar.py +315 -0
  86. sentry_sdk/integrations/logging.py +307 -96
  87. sentry_sdk/integrations/loguru.py +213 -0
  88. sentry_sdk/integrations/mcp.py +566 -0
  89. sentry_sdk/integrations/modules.py +14 -31
  90. sentry_sdk/integrations/openai.py +725 -0
  91. sentry_sdk/integrations/openai_agents/__init__.py +61 -0
  92. sentry_sdk/integrations/openai_agents/consts.py +1 -0
  93. sentry_sdk/integrations/openai_agents/patches/__init__.py +5 -0
  94. sentry_sdk/integrations/openai_agents/patches/agent_run.py +140 -0
  95. sentry_sdk/integrations/openai_agents/patches/error_tracing.py +77 -0
  96. sentry_sdk/integrations/openai_agents/patches/models.py +50 -0
  97. sentry_sdk/integrations/openai_agents/patches/runner.py +45 -0
  98. sentry_sdk/integrations/openai_agents/patches/tools.py +77 -0
  99. sentry_sdk/integrations/openai_agents/spans/__init__.py +5 -0
  100. sentry_sdk/integrations/openai_agents/spans/agent_workflow.py +21 -0
  101. sentry_sdk/integrations/openai_agents/spans/ai_client.py +42 -0
  102. sentry_sdk/integrations/openai_agents/spans/execute_tool.py +48 -0
  103. sentry_sdk/integrations/openai_agents/spans/handoff.py +19 -0
  104. sentry_sdk/integrations/openai_agents/spans/invoke_agent.py +86 -0
  105. sentry_sdk/integrations/openai_agents/utils.py +199 -0
  106. sentry_sdk/integrations/openfeature.py +35 -0
  107. sentry_sdk/integrations/opentelemetry/__init__.py +7 -0
  108. sentry_sdk/integrations/opentelemetry/consts.py +5 -0
  109. sentry_sdk/integrations/opentelemetry/integration.py +58 -0
  110. sentry_sdk/integrations/opentelemetry/propagator.py +117 -0
  111. sentry_sdk/integrations/opentelemetry/span_processor.py +391 -0
  112. sentry_sdk/integrations/otlp.py +82 -0
  113. sentry_sdk/integrations/pure_eval.py +141 -0
  114. sentry_sdk/integrations/pydantic_ai/__init__.py +47 -0
  115. sentry_sdk/integrations/pydantic_ai/consts.py +1 -0
  116. sentry_sdk/integrations/pydantic_ai/patches/__init__.py +4 -0
  117. sentry_sdk/integrations/pydantic_ai/patches/agent_run.py +215 -0
  118. sentry_sdk/integrations/pydantic_ai/patches/graph_nodes.py +110 -0
  119. sentry_sdk/integrations/pydantic_ai/patches/model_request.py +40 -0
  120. sentry_sdk/integrations/pydantic_ai/patches/tools.py +98 -0
  121. sentry_sdk/integrations/pydantic_ai/spans/__init__.py +3 -0
  122. sentry_sdk/integrations/pydantic_ai/spans/ai_client.py +246 -0
  123. sentry_sdk/integrations/pydantic_ai/spans/execute_tool.py +49 -0
  124. sentry_sdk/integrations/pydantic_ai/spans/invoke_agent.py +112 -0
  125. sentry_sdk/integrations/pydantic_ai/utils.py +223 -0
  126. sentry_sdk/integrations/pymongo.py +214 -0
  127. sentry_sdk/integrations/pyramid.py +112 -68
  128. sentry_sdk/integrations/quart.py +237 -0
  129. sentry_sdk/integrations/ray.py +165 -0
  130. sentry_sdk/integrations/redis/__init__.py +48 -0
  131. sentry_sdk/integrations/redis/_async_common.py +116 -0
  132. sentry_sdk/integrations/redis/_sync_common.py +119 -0
  133. sentry_sdk/integrations/redis/consts.py +19 -0
  134. sentry_sdk/integrations/redis/modules/__init__.py +0 -0
  135. sentry_sdk/integrations/redis/modules/caches.py +118 -0
  136. sentry_sdk/integrations/redis/modules/queries.py +65 -0
  137. sentry_sdk/integrations/redis/rb.py +32 -0
  138. sentry_sdk/integrations/redis/redis.py +69 -0
  139. sentry_sdk/integrations/redis/redis_cluster.py +107 -0
  140. sentry_sdk/integrations/redis/redis_py_cluster_legacy.py +50 -0
  141. sentry_sdk/integrations/redis/utils.py +148 -0
  142. sentry_sdk/integrations/rq.py +95 -37
  143. sentry_sdk/integrations/rust_tracing.py +284 -0
  144. sentry_sdk/integrations/sanic.py +294 -123
  145. sentry_sdk/integrations/serverless.py +48 -19
  146. sentry_sdk/integrations/socket.py +96 -0
  147. sentry_sdk/integrations/spark/__init__.py +4 -0
  148. sentry_sdk/integrations/spark/spark_driver.py +316 -0
  149. sentry_sdk/integrations/spark/spark_worker.py +116 -0
  150. sentry_sdk/integrations/sqlalchemy.py +142 -0
  151. sentry_sdk/integrations/starlette.py +737 -0
  152. sentry_sdk/integrations/starlite.py +292 -0
  153. sentry_sdk/integrations/statsig.py +37 -0
  154. sentry_sdk/integrations/stdlib.py +235 -29
  155. sentry_sdk/integrations/strawberry.py +394 -0
  156. sentry_sdk/integrations/sys_exit.py +70 -0
  157. sentry_sdk/integrations/threading.py +158 -28
  158. sentry_sdk/integrations/tornado.py +84 -52
  159. sentry_sdk/integrations/trytond.py +50 -0
  160. sentry_sdk/integrations/typer.py +60 -0
  161. sentry_sdk/integrations/unleash.py +33 -0
  162. sentry_sdk/integrations/unraisablehook.py +53 -0
  163. sentry_sdk/integrations/wsgi.py +201 -119
  164. sentry_sdk/logger.py +96 -0
  165. sentry_sdk/metrics.py +81 -0
  166. sentry_sdk/monitor.py +120 -0
  167. sentry_sdk/profiler/__init__.py +49 -0
  168. sentry_sdk/profiler/continuous_profiler.py +730 -0
  169. sentry_sdk/profiler/transaction_profiler.py +839 -0
  170. sentry_sdk/profiler/utils.py +195 -0
  171. sentry_sdk/py.typed +0 -0
  172. sentry_sdk/scope.py +1713 -85
  173. sentry_sdk/scrubber.py +177 -0
  174. sentry_sdk/serializer.py +405 -0
  175. sentry_sdk/session.py +177 -0
  176. sentry_sdk/sessions.py +275 -0
  177. sentry_sdk/spotlight.py +242 -0
  178. sentry_sdk/tracing.py +1486 -0
  179. sentry_sdk/tracing_utils.py +1236 -0
  180. sentry_sdk/transport.py +806 -134
  181. sentry_sdk/types.py +52 -0
  182. sentry_sdk/utils.py +1625 -465
  183. sentry_sdk/worker.py +54 -25
  184. sentry_sdk-2.46.0.dist-info/METADATA +268 -0
  185. sentry_sdk-2.46.0.dist-info/RECORD +189 -0
  186. {sentry_sdk-0.7.5.dist-info → sentry_sdk-2.46.0.dist-info}/WHEEL +1 -1
  187. sentry_sdk-2.46.0.dist-info/entry_points.txt +2 -0
  188. sentry_sdk-2.46.0.dist-info/licenses/LICENSE +21 -0
  189. sentry_sdk/integrations/celery.py +0 -119
  190. sentry_sdk-0.7.5.dist-info/LICENSE +0 -9
  191. sentry_sdk-0.7.5.dist-info/METADATA +0 -36
  192. sentry_sdk-0.7.5.dist-info/RECORD +0 -39
  193. {sentry_sdk-0.7.5.dist-info → sentry_sdk-2.46.0.dist-info}/top_level.txt +0 -0
sentry_sdk/session.py ADDED
@@ -0,0 +1,177 @@
1
+ import uuid
2
+ from datetime import datetime, timezone
3
+
4
+ from sentry_sdk.utils import format_timestamp
5
+
6
+ from typing import TYPE_CHECKING
7
+
8
+ if TYPE_CHECKING:
9
+ from typing import Optional
10
+ from typing import Union
11
+ from typing import Any
12
+ from typing import Dict
13
+
14
+ from sentry_sdk._types import SessionStatus
15
+
16
+
17
+ def _minute_trunc(ts):
18
+ # type: (datetime) -> datetime
19
+ return ts.replace(second=0, microsecond=0)
20
+
21
+
22
+ def _make_uuid(
23
+ val, # type: Union[str, uuid.UUID]
24
+ ):
25
+ # type: (...) -> uuid.UUID
26
+ if isinstance(val, uuid.UUID):
27
+ return val
28
+ return uuid.UUID(val)
29
+
30
+
31
+ class Session:
32
+ def __init__(
33
+ self,
34
+ sid=None, # type: Optional[Union[str, uuid.UUID]]
35
+ did=None, # type: Optional[str]
36
+ timestamp=None, # type: Optional[datetime]
37
+ started=None, # type: Optional[datetime]
38
+ duration=None, # type: Optional[float]
39
+ status=None, # type: Optional[SessionStatus]
40
+ release=None, # type: Optional[str]
41
+ environment=None, # type: Optional[str]
42
+ user_agent=None, # type: Optional[str]
43
+ ip_address=None, # type: Optional[str]
44
+ errors=None, # type: Optional[int]
45
+ user=None, # type: Optional[Any]
46
+ session_mode="application", # type: str
47
+ ):
48
+ # type: (...) -> None
49
+ if sid is None:
50
+ sid = uuid.uuid4()
51
+ if started is None:
52
+ started = datetime.now(timezone.utc)
53
+ if status is None:
54
+ status = "ok"
55
+ self.status = status
56
+ self.did = None # type: Optional[str]
57
+ self.started = started
58
+ self.release = None # type: Optional[str]
59
+ self.environment = None # type: Optional[str]
60
+ self.duration = None # type: Optional[float]
61
+ self.user_agent = None # type: Optional[str]
62
+ self.ip_address = None # type: Optional[str]
63
+ self.session_mode = session_mode # type: str
64
+ self.errors = 0
65
+
66
+ self.update(
67
+ sid=sid,
68
+ did=did,
69
+ timestamp=timestamp,
70
+ duration=duration,
71
+ release=release,
72
+ environment=environment,
73
+ user_agent=user_agent,
74
+ ip_address=ip_address,
75
+ errors=errors,
76
+ user=user,
77
+ )
78
+
79
+ @property
80
+ def truncated_started(self):
81
+ # type: (...) -> datetime
82
+ return _minute_trunc(self.started)
83
+
84
+ def update(
85
+ self,
86
+ sid=None, # type: Optional[Union[str, uuid.UUID]]
87
+ did=None, # type: Optional[str]
88
+ timestamp=None, # type: Optional[datetime]
89
+ started=None, # type: Optional[datetime]
90
+ duration=None, # type: Optional[float]
91
+ status=None, # type: Optional[SessionStatus]
92
+ release=None, # type: Optional[str]
93
+ environment=None, # type: Optional[str]
94
+ user_agent=None, # type: Optional[str]
95
+ ip_address=None, # type: Optional[str]
96
+ errors=None, # type: Optional[int]
97
+ user=None, # type: Optional[Any]
98
+ ):
99
+ # type: (...) -> None
100
+ # If a user is supplied we pull some data form it
101
+ if user:
102
+ if ip_address is None:
103
+ ip_address = user.get("ip_address")
104
+ if did is None:
105
+ did = user.get("id") or user.get("email") or user.get("username")
106
+
107
+ if sid is not None:
108
+ self.sid = _make_uuid(sid)
109
+ if did is not None:
110
+ self.did = str(did)
111
+ if timestamp is None:
112
+ timestamp = datetime.now(timezone.utc)
113
+ self.timestamp = timestamp
114
+ if started is not None:
115
+ self.started = started
116
+ if duration is not None:
117
+ self.duration = duration
118
+ if release is not None:
119
+ self.release = release
120
+ if environment is not None:
121
+ self.environment = environment
122
+ if ip_address is not None:
123
+ self.ip_address = ip_address
124
+ if user_agent is not None:
125
+ self.user_agent = user_agent
126
+ if errors is not None:
127
+ self.errors = errors
128
+
129
+ if status is not None:
130
+ self.status = status
131
+
132
+ def close(
133
+ self,
134
+ status=None, # type: Optional[SessionStatus]
135
+ ):
136
+ # type: (...) -> Any
137
+ if status is None and self.status == "ok":
138
+ status = "exited"
139
+ if status is not None:
140
+ self.update(status=status)
141
+
142
+ def get_json_attrs(
143
+ self,
144
+ with_user_info=True, # type: Optional[bool]
145
+ ):
146
+ # type: (...) -> Any
147
+ attrs = {}
148
+ if self.release is not None:
149
+ attrs["release"] = self.release
150
+ if self.environment is not None:
151
+ attrs["environment"] = self.environment
152
+ if with_user_info:
153
+ if self.ip_address is not None:
154
+ attrs["ip_address"] = self.ip_address
155
+ if self.user_agent is not None:
156
+ attrs["user_agent"] = self.user_agent
157
+ return attrs
158
+
159
+ def to_json(self):
160
+ # type: (...) -> Any
161
+ rv = {
162
+ "sid": str(self.sid),
163
+ "init": True,
164
+ "started": format_timestamp(self.started),
165
+ "timestamp": format_timestamp(self.timestamp),
166
+ "status": self.status,
167
+ } # type: Dict[str, Any]
168
+ if self.errors:
169
+ rv["errors"] = self.errors
170
+ if self.did is not None:
171
+ rv["did"] = self.did
172
+ if self.duration is not None:
173
+ rv["duration"] = self.duration
174
+ attrs = self.get_json_attrs()
175
+ if attrs:
176
+ rv["attrs"] = attrs
177
+ return rv
sentry_sdk/sessions.py ADDED
@@ -0,0 +1,275 @@
1
+ import os
2
+ import warnings
3
+ from threading import Thread, Lock, Event
4
+ from contextlib import contextmanager
5
+
6
+ import sentry_sdk
7
+ from sentry_sdk.envelope import Envelope
8
+ from sentry_sdk.session import Session
9
+ from sentry_sdk.utils import format_timestamp
10
+
11
+ from typing import TYPE_CHECKING
12
+
13
+ if TYPE_CHECKING:
14
+ from typing import Any
15
+ from typing import Callable
16
+ from typing import Dict
17
+ from typing import Generator
18
+ from typing import List
19
+ from typing import Optional
20
+ from typing import Union
21
+
22
+
23
+ def is_auto_session_tracking_enabled(hub=None):
24
+ # type: (Optional[sentry_sdk.Hub]) -> Union[Any, bool, None]
25
+ """DEPRECATED: Utility function to find out if session tracking is enabled."""
26
+
27
+ # Internal callers should use private _is_auto_session_tracking_enabled, instead.
28
+ warnings.warn(
29
+ "This function is deprecated and will be removed in the next major release. "
30
+ "There is no public API replacement.",
31
+ DeprecationWarning,
32
+ stacklevel=2,
33
+ )
34
+
35
+ if hub is None:
36
+ hub = sentry_sdk.Hub.current
37
+
38
+ should_track = hub.scope._force_auto_session_tracking
39
+
40
+ if should_track is None:
41
+ client_options = hub.client.options if hub.client else {}
42
+ should_track = client_options.get("auto_session_tracking", False)
43
+
44
+ return should_track
45
+
46
+
47
+ @contextmanager
48
+ def auto_session_tracking(hub=None, session_mode="application"):
49
+ # type: (Optional[sentry_sdk.Hub], str) -> Generator[None, None, None]
50
+ """DEPRECATED: Use track_session instead
51
+ Starts and stops a session automatically around a block.
52
+ """
53
+ warnings.warn(
54
+ "This function is deprecated and will be removed in the next major release. "
55
+ "Use track_session instead.",
56
+ DeprecationWarning,
57
+ stacklevel=2,
58
+ )
59
+
60
+ if hub is None:
61
+ hub = sentry_sdk.Hub.current
62
+ with warnings.catch_warnings():
63
+ warnings.simplefilter("ignore", DeprecationWarning)
64
+ should_track = is_auto_session_tracking_enabled(hub)
65
+ if should_track:
66
+ hub.start_session(session_mode=session_mode)
67
+ try:
68
+ yield
69
+ finally:
70
+ if should_track:
71
+ hub.end_session()
72
+
73
+
74
+ def is_auto_session_tracking_enabled_scope(scope):
75
+ # type: (sentry_sdk.Scope) -> bool
76
+ """
77
+ DEPRECATED: Utility function to find out if session tracking is enabled.
78
+ """
79
+
80
+ warnings.warn(
81
+ "This function is deprecated and will be removed in the next major release. "
82
+ "There is no public API replacement.",
83
+ DeprecationWarning,
84
+ stacklevel=2,
85
+ )
86
+
87
+ # Internal callers should use private _is_auto_session_tracking_enabled, instead.
88
+ return _is_auto_session_tracking_enabled(scope)
89
+
90
+
91
+ def _is_auto_session_tracking_enabled(scope):
92
+ # type: (sentry_sdk.Scope) -> bool
93
+ """
94
+ Utility function to find out if session tracking is enabled.
95
+ """
96
+
97
+ should_track = scope._force_auto_session_tracking
98
+ if should_track is None:
99
+ client_options = sentry_sdk.get_client().options
100
+ should_track = client_options.get("auto_session_tracking", False)
101
+
102
+ return should_track
103
+
104
+
105
+ @contextmanager
106
+ def auto_session_tracking_scope(scope, session_mode="application"):
107
+ # type: (sentry_sdk.Scope, str) -> Generator[None, None, None]
108
+ """DEPRECATED: This function is a deprecated alias for track_session.
109
+ Starts and stops a session automatically around a block.
110
+ """
111
+
112
+ warnings.warn(
113
+ "This function is a deprecated alias for track_session and will be removed in the next major release.",
114
+ DeprecationWarning,
115
+ stacklevel=2,
116
+ )
117
+
118
+ with track_session(scope, session_mode=session_mode):
119
+ yield
120
+
121
+
122
+ @contextmanager
123
+ def track_session(scope, session_mode="application"):
124
+ # type: (sentry_sdk.Scope, str) -> Generator[None, None, None]
125
+ """
126
+ Start a new session in the provided scope, assuming session tracking is enabled.
127
+ This is a no-op context manager if session tracking is not enabled.
128
+ """
129
+
130
+ should_track = _is_auto_session_tracking_enabled(scope)
131
+ if should_track:
132
+ scope.start_session(session_mode=session_mode)
133
+ try:
134
+ yield
135
+ finally:
136
+ if should_track:
137
+ scope.end_session()
138
+
139
+
140
+ TERMINAL_SESSION_STATES = ("exited", "abnormal", "crashed")
141
+ MAX_ENVELOPE_ITEMS = 100
142
+
143
+
144
+ def make_aggregate_envelope(aggregate_states, attrs):
145
+ # type: (Any, Any) -> Any
146
+ return {"attrs": dict(attrs), "aggregates": list(aggregate_states.values())}
147
+
148
+
149
+ class SessionFlusher:
150
+ def __init__(
151
+ self,
152
+ capture_func, # type: Callable[[Envelope], None]
153
+ flush_interval=60, # type: int
154
+ ):
155
+ # type: (...) -> None
156
+ self.capture_func = capture_func
157
+ self.flush_interval = flush_interval
158
+ self.pending_sessions = [] # type: List[Any]
159
+ self.pending_aggregates = {} # type: Dict[Any, Any]
160
+ self._thread = None # type: Optional[Thread]
161
+ self._thread_lock = Lock()
162
+ self._aggregate_lock = Lock()
163
+ self._thread_for_pid = None # type: Optional[int]
164
+ self.__shutdown_requested = Event()
165
+
166
+ def flush(self):
167
+ # type: (...) -> None
168
+ pending_sessions = self.pending_sessions
169
+ self.pending_sessions = []
170
+
171
+ with self._aggregate_lock:
172
+ pending_aggregates = self.pending_aggregates
173
+ self.pending_aggregates = {}
174
+
175
+ envelope = Envelope()
176
+ for session in pending_sessions:
177
+ if len(envelope.items) == MAX_ENVELOPE_ITEMS:
178
+ self.capture_func(envelope)
179
+ envelope = Envelope()
180
+
181
+ envelope.add_session(session)
182
+
183
+ for attrs, states in pending_aggregates.items():
184
+ if len(envelope.items) == MAX_ENVELOPE_ITEMS:
185
+ self.capture_func(envelope)
186
+ envelope = Envelope()
187
+
188
+ envelope.add_sessions(make_aggregate_envelope(states, attrs))
189
+
190
+ if len(envelope.items) > 0:
191
+ self.capture_func(envelope)
192
+
193
+ def _ensure_running(self):
194
+ # type: (...) -> None
195
+ """
196
+ Check that we have an active thread to run in, or create one if not.
197
+
198
+ Note that this might fail (e.g. in Python 3.12 it's not possible to
199
+ spawn new threads at interpreter shutdown). In that case self._running
200
+ will be False after running this function.
201
+ """
202
+ if self._thread_for_pid == os.getpid() and self._thread is not None:
203
+ return None
204
+ with self._thread_lock:
205
+ if self._thread_for_pid == os.getpid() and self._thread is not None:
206
+ return None
207
+
208
+ def _thread():
209
+ # type: (...) -> None
210
+ running = True
211
+ while running:
212
+ running = not self.__shutdown_requested.wait(self.flush_interval)
213
+ self.flush()
214
+
215
+ thread = Thread(target=_thread)
216
+ thread.daemon = True
217
+ try:
218
+ thread.start()
219
+ except RuntimeError:
220
+ # Unfortunately at this point the interpreter is in a state that no
221
+ # longer allows us to spawn a thread and we have to bail.
222
+ self.__shutdown_requested.set()
223
+ return None
224
+
225
+ self._thread = thread
226
+ self._thread_for_pid = os.getpid()
227
+
228
+ return None
229
+
230
+ def add_aggregate_session(
231
+ self,
232
+ session, # type: Session
233
+ ):
234
+ # type: (...) -> None
235
+ # NOTE on `session.did`:
236
+ # the protocol can deal with buckets that have a distinct-id, however
237
+ # in practice we expect the python SDK to have an extremely high cardinality
238
+ # here, effectively making aggregation useless, therefore we do not
239
+ # aggregate per-did.
240
+
241
+ # For this part we can get away with using the global interpreter lock
242
+ with self._aggregate_lock:
243
+ attrs = session.get_json_attrs(with_user_info=False)
244
+ primary_key = tuple(sorted(attrs.items()))
245
+ secondary_key = session.truncated_started # (, session.did)
246
+ states = self.pending_aggregates.setdefault(primary_key, {})
247
+ state = states.setdefault(secondary_key, {})
248
+
249
+ if "started" not in state:
250
+ state["started"] = format_timestamp(session.truncated_started)
251
+ # if session.did is not None:
252
+ # state["did"] = session.did
253
+ if session.status == "crashed":
254
+ state["crashed"] = state.get("crashed", 0) + 1
255
+ elif session.status == "abnormal":
256
+ state["abnormal"] = state.get("abnormal", 0) + 1
257
+ elif session.errors > 0:
258
+ state["errored"] = state.get("errored", 0) + 1
259
+ else:
260
+ state["exited"] = state.get("exited", 0) + 1
261
+
262
+ def add_session(
263
+ self,
264
+ session, # type: Session
265
+ ):
266
+ # type: (...) -> None
267
+ if session.session_mode == "request":
268
+ self.add_aggregate_session(session)
269
+ else:
270
+ self.pending_sessions.append(session.to_json())
271
+ self._ensure_running()
272
+
273
+ def kill(self):
274
+ # type: (...) -> None
275
+ self.__shutdown_requested.set()
@@ -0,0 +1,242 @@
1
+ import io
2
+ import logging
3
+ import os
4
+ import urllib.parse
5
+ import urllib.request
6
+ import urllib.error
7
+ import urllib3
8
+ import sys
9
+
10
+ from itertools import chain, product
11
+
12
+ from typing import TYPE_CHECKING
13
+
14
+ if TYPE_CHECKING:
15
+ from typing import Any
16
+ from typing import Callable
17
+ from typing import Dict
18
+ from typing import Optional
19
+ from typing import Self
20
+
21
+ from sentry_sdk.utils import (
22
+ logger as sentry_logger,
23
+ env_to_bool,
24
+ capture_internal_exceptions,
25
+ )
26
+ from sentry_sdk.envelope import Envelope
27
+
28
+
29
+ logger = logging.getLogger("spotlight")
30
+
31
+
32
+ DEFAULT_SPOTLIGHT_URL = "http://localhost:8969/stream"
33
+ DJANGO_SPOTLIGHT_MIDDLEWARE_PATH = "sentry_sdk.spotlight.SpotlightMiddleware"
34
+
35
+
36
+ class SpotlightClient:
37
+ def __init__(self, url):
38
+ # type: (str) -> None
39
+ self.url = url
40
+ self.http = urllib3.PoolManager()
41
+ self.fails = 0
42
+
43
+ def capture_envelope(self, envelope):
44
+ # type: (Envelope) -> None
45
+ body = io.BytesIO()
46
+ envelope.serialize_into(body)
47
+ try:
48
+ req = self.http.request(
49
+ url=self.url,
50
+ body=body.getvalue(),
51
+ method="POST",
52
+ headers={
53
+ "Content-Type": "application/x-sentry-envelope",
54
+ },
55
+ )
56
+ req.close()
57
+ self.fails = 0
58
+ except Exception as e:
59
+ if self.fails < 2:
60
+ sentry_logger.warning(str(e))
61
+ self.fails += 1
62
+ elif self.fails == 2:
63
+ self.fails += 1
64
+ sentry_logger.warning(
65
+ "Looks like Spotlight is not running, will keep trying to send events but will not log errors."
66
+ )
67
+ # omitting self.fails += 1 in the `else:` case intentionally
68
+ # to avoid overflowing the variable if Spotlight never becomes reachable
69
+
70
+
71
+ try:
72
+ from django.utils.deprecation import MiddlewareMixin
73
+ from django.http import HttpResponseServerError, HttpResponse, HttpRequest
74
+ from django.conf import settings
75
+
76
+ SPOTLIGHT_JS_ENTRY_PATH = "/assets/main.js"
77
+ SPOTLIGHT_JS_SNIPPET_PATTERN = (
78
+ "<script>window.__spotlight = {{ initOptions: {{ sidecarUrl: '{spotlight_url}', fullPage: false }} }};</script>\n"
79
+ '<script type="module" crossorigin src="{spotlight_js_url}"></script>\n'
80
+ )
81
+ SPOTLIGHT_ERROR_PAGE_SNIPPET = (
82
+ '<html><base href="{spotlight_url}">\n'
83
+ '<script>window.__spotlight = {{ initOptions: {{ fullPage: true, startFrom: "/errors/{event_id}" }}}};</script>\n'
84
+ )
85
+ CHARSET_PREFIX = "charset="
86
+ BODY_TAG_NAME = "body"
87
+ BODY_CLOSE_TAG_POSSIBILITIES = tuple(
88
+ "</{}>".format("".join(chars))
89
+ for chars in product(*zip(BODY_TAG_NAME.upper(), BODY_TAG_NAME.lower()))
90
+ )
91
+
92
+ class SpotlightMiddleware(MiddlewareMixin): # type: ignore[misc]
93
+ _spotlight_script = None # type: Optional[str]
94
+ _spotlight_url = None # type: Optional[str]
95
+
96
+ def __init__(self, get_response):
97
+ # type: (Self, Callable[..., HttpResponse]) -> None
98
+ super().__init__(get_response)
99
+
100
+ import sentry_sdk.api
101
+
102
+ self.sentry_sdk = sentry_sdk.api
103
+
104
+ spotlight_client = self.sentry_sdk.get_client().spotlight
105
+ if spotlight_client is None:
106
+ sentry_logger.warning(
107
+ "Cannot find Spotlight client from SpotlightMiddleware, disabling the middleware."
108
+ )
109
+ return None
110
+ # Spotlight URL has a trailing `/stream` part at the end so split it off
111
+ self._spotlight_url = urllib.parse.urljoin(spotlight_client.url, "../")
112
+
113
+ @property
114
+ def spotlight_script(self):
115
+ # type: (Self) -> Optional[str]
116
+ if self._spotlight_url is not None and self._spotlight_script is None:
117
+ try:
118
+ spotlight_js_url = urllib.parse.urljoin(
119
+ self._spotlight_url, SPOTLIGHT_JS_ENTRY_PATH
120
+ )
121
+ req = urllib.request.Request(
122
+ spotlight_js_url,
123
+ method="HEAD",
124
+ )
125
+ urllib.request.urlopen(req)
126
+ self._spotlight_script = SPOTLIGHT_JS_SNIPPET_PATTERN.format(
127
+ spotlight_url=self._spotlight_url,
128
+ spotlight_js_url=spotlight_js_url,
129
+ )
130
+ except urllib.error.URLError as err:
131
+ sentry_logger.debug(
132
+ "Cannot get Spotlight JS to inject at %s. SpotlightMiddleware will not be very useful.",
133
+ spotlight_js_url,
134
+ exc_info=err,
135
+ )
136
+
137
+ return self._spotlight_script
138
+
139
+ def process_response(self, _request, response):
140
+ # type: (Self, HttpRequest, HttpResponse) -> Optional[HttpResponse]
141
+ content_type_header = tuple(
142
+ p.strip()
143
+ for p in response.headers.get("Content-Type", "").lower().split(";")
144
+ )
145
+ content_type = content_type_header[0]
146
+ if len(content_type_header) > 1 and content_type_header[1].startswith(
147
+ CHARSET_PREFIX
148
+ ):
149
+ encoding = content_type_header[1][len(CHARSET_PREFIX) :]
150
+ else:
151
+ encoding = "utf-8"
152
+
153
+ if (
154
+ self.spotlight_script is not None
155
+ and not response.streaming
156
+ and content_type == "text/html"
157
+ ):
158
+ content_length = len(response.content)
159
+ injection = self.spotlight_script.encode(encoding)
160
+ injection_site = next(
161
+ (
162
+ idx
163
+ for idx in (
164
+ response.content.rfind(body_variant.encode(encoding))
165
+ for body_variant in BODY_CLOSE_TAG_POSSIBILITIES
166
+ )
167
+ if idx > -1
168
+ ),
169
+ content_length,
170
+ )
171
+
172
+ # This approach works even when we don't have a `</body>` tag
173
+ response.content = (
174
+ response.content[:injection_site]
175
+ + injection
176
+ + response.content[injection_site:]
177
+ )
178
+
179
+ if response.has_header("Content-Length"):
180
+ response.headers["Content-Length"] = content_length + len(injection)
181
+
182
+ return response
183
+
184
+ def process_exception(self, _request, exception):
185
+ # type: (Self, HttpRequest, Exception) -> Optional[HttpResponseServerError]
186
+ if not settings.DEBUG or not self._spotlight_url:
187
+ return None
188
+
189
+ try:
190
+ spotlight = (
191
+ urllib.request.urlopen(self._spotlight_url).read().decode("utf-8")
192
+ )
193
+ except urllib.error.URLError:
194
+ return None
195
+ else:
196
+ event_id = self.sentry_sdk.capture_exception(exception)
197
+ return HttpResponseServerError(
198
+ spotlight.replace(
199
+ "<html>",
200
+ SPOTLIGHT_ERROR_PAGE_SNIPPET.format(
201
+ spotlight_url=self._spotlight_url, event_id=event_id
202
+ ),
203
+ )
204
+ )
205
+
206
+ except ImportError:
207
+ settings = None
208
+
209
+
210
+ def setup_spotlight(options):
211
+ # type: (Dict[str, Any]) -> Optional[SpotlightClient]
212
+ _handler = logging.StreamHandler(sys.stderr)
213
+ _handler.setFormatter(logging.Formatter(" [spotlight] %(levelname)s: %(message)s"))
214
+ logger.addHandler(_handler)
215
+ logger.setLevel(logging.INFO)
216
+
217
+ url = options.get("spotlight")
218
+
219
+ if url is True:
220
+ url = DEFAULT_SPOTLIGHT_URL
221
+
222
+ if not isinstance(url, str):
223
+ return None
224
+
225
+ with capture_internal_exceptions():
226
+ if (
227
+ settings is not None
228
+ and settings.DEBUG
229
+ and env_to_bool(os.environ.get("SENTRY_SPOTLIGHT_ON_ERROR", "1"))
230
+ and env_to_bool(os.environ.get("SENTRY_SPOTLIGHT_MIDDLEWARE", "1"))
231
+ ):
232
+ middleware = settings.MIDDLEWARE
233
+ if DJANGO_SPOTLIGHT_MIDDLEWARE_PATH not in middleware:
234
+ settings.MIDDLEWARE = type(middleware)(
235
+ chain(middleware, (DJANGO_SPOTLIGHT_MIDDLEWARE_PATH,))
236
+ )
237
+ logger.info("Enabled Spotlight integration for Django")
238
+
239
+ client = SpotlightClient(url)
240
+ logger.info("Enabled Spotlight using sidecar at %s", url)
241
+
242
+ return client