justanalytics-python 0.1.0__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.
- justanalytics/__init__.py +429 -0
- justanalytics/client.py +665 -0
- justanalytics/context.py +143 -0
- justanalytics/integrations/__init__.py +11 -0
- justanalytics/integrations/django.py +157 -0
- justanalytics/integrations/fastapi.py +197 -0
- justanalytics/integrations/flask.py +203 -0
- justanalytics/integrations/logging.py +175 -0
- justanalytics/integrations/requests.py +149 -0
- justanalytics/integrations/urllib3.py +146 -0
- justanalytics/span.py +281 -0
- justanalytics/trace_context.py +124 -0
- justanalytics/transport.py +430 -0
- justanalytics/types.py +214 -0
- justanalytics_python-0.1.0.dist-info/METADATA +173 -0
- justanalytics_python-0.1.0.dist-info/RECORD +17 -0
- justanalytics_python-0.1.0.dist-info/WHEEL +4 -0
justanalytics/client.py
ADDED
|
@@ -0,0 +1,665 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Core Client class managing SDK lifecycle, span creation, error capture,
|
|
3
|
+
context propagation, and batched transport.
|
|
4
|
+
|
|
5
|
+
The client is the central orchestrator:
|
|
6
|
+
- Validates and stores configuration from ``init()``
|
|
7
|
+
- Creates spans via ``start_span()`` (context manager / decorator)
|
|
8
|
+
- Captures errors via ``capture_exception()`` and ``capture_message()``
|
|
9
|
+
- Manages the BatchTransport for periodic flushing
|
|
10
|
+
- Provides ``set_user()``, ``set_tag()`` context APIs
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import logging
|
|
16
|
+
import os
|
|
17
|
+
import platform
|
|
18
|
+
import sys
|
|
19
|
+
import traceback
|
|
20
|
+
from contextlib import contextmanager
|
|
21
|
+
from datetime import datetime, timezone
|
|
22
|
+
from functools import wraps
|
|
23
|
+
from typing import (
|
|
24
|
+
Any,
|
|
25
|
+
Callable,
|
|
26
|
+
Dict,
|
|
27
|
+
Generator,
|
|
28
|
+
List,
|
|
29
|
+
Optional,
|
|
30
|
+
TypeVar,
|
|
31
|
+
Union,
|
|
32
|
+
overload,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
from . import context as ctx
|
|
36
|
+
from .span import Span, _generate_span_id, _generate_trace_id
|
|
37
|
+
from .transport import BatchTransport
|
|
38
|
+
from .types import (
|
|
39
|
+
ErrorLevel,
|
|
40
|
+
ErrorPayload,
|
|
41
|
+
LogLevel,
|
|
42
|
+
LogPayload,
|
|
43
|
+
MechanismType,
|
|
44
|
+
MetricPayload,
|
|
45
|
+
SpanKind,
|
|
46
|
+
SpanStatus,
|
|
47
|
+
UserContext,
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
sdk_logger = logging.getLogger("justanalytics")
|
|
51
|
+
|
|
52
|
+
# Default JustAnalytics server URL
|
|
53
|
+
DEFAULT_SERVER_URL = "https://justanalytics.up.railway.app"
|
|
54
|
+
|
|
55
|
+
F = TypeVar("F", bound=Callable[..., Any])
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class JustAnalyticsClient:
|
|
59
|
+
"""
|
|
60
|
+
Core SDK client managing lifecycle, instrumentation, and transport.
|
|
61
|
+
|
|
62
|
+
Typically used as a singleton via the module-level functions in
|
|
63
|
+
``justanalytics.__init__``.
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
def __init__(self) -> None:
|
|
67
|
+
self._initialized: bool = False
|
|
68
|
+
self._enabled: bool = True
|
|
69
|
+
self._service_name: str = ""
|
|
70
|
+
self._environment: Optional[str] = None
|
|
71
|
+
self._release: Optional[str] = None
|
|
72
|
+
self._debug: bool = False
|
|
73
|
+
self._transport: Optional[BatchTransport] = None
|
|
74
|
+
self._site_id: str = ""
|
|
75
|
+
|
|
76
|
+
def init(
|
|
77
|
+
self,
|
|
78
|
+
site_id: str,
|
|
79
|
+
api_key: str,
|
|
80
|
+
service_name: str,
|
|
81
|
+
environment: Optional[str] = None,
|
|
82
|
+
release: Optional[str] = None,
|
|
83
|
+
server_url: Optional[str] = None,
|
|
84
|
+
debug: bool = False,
|
|
85
|
+
flush_interval_s: float = 2.0,
|
|
86
|
+
max_batch_size: int = 100,
|
|
87
|
+
enabled: bool = True,
|
|
88
|
+
) -> None:
|
|
89
|
+
"""
|
|
90
|
+
Initialize the JustAnalytics SDK.
|
|
91
|
+
|
|
92
|
+
Must be called before any other SDK method. Calling ``init()`` more
|
|
93
|
+
than once logs a warning and is ignored.
|
|
94
|
+
|
|
95
|
+
Args:
|
|
96
|
+
site_id: Site ID from the JustAnalytics dashboard.
|
|
97
|
+
api_key: API key (format: ``ja_sk_...``).
|
|
98
|
+
service_name: Service name (e.g., "api-server", "worker").
|
|
99
|
+
environment: Deployment environment (e.g., "production", "staging").
|
|
100
|
+
release: Release/version string (e.g., "1.2.3", git SHA).
|
|
101
|
+
server_url: Base URL of JustAnalytics server (default: production URL).
|
|
102
|
+
debug: Enable debug logging to the ``justanalytics`` logger.
|
|
103
|
+
flush_interval_s: Flush interval in seconds (default: 2.0).
|
|
104
|
+
max_batch_size: Max items per batch before immediate flush (default: 100).
|
|
105
|
+
enabled: Enable/disable the SDK (default: True). When False, all methods are no-ops.
|
|
106
|
+
|
|
107
|
+
Raises:
|
|
108
|
+
ValueError: If required fields (site_id, api_key, service_name) are missing.
|
|
109
|
+
"""
|
|
110
|
+
if self._initialized:
|
|
111
|
+
sdk_logger.warning("init() already called. Ignoring.")
|
|
112
|
+
return
|
|
113
|
+
|
|
114
|
+
if not site_id:
|
|
115
|
+
raise ValueError(
|
|
116
|
+
"init() requires 'site_id'. Get it from your JustAnalytics dashboard."
|
|
117
|
+
)
|
|
118
|
+
if not api_key:
|
|
119
|
+
raise ValueError(
|
|
120
|
+
"init() requires 'api_key'. Create one in your JustAnalytics site settings."
|
|
121
|
+
)
|
|
122
|
+
if not service_name:
|
|
123
|
+
raise ValueError(
|
|
124
|
+
"init() requires 'service_name' (e.g., 'api-server', 'worker')."
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
self._enabled = enabled
|
|
128
|
+
self._service_name = service_name
|
|
129
|
+
self._environment = environment
|
|
130
|
+
self._release = release
|
|
131
|
+
self._debug = debug
|
|
132
|
+
self._site_id = site_id
|
|
133
|
+
|
|
134
|
+
resolved_url = (
|
|
135
|
+
server_url
|
|
136
|
+
or os.environ.get("JUSTANALYTICS_URL")
|
|
137
|
+
or DEFAULT_SERVER_URL
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
if self._debug:
|
|
141
|
+
key_prefix = api_key[:10] if len(api_key) >= 10 else api_key
|
|
142
|
+
sdk_logger.debug(
|
|
143
|
+
"Initializing SDK: service=%s, server=%s, apiKey=%s..., enabled=%s",
|
|
144
|
+
service_name,
|
|
145
|
+
resolved_url,
|
|
146
|
+
key_prefix,
|
|
147
|
+
enabled,
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
if self._enabled:
|
|
151
|
+
self._transport = BatchTransport(
|
|
152
|
+
server_url=resolved_url,
|
|
153
|
+
api_key=api_key,
|
|
154
|
+
site_id=site_id,
|
|
155
|
+
flush_interval_s=flush_interval_s,
|
|
156
|
+
max_batch_size=max_batch_size,
|
|
157
|
+
debug=debug,
|
|
158
|
+
)
|
|
159
|
+
self._transport.start()
|
|
160
|
+
|
|
161
|
+
self._initialized = True
|
|
162
|
+
|
|
163
|
+
@property
|
|
164
|
+
def is_initialized(self) -> bool:
|
|
165
|
+
"""Whether ``init()`` has been called successfully."""
|
|
166
|
+
return self._initialized
|
|
167
|
+
|
|
168
|
+
# --- Span Creation ---
|
|
169
|
+
|
|
170
|
+
@contextmanager
|
|
171
|
+
def start_span(
|
|
172
|
+
self,
|
|
173
|
+
name: str,
|
|
174
|
+
op: Optional[str] = None,
|
|
175
|
+
kind: SpanKind = SpanKind.INTERNAL,
|
|
176
|
+
attributes: Optional[Dict[str, Any]] = None,
|
|
177
|
+
) -> Generator[Span, None, None]:
|
|
178
|
+
"""
|
|
179
|
+
Create a span as a context manager.
|
|
180
|
+
|
|
181
|
+
The span is automatically ended when the ``with`` block exits.
|
|
182
|
+
If an exception occurs, the span status is set to ``error``.
|
|
183
|
+
|
|
184
|
+
Nested ``start_span()`` calls automatically form parent-child relationships
|
|
185
|
+
via ``contextvars``.
|
|
186
|
+
|
|
187
|
+
Args:
|
|
188
|
+
name: Operation name for the span.
|
|
189
|
+
op: Optional operation type (alias for backwards compat; sets attribute).
|
|
190
|
+
kind: Span kind (default: INTERNAL).
|
|
191
|
+
attributes: Initial attributes to set on the span.
|
|
192
|
+
|
|
193
|
+
Yields:
|
|
194
|
+
The created Span instance.
|
|
195
|
+
|
|
196
|
+
Example::
|
|
197
|
+
|
|
198
|
+
with justanalytics.start_span("process-order") as span:
|
|
199
|
+
span.set_attribute("order.id", "12345")
|
|
200
|
+
result = process_order()
|
|
201
|
+
"""
|
|
202
|
+
if not self._initialized or not self._enabled:
|
|
203
|
+
# Yield a dummy span that does nothing
|
|
204
|
+
dummy = Span(
|
|
205
|
+
operation_name=name,
|
|
206
|
+
service_name=self._service_name or "unknown",
|
|
207
|
+
kind=kind,
|
|
208
|
+
)
|
|
209
|
+
yield dummy
|
|
210
|
+
return
|
|
211
|
+
|
|
212
|
+
# Determine parent and trace ID from current context
|
|
213
|
+
parent_span = ctx.get_active_span()
|
|
214
|
+
if parent_span:
|
|
215
|
+
trace_id = parent_span.trace_id
|
|
216
|
+
parent_span_id = parent_span.id
|
|
217
|
+
else:
|
|
218
|
+
trace_id = _generate_trace_id()
|
|
219
|
+
parent_span_id = None
|
|
220
|
+
|
|
221
|
+
# Merge context tags into initial attributes
|
|
222
|
+
context_tags = ctx.get_tags()
|
|
223
|
+
initial_attrs: Dict[str, Any] = {**context_tags}
|
|
224
|
+
if self._environment:
|
|
225
|
+
initial_attrs["environment"] = self._environment
|
|
226
|
+
if self._release:
|
|
227
|
+
initial_attrs["release"] = self._release
|
|
228
|
+
if op:
|
|
229
|
+
initial_attrs["op"] = op
|
|
230
|
+
if attributes:
|
|
231
|
+
initial_attrs.update(attributes)
|
|
232
|
+
|
|
233
|
+
span = Span(
|
|
234
|
+
operation_name=name,
|
|
235
|
+
service_name=self._service_name,
|
|
236
|
+
kind=kind,
|
|
237
|
+
trace_id=trace_id,
|
|
238
|
+
parent_span_id=parent_span_id,
|
|
239
|
+
attributes=initial_attrs,
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
# Use the span as context manager (sets contextvars)
|
|
243
|
+
with span:
|
|
244
|
+
yield span
|
|
245
|
+
|
|
246
|
+
# Enqueue the ended span
|
|
247
|
+
if self._transport:
|
|
248
|
+
self._transport.enqueue_span(span.to_dict())
|
|
249
|
+
|
|
250
|
+
def span_decorator(
|
|
251
|
+
self,
|
|
252
|
+
name: Optional[str] = None,
|
|
253
|
+
op: Optional[str] = None,
|
|
254
|
+
kind: SpanKind = SpanKind.INTERNAL,
|
|
255
|
+
attributes: Optional[Dict[str, Any]] = None,
|
|
256
|
+
) -> Callable[[F], F]:
|
|
257
|
+
"""
|
|
258
|
+
Decorator that wraps a function in a span.
|
|
259
|
+
|
|
260
|
+
Args:
|
|
261
|
+
name: Span name (defaults to the function's qualified name).
|
|
262
|
+
op: Optional operation type.
|
|
263
|
+
kind: Span kind.
|
|
264
|
+
attributes: Initial span attributes.
|
|
265
|
+
|
|
266
|
+
Returns:
|
|
267
|
+
A decorator that wraps the function in a span.
|
|
268
|
+
|
|
269
|
+
Example::
|
|
270
|
+
|
|
271
|
+
@justanalytics.span_decorator(op="db.query")
|
|
272
|
+
def get_user(user_id: str):
|
|
273
|
+
return db.query(...)
|
|
274
|
+
"""
|
|
275
|
+
|
|
276
|
+
def decorator(fn: F) -> F:
|
|
277
|
+
span_name = name or fn.__qualname__
|
|
278
|
+
|
|
279
|
+
@wraps(fn)
|
|
280
|
+
def wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
281
|
+
with self.start_span(span_name, op=op, kind=kind, attributes=attributes):
|
|
282
|
+
return fn(*args, **kwargs)
|
|
283
|
+
|
|
284
|
+
return wrapper # type: ignore[return-value]
|
|
285
|
+
|
|
286
|
+
return decorator
|
|
287
|
+
|
|
288
|
+
# --- Error Capture ---
|
|
289
|
+
|
|
290
|
+
def capture_exception(
|
|
291
|
+
self,
|
|
292
|
+
error: BaseException,
|
|
293
|
+
tags: Optional[Dict[str, str]] = None,
|
|
294
|
+
extra: Optional[Dict[str, Any]] = None,
|
|
295
|
+
user: Optional[UserContext] = None,
|
|
296
|
+
level: ErrorLevel = ErrorLevel.ERROR,
|
|
297
|
+
fingerprint: Optional[List[str]] = None,
|
|
298
|
+
) -> str:
|
|
299
|
+
"""
|
|
300
|
+
Capture an exception and send it to JustAnalytics.
|
|
301
|
+
|
|
302
|
+
Automatically attaches the current trace_id, span_id, user context,
|
|
303
|
+
and tags from the contextvars context.
|
|
304
|
+
|
|
305
|
+
Args:
|
|
306
|
+
error: The exception to capture.
|
|
307
|
+
tags: Additional tags (merged with context tags).
|
|
308
|
+
extra: Arbitrary extra data.
|
|
309
|
+
user: User context (overrides context user).
|
|
310
|
+
level: Severity level (default: ERROR).
|
|
311
|
+
fingerprint: Custom fingerprint for grouping.
|
|
312
|
+
|
|
313
|
+
Returns:
|
|
314
|
+
A unique event_id for correlation, or empty string if SDK is disabled.
|
|
315
|
+
|
|
316
|
+
Example::
|
|
317
|
+
|
|
318
|
+
try:
|
|
319
|
+
risky_operation()
|
|
320
|
+
except Exception as e:
|
|
321
|
+
justanalytics.capture_exception(e, tags={"module": "payments"})
|
|
322
|
+
"""
|
|
323
|
+
if not self._initialized or not self._enabled:
|
|
324
|
+
return ""
|
|
325
|
+
try:
|
|
326
|
+
payload = self._build_error_payload(
|
|
327
|
+
error=error,
|
|
328
|
+
tags=tags,
|
|
329
|
+
extra=extra,
|
|
330
|
+
user=user,
|
|
331
|
+
level=level,
|
|
332
|
+
fingerprint=fingerprint,
|
|
333
|
+
mechanism_type=MechanismType.MANUAL,
|
|
334
|
+
handled=True,
|
|
335
|
+
)
|
|
336
|
+
if self._transport:
|
|
337
|
+
self._transport.enqueue_error(payload.to_dict())
|
|
338
|
+
return payload.event_id
|
|
339
|
+
except Exception:
|
|
340
|
+
if self._debug:
|
|
341
|
+
sdk_logger.debug("capture_exception() internal error", exc_info=True)
|
|
342
|
+
return ""
|
|
343
|
+
|
|
344
|
+
def capture_message(
|
|
345
|
+
self,
|
|
346
|
+
message: str,
|
|
347
|
+
level: ErrorLevel = ErrorLevel.INFO,
|
|
348
|
+
tags: Optional[Dict[str, str]] = None,
|
|
349
|
+
extra: Optional[Dict[str, Any]] = None,
|
|
350
|
+
) -> str:
|
|
351
|
+
"""
|
|
352
|
+
Capture a message and send it to JustAnalytics.
|
|
353
|
+
|
|
354
|
+
Args:
|
|
355
|
+
message: The message string.
|
|
356
|
+
level: Severity level (default: INFO).
|
|
357
|
+
tags: Additional tags (merged with context tags).
|
|
358
|
+
extra: Arbitrary extra data.
|
|
359
|
+
|
|
360
|
+
Returns:
|
|
361
|
+
A unique event_id for correlation, or empty string if SDK is disabled.
|
|
362
|
+
|
|
363
|
+
Example::
|
|
364
|
+
|
|
365
|
+
justanalytics.capture_message(
|
|
366
|
+
"User exceeded rate limit",
|
|
367
|
+
level=ErrorLevel.WARNING,
|
|
368
|
+
tags={"userId": user.id},
|
|
369
|
+
)
|
|
370
|
+
"""
|
|
371
|
+
if not self._initialized or not self._enabled:
|
|
372
|
+
return ""
|
|
373
|
+
try:
|
|
374
|
+
# Create a synthetic error-like payload for the message
|
|
375
|
+
payload = self._build_error_payload(
|
|
376
|
+
error=message,
|
|
377
|
+
tags=tags,
|
|
378
|
+
extra=extra,
|
|
379
|
+
user=None,
|
|
380
|
+
level=level,
|
|
381
|
+
fingerprint=None,
|
|
382
|
+
mechanism_type=MechanismType.MANUAL,
|
|
383
|
+
handled=True,
|
|
384
|
+
)
|
|
385
|
+
# Override type to 'Message' for capture_message
|
|
386
|
+
payload.error["type"] = "Message"
|
|
387
|
+
if self._transport:
|
|
388
|
+
self._transport.enqueue_error(payload.to_dict())
|
|
389
|
+
return payload.event_id
|
|
390
|
+
except Exception:
|
|
391
|
+
if self._debug:
|
|
392
|
+
sdk_logger.debug("capture_message() internal error", exc_info=True)
|
|
393
|
+
return ""
|
|
394
|
+
|
|
395
|
+
# --- Context ---
|
|
396
|
+
|
|
397
|
+
def set_user(
|
|
398
|
+
self,
|
|
399
|
+
id: Optional[str] = None,
|
|
400
|
+
email: Optional[str] = None,
|
|
401
|
+
username: Optional[str] = None,
|
|
402
|
+
) -> None:
|
|
403
|
+
"""
|
|
404
|
+
Set user context for the current execution scope.
|
|
405
|
+
|
|
406
|
+
User information is attached to all spans, errors, and logs
|
|
407
|
+
created within the current contextvars scope.
|
|
408
|
+
|
|
409
|
+
Args:
|
|
410
|
+
id: User ID.
|
|
411
|
+
email: User email.
|
|
412
|
+
username: Username.
|
|
413
|
+
|
|
414
|
+
Example::
|
|
415
|
+
|
|
416
|
+
justanalytics.set_user(id="user-123", email="alice@example.com")
|
|
417
|
+
"""
|
|
418
|
+
if not self._initialized or not self._enabled:
|
|
419
|
+
return
|
|
420
|
+
ctx.set_user(UserContext(id=id, email=email, username=username))
|
|
421
|
+
|
|
422
|
+
def set_tag(self, key: str, value: str) -> None:
|
|
423
|
+
"""
|
|
424
|
+
Set a tag for the current execution scope.
|
|
425
|
+
|
|
426
|
+
Tags are attached as attributes on all spans created within the
|
|
427
|
+
current contextvars scope.
|
|
428
|
+
|
|
429
|
+
Args:
|
|
430
|
+
key: Tag key.
|
|
431
|
+
value: Tag value.
|
|
432
|
+
|
|
433
|
+
Example::
|
|
434
|
+
|
|
435
|
+
justanalytics.set_tag("feature", "checkout")
|
|
436
|
+
justanalytics.set_tag("region", "us-east-1")
|
|
437
|
+
"""
|
|
438
|
+
if not self._initialized or not self._enabled:
|
|
439
|
+
return
|
|
440
|
+
ctx.set_tag(key, value)
|
|
441
|
+
|
|
442
|
+
# --- Logging ---
|
|
443
|
+
|
|
444
|
+
def log(
|
|
445
|
+
self,
|
|
446
|
+
level: LogLevel,
|
|
447
|
+
message: str,
|
|
448
|
+
attributes: Optional[Dict[str, Any]] = None,
|
|
449
|
+
) -> None:
|
|
450
|
+
"""
|
|
451
|
+
Send a structured log entry to JustAnalytics.
|
|
452
|
+
|
|
453
|
+
Automatically attaches trace_id and span_id from the current context.
|
|
454
|
+
|
|
455
|
+
Args:
|
|
456
|
+
level: Log severity level.
|
|
457
|
+
message: Log message.
|
|
458
|
+
attributes: Optional key-value metadata.
|
|
459
|
+
"""
|
|
460
|
+
if not self._initialized or not self._enabled or not self._transport:
|
|
461
|
+
return
|
|
462
|
+
try:
|
|
463
|
+
active_span = ctx.get_active_span()
|
|
464
|
+
payload = LogPayload(
|
|
465
|
+
level=level.value,
|
|
466
|
+
message=message,
|
|
467
|
+
service_name=self._service_name,
|
|
468
|
+
timestamp=datetime.now(timezone.utc).isoformat(),
|
|
469
|
+
trace_id=active_span.trace_id if active_span else None,
|
|
470
|
+
span_id=active_span.id if active_span else None,
|
|
471
|
+
attributes=attributes or {},
|
|
472
|
+
)
|
|
473
|
+
self._transport.enqueue_log(payload.to_dict())
|
|
474
|
+
except Exception:
|
|
475
|
+
if self._debug:
|
|
476
|
+
sdk_logger.debug("log() internal error", exc_info=True)
|
|
477
|
+
|
|
478
|
+
# --- Metrics ---
|
|
479
|
+
|
|
480
|
+
def record_metric(
|
|
481
|
+
self,
|
|
482
|
+
metric_name: str,
|
|
483
|
+
value: float,
|
|
484
|
+
tags: Optional[Dict[str, Any]] = None,
|
|
485
|
+
) -> None:
|
|
486
|
+
"""
|
|
487
|
+
Record a custom infrastructure metric.
|
|
488
|
+
|
|
489
|
+
Args:
|
|
490
|
+
metric_name: Metric name using dot notation (e.g., "custom.queue_size").
|
|
491
|
+
value: Numeric value.
|
|
492
|
+
tags: Optional additional tags.
|
|
493
|
+
|
|
494
|
+
Example::
|
|
495
|
+
|
|
496
|
+
justanalytics.record_metric("custom.queue_size", 42, {"queue": "emails"})
|
|
497
|
+
"""
|
|
498
|
+
if not self._initialized or not self._enabled or not self._transport:
|
|
499
|
+
return
|
|
500
|
+
try:
|
|
501
|
+
metric_tags: Dict[str, Any] = {
|
|
502
|
+
"hostname": platform.node(),
|
|
503
|
+
}
|
|
504
|
+
if self._environment:
|
|
505
|
+
metric_tags["environment"] = self._environment
|
|
506
|
+
if tags:
|
|
507
|
+
metric_tags.update(tags)
|
|
508
|
+
payload = MetricPayload(
|
|
509
|
+
metric_name=metric_name,
|
|
510
|
+
value=value,
|
|
511
|
+
service_name=self._service_name,
|
|
512
|
+
timestamp=datetime.now(timezone.utc).isoformat(),
|
|
513
|
+
tags=metric_tags,
|
|
514
|
+
)
|
|
515
|
+
self._transport.enqueue_metric(payload.to_dict())
|
|
516
|
+
except Exception:
|
|
517
|
+
if self._debug:
|
|
518
|
+
sdk_logger.debug("record_metric() internal error", exc_info=True)
|
|
519
|
+
|
|
520
|
+
# --- Lifecycle ---
|
|
521
|
+
|
|
522
|
+
def flush(self) -> None:
|
|
523
|
+
"""
|
|
524
|
+
Manually flush all pending data to the server.
|
|
525
|
+
|
|
526
|
+
Useful before a serverless function completes or in tests.
|
|
527
|
+
"""
|
|
528
|
+
if self._transport:
|
|
529
|
+
self._transport.flush()
|
|
530
|
+
|
|
531
|
+
def close(self) -> None:
|
|
532
|
+
"""
|
|
533
|
+
Shut down the SDK: flush remaining data, stop timers.
|
|
534
|
+
|
|
535
|
+
After calling ``close()``, the SDK cannot be used again until
|
|
536
|
+
``init()`` is called.
|
|
537
|
+
"""
|
|
538
|
+
if not self._initialized:
|
|
539
|
+
return
|
|
540
|
+
|
|
541
|
+
if self._debug:
|
|
542
|
+
sdk_logger.debug("Closing SDK...")
|
|
543
|
+
|
|
544
|
+
if self._transport:
|
|
545
|
+
self._transport.flush()
|
|
546
|
+
self._transport.stop()
|
|
547
|
+
self._transport = None
|
|
548
|
+
|
|
549
|
+
self._initialized = False
|
|
550
|
+
|
|
551
|
+
if self._debug:
|
|
552
|
+
sdk_logger.debug("SDK closed.")
|
|
553
|
+
|
|
554
|
+
# --- Internal: Error Payload Construction ---
|
|
555
|
+
|
|
556
|
+
def _build_error_payload(
|
|
557
|
+
self,
|
|
558
|
+
error: Union[BaseException, str],
|
|
559
|
+
tags: Optional[Dict[str, str]],
|
|
560
|
+
extra: Optional[Dict[str, Any]],
|
|
561
|
+
user: Optional[UserContext],
|
|
562
|
+
level: ErrorLevel,
|
|
563
|
+
fingerprint: Optional[List[str]],
|
|
564
|
+
mechanism_type: MechanismType,
|
|
565
|
+
handled: bool,
|
|
566
|
+
) -> ErrorPayload:
|
|
567
|
+
"""Build a complete error payload from an error and options."""
|
|
568
|
+
# Serialize the error
|
|
569
|
+
if isinstance(error, BaseException):
|
|
570
|
+
error_dict = {
|
|
571
|
+
"message": str(error) or type(error).__name__,
|
|
572
|
+
"type": type(error).__name__,
|
|
573
|
+
"stack": "".join(traceback.format_exception(type(error), error, error.__traceback__)),
|
|
574
|
+
}
|
|
575
|
+
# Extract cause chain
|
|
576
|
+
cause_chain = self._extract_cause_chain(error, max_depth=3)
|
|
577
|
+
elif isinstance(error, str):
|
|
578
|
+
error_dict = {
|
|
579
|
+
"message": error,
|
|
580
|
+
"type": "Error",
|
|
581
|
+
"stack": None,
|
|
582
|
+
}
|
|
583
|
+
cause_chain = None
|
|
584
|
+
else:
|
|
585
|
+
error_dict = {
|
|
586
|
+
"message": str(error),
|
|
587
|
+
"type": "Error",
|
|
588
|
+
"stack": None,
|
|
589
|
+
}
|
|
590
|
+
cause_chain = None
|
|
591
|
+
|
|
592
|
+
# Get trace context
|
|
593
|
+
active_span = ctx.get_active_span()
|
|
594
|
+
trace_id = active_span.trace_id if active_span else None
|
|
595
|
+
span_id = active_span.id if active_span else None
|
|
596
|
+
|
|
597
|
+
# Get user context (parameter overrides context)
|
|
598
|
+
resolved_user = user or ctx.get_user()
|
|
599
|
+
user_dict = resolved_user.to_dict() if resolved_user else None
|
|
600
|
+
|
|
601
|
+
# Merge context tags with option tags
|
|
602
|
+
context_tags = ctx.get_tags()
|
|
603
|
+
merged_tags = {**context_tags, **(tags or {})}
|
|
604
|
+
|
|
605
|
+
# Merge extra with cause chain
|
|
606
|
+
merged_extra: Dict[str, Any] = dict(extra) if extra else {}
|
|
607
|
+
if cause_chain:
|
|
608
|
+
merged_extra["error.cause"] = cause_chain
|
|
609
|
+
|
|
610
|
+
event_id = os.urandom(12).hex()
|
|
611
|
+
|
|
612
|
+
return ErrorPayload(
|
|
613
|
+
event_id=event_id,
|
|
614
|
+
timestamp=datetime.now(timezone.utc).isoformat(),
|
|
615
|
+
error=error_dict,
|
|
616
|
+
level=level.value,
|
|
617
|
+
mechanism={
|
|
618
|
+
"type": mechanism_type.value,
|
|
619
|
+
"handled": handled,
|
|
620
|
+
},
|
|
621
|
+
context={
|
|
622
|
+
"serviceName": self._service_name,
|
|
623
|
+
"environment": self._environment,
|
|
624
|
+
"release": self._release,
|
|
625
|
+
"runtime": "python",
|
|
626
|
+
"nodeVersion": platform.python_version(),
|
|
627
|
+
},
|
|
628
|
+
trace={"traceId": trace_id, "spanId": span_id} if trace_id else None,
|
|
629
|
+
user=user_dict,
|
|
630
|
+
tags=merged_tags if merged_tags else None,
|
|
631
|
+
extra=merged_extra if merged_extra else None,
|
|
632
|
+
fingerprint=fingerprint,
|
|
633
|
+
)
|
|
634
|
+
|
|
635
|
+
@staticmethod
|
|
636
|
+
def _extract_cause_chain(
|
|
637
|
+
error: BaseException, max_depth: int = 3
|
|
638
|
+
) -> Optional[List[Dict[str, Any]]]:
|
|
639
|
+
"""Extract the __cause__ chain from an exception."""
|
|
640
|
+
chain: List[Dict[str, Any]] = []
|
|
641
|
+
seen: set = set()
|
|
642
|
+
seen.add(id(error))
|
|
643
|
+
current: Optional[BaseException] = error.__cause__
|
|
644
|
+
|
|
645
|
+
for _ in range(max_depth):
|
|
646
|
+
if current is None:
|
|
647
|
+
break
|
|
648
|
+
if id(current) in seen:
|
|
649
|
+
chain.append({"message": "[Circular]", "type": "Circular", "stack": None})
|
|
650
|
+
break
|
|
651
|
+
seen.add(id(current))
|
|
652
|
+
chain.append(
|
|
653
|
+
{
|
|
654
|
+
"message": str(current),
|
|
655
|
+
"type": type(current).__name__,
|
|
656
|
+
"stack": "".join(
|
|
657
|
+
traceback.format_exception(
|
|
658
|
+
type(current), current, current.__traceback__
|
|
659
|
+
)
|
|
660
|
+
),
|
|
661
|
+
}
|
|
662
|
+
)
|
|
663
|
+
current = current.__cause__
|
|
664
|
+
|
|
665
|
+
return chain if chain else None
|