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.
@@ -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