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,430 @@
1
+ """
2
+ Batched HTTP transport for sending telemetry data to the JustAnalytics server.
3
+
4
+ Collects spans, errors, logs, and metrics in thread-safe in-memory queues
5
+ and periodically flushes them to their respective ingestion endpoints:
6
+ - Spans: POST /api/ingest/spans (batched array, max 100)
7
+ - Errors: POST /api/ingest/errors (individual POST per error)
8
+ - Logs: POST /api/ingest/logs (batched array, max 200)
9
+ - Metrics: POST /api/ingest/metrics (batched array, max 500)
10
+
11
+ Flush behavior:
12
+ - Timer-based: flushes every ``flush_interval_s`` seconds (default: 2.0)
13
+ - Size-based: flushes immediately when a buffer reaches ``max_batch_size`` (default: 100)
14
+ - Manual: ``flush()`` can be called explicitly
15
+ - Shutdown: ``atexit`` handler ensures pending data is flushed
16
+
17
+ Error handling:
18
+ - HTTP 429 (rate limited): doubles flush interval for 60 seconds, then resets
19
+ - Network errors / non-2xx: logs warning (debug mode), drops the batch
20
+ - Never raises exceptions that could crash the host process
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import atexit
26
+ import json
27
+ import logging
28
+ import threading
29
+ import time
30
+ from collections import deque
31
+ from typing import Any, Deque, Dict, List, Optional
32
+ from urllib.parse import urljoin
33
+
34
+ import urllib3
35
+
36
+ from .types import ErrorPayload, LogPayload, MetricPayload
37
+
38
+ logger = logging.getLogger("justanalytics.transport")
39
+
40
+ # SDK version included in User-Agent header
41
+ _SDK_VERSION = "0.1.0"
42
+
43
+
44
+ class BatchTransport:
45
+ """
46
+ Thread-safe batched HTTP transport for JustAnalytics telemetry data.
47
+
48
+ Uses urllib3 for connection pooling and keep-alive. All public methods
49
+ are safe to call from any thread.
50
+
51
+ Args:
52
+ server_url: Base URL of the JustAnalytics server.
53
+ api_key: API key for authentication (``ja_sk_...``).
54
+ site_id: Site ID from the JustAnalytics dashboard.
55
+ flush_interval_s: Flush interval in seconds (default: 2.0).
56
+ max_batch_size: Max items per batch before immediate flush (default: 100).
57
+ debug: Enable debug logging (default: False).
58
+ """
59
+
60
+ def __init__(
61
+ self,
62
+ server_url: str,
63
+ api_key: str,
64
+ site_id: str,
65
+ flush_interval_s: float = 2.0,
66
+ max_batch_size: int = 100,
67
+ debug: bool = False,
68
+ ) -> None:
69
+ self._server_url = server_url.rstrip("/")
70
+ self._api_key = api_key
71
+ self._site_id = site_id
72
+ self._flush_interval_s = flush_interval_s
73
+ self._original_flush_interval_s = flush_interval_s
74
+ self._max_batch_size = max_batch_size
75
+ self._debug = debug
76
+
77
+ # Thread-safe buffers
78
+ self._span_buffer: Deque[Dict[str, Any]] = deque()
79
+ self._error_buffer: Deque[Dict[str, Any]] = deque()
80
+ self._log_buffer: Deque[Dict[str, Any]] = deque()
81
+ self._metric_buffer: Deque[Dict[str, Any]] = deque()
82
+ self._lock = threading.Lock()
83
+
84
+ # Flushing guards
85
+ self._flushing_spans = False
86
+ self._flushing_errors = False
87
+ self._flushing_logs = False
88
+ self._flushing_metrics = False
89
+
90
+ # Timer
91
+ self._timer: Optional[threading.Timer] = None
92
+ self._running = False
93
+
94
+ # Connection pool
95
+ self._http = urllib3.PoolManager(
96
+ num_pools=4,
97
+ maxsize=10,
98
+ timeout=urllib3.Timeout(connect=5.0, read=10.0),
99
+ retries=urllib3.Retry(total=0), # No retries; we handle drops ourselves
100
+ )
101
+
102
+ # Common headers
103
+ self._headers = {
104
+ "Content-Type": "application/json",
105
+ "Authorization": f"Bearer {self._api_key}",
106
+ "X-Site-ID": self._site_id,
107
+ "User-Agent": f"justanalytics-python/{_SDK_VERSION}",
108
+ }
109
+
110
+ # Backoff timer
111
+ self._backoff_timer: Optional[threading.Timer] = None
112
+
113
+ # --- Buffer Enqueue Methods ---
114
+
115
+ def enqueue_span(self, span_dict: Dict[str, Any]) -> None:
116
+ """Enqueue a serialized span for batched sending.
117
+
118
+ If the buffer reaches ``max_batch_size``, triggers an immediate flush.
119
+
120
+ Args:
121
+ span_dict: Serialized span dict (from Span.to_dict()).
122
+ """
123
+ with self._lock:
124
+ self._span_buffer.append(span_dict)
125
+ size = len(self._span_buffer)
126
+ if size >= self._max_batch_size:
127
+ self._flush_spans_async()
128
+
129
+ def enqueue_error(self, error_dict: Dict[str, Any]) -> None:
130
+ """Enqueue a serialized error for sending.
131
+
132
+ Args:
133
+ error_dict: Serialized error dict (from ErrorPayload.to_dict()).
134
+ """
135
+ with self._lock:
136
+ self._error_buffer.append(error_dict)
137
+ size = len(self._error_buffer)
138
+ if size >= self._max_batch_size:
139
+ self._flush_errors_async()
140
+
141
+ def enqueue_log(self, log_dict: Dict[str, Any]) -> None:
142
+ """Enqueue a serialized log entry for batched sending.
143
+
144
+ Args:
145
+ log_dict: Serialized log dict (from LogPayload.to_dict()).
146
+ """
147
+ with self._lock:
148
+ self._log_buffer.append(log_dict)
149
+ size = len(self._log_buffer)
150
+ if size >= self._max_batch_size:
151
+ self._flush_logs_async()
152
+
153
+ def enqueue_metric(self, metric_dict: Dict[str, Any]) -> None:
154
+ """Enqueue a serialized metric for batched sending.
155
+
156
+ Args:
157
+ metric_dict: Serialized metric dict (from MetricPayload.to_dict()).
158
+ """
159
+ with self._lock:
160
+ self._metric_buffer.append(metric_dict)
161
+ size = len(self._metric_buffer)
162
+ if size >= self._max_batch_size:
163
+ self._flush_metrics_async()
164
+
165
+ # --- Lifecycle ---
166
+
167
+ def start(self) -> None:
168
+ """Start the periodic flush timer."""
169
+ self._running = True
170
+ self._schedule_flush()
171
+ atexit.register(self._atexit_flush)
172
+
173
+ def stop(self) -> None:
174
+ """Stop the periodic flush timer."""
175
+ self._running = False
176
+ if self._timer:
177
+ self._timer.cancel()
178
+ self._timer = None
179
+ if self._backoff_timer:
180
+ self._backoff_timer.cancel()
181
+ self._backoff_timer = None
182
+
183
+ def flush(self) -> None:
184
+ """Flush all pending buffers synchronously.
185
+
186
+ Safe to call from any thread. Blocks until all buffers are flushed.
187
+ """
188
+ self._flush_spans()
189
+ self._flush_errors()
190
+ self._flush_logs()
191
+ self._flush_metrics()
192
+
193
+ @property
194
+ def pending_count(self) -> int:
195
+ """Total number of items across all buffers."""
196
+ return (
197
+ len(self._span_buffer)
198
+ + len(self._error_buffer)
199
+ + len(self._log_buffer)
200
+ + len(self._metric_buffer)
201
+ )
202
+
203
+ # --- Internal: Timer ---
204
+
205
+ def _schedule_flush(self) -> None:
206
+ """Schedule the next flush timer."""
207
+ if not self._running:
208
+ return
209
+ self._timer = threading.Timer(self._flush_interval_s, self._timer_flush)
210
+ self._timer.daemon = True
211
+ self._timer.start()
212
+
213
+ def _timer_flush(self) -> None:
214
+ """Called by the timer to flush all buffers and reschedule."""
215
+ try:
216
+ self.flush()
217
+ except Exception:
218
+ pass # Never crash the timer thread
219
+ self._schedule_flush()
220
+
221
+ def _atexit_flush(self) -> None:
222
+ """Called by atexit to flush remaining data."""
223
+ self._running = False
224
+ if self._timer:
225
+ self._timer.cancel()
226
+ try:
227
+ self.flush()
228
+ except Exception:
229
+ pass # Never crash during shutdown
230
+
231
+ # --- Internal: Async Flush Triggers ---
232
+
233
+ def _flush_spans_async(self) -> None:
234
+ """Trigger a span flush in a background thread."""
235
+ t = threading.Thread(target=self._flush_spans, daemon=True)
236
+ t.start()
237
+
238
+ def _flush_errors_async(self) -> None:
239
+ """Trigger an error flush in a background thread."""
240
+ t = threading.Thread(target=self._flush_errors, daemon=True)
241
+ t.start()
242
+
243
+ def _flush_logs_async(self) -> None:
244
+ """Trigger a log flush in a background thread."""
245
+ t = threading.Thread(target=self._flush_logs, daemon=True)
246
+ t.start()
247
+
248
+ def _flush_metrics_async(self) -> None:
249
+ """Trigger a metric flush in a background thread."""
250
+ t = threading.Thread(target=self._flush_metrics, daemon=True)
251
+ t.start()
252
+
253
+ # --- Internal: Flush Implementations ---
254
+
255
+ def _flush_spans(self) -> None:
256
+ """Flush all pending spans to POST /api/ingest/spans."""
257
+ if self._flushing_spans:
258
+ return
259
+ with self._lock:
260
+ if not self._span_buffer:
261
+ return
262
+ batch = list(self._span_buffer)
263
+ self._span_buffer.clear()
264
+ self._flushing_spans = True
265
+ try:
266
+ self._send_batch("/api/ingest/spans", {"spans": batch}, len(batch), "span")
267
+ except Exception as exc:
268
+ if self._debug:
269
+ logger.debug("Span flush failed, dropped %d span(s): %s", len(batch), exc)
270
+ finally:
271
+ self._flushing_spans = False
272
+
273
+ def _flush_errors(self) -> None:
274
+ """Flush all pending errors to POST /api/ingest/errors (one per request)."""
275
+ if self._flushing_errors:
276
+ return
277
+ with self._lock:
278
+ if not self._error_buffer:
279
+ return
280
+ batch = list(self._error_buffer)
281
+ self._error_buffer.clear()
282
+ self._flushing_errors = True
283
+ try:
284
+ for error_dict in batch:
285
+ self._send_single("/api/ingest/errors", error_dict, "error")
286
+ except Exception as exc:
287
+ if self._debug:
288
+ logger.debug("Error flush failed, dropped %d error(s): %s", len(batch), exc)
289
+ finally:
290
+ self._flushing_errors = False
291
+
292
+ def _flush_logs(self) -> None:
293
+ """Flush all pending logs to POST /api/ingest/logs."""
294
+ if self._flushing_logs:
295
+ return
296
+ with self._lock:
297
+ if not self._log_buffer:
298
+ return
299
+ batch = list(self._log_buffer)
300
+ self._log_buffer.clear()
301
+ self._flushing_logs = True
302
+ try:
303
+ self._send_batch("/api/ingest/logs", {"logs": batch}, len(batch), "log")
304
+ except Exception as exc:
305
+ if self._debug:
306
+ logger.debug("Log flush failed, dropped %d log(s): %s", len(batch), exc)
307
+ finally:
308
+ self._flushing_logs = False
309
+
310
+ def _flush_metrics(self) -> None:
311
+ """Flush all pending metrics to POST /api/ingest/metrics."""
312
+ if self._flushing_metrics:
313
+ return
314
+ with self._lock:
315
+ if not self._metric_buffer:
316
+ return
317
+ batch = list(self._metric_buffer)
318
+ self._metric_buffer.clear()
319
+ self._flushing_metrics = True
320
+ try:
321
+ self._send_batch(
322
+ "/api/ingest/metrics", {"metrics": batch}, len(batch), "metric"
323
+ )
324
+ except Exception as exc:
325
+ if self._debug:
326
+ logger.debug("Metric flush failed, dropped %d metric(s): %s", len(batch), exc)
327
+ finally:
328
+ self._flushing_metrics = False
329
+
330
+ # --- Internal: HTTP Sending ---
331
+
332
+ def _send_batch(
333
+ self, path: str, body: Dict[str, Any], count: int, label: str
334
+ ) -> None:
335
+ """Send a batched POST request to the given path.
336
+
337
+ Args:
338
+ path: API path (e.g., "/api/ingest/spans").
339
+ body: Request body dict to JSON-encode.
340
+ count: Number of items for logging.
341
+ label: Item type label for logging (e.g., "span").
342
+ """
343
+ url = f"{self._server_url}{path}"
344
+ try:
345
+ data = json.dumps(body).encode("utf-8")
346
+ response = self._http.request(
347
+ "POST",
348
+ url,
349
+ body=data,
350
+ headers=self._headers,
351
+ )
352
+ status = response.status
353
+ if 200 <= status < 300:
354
+ if self._debug:
355
+ logger.debug("Flushed %d %s(s) successfully", count, label)
356
+ elif status == 429:
357
+ self._handle_backoff()
358
+ if self._debug:
359
+ logger.debug(
360
+ "Rate limited (429). Backing off. Dropped %d %s(s).",
361
+ count,
362
+ label,
363
+ )
364
+ else:
365
+ if self._debug:
366
+ logger.debug(
367
+ "Flush failed HTTP %d. Dropped %d %s(s). Response: %s",
368
+ status,
369
+ count,
370
+ label,
371
+ response.data.decode("utf-8", errors="replace")[:500],
372
+ )
373
+ except Exception as exc:
374
+ if self._debug:
375
+ logger.debug(
376
+ "Network error during %s flush: %s. Dropped %d %s(s).",
377
+ label,
378
+ exc,
379
+ count,
380
+ label,
381
+ )
382
+
383
+ def _send_single(self, path: str, body: Dict[str, Any], label: str) -> None:
384
+ """Send a single POST request.
385
+
386
+ Args:
387
+ path: API path.
388
+ body: Request body dict.
389
+ label: Item type label for logging.
390
+ """
391
+ url = f"{self._server_url}{path}"
392
+ try:
393
+ data = json.dumps(body).encode("utf-8")
394
+ response = self._http.request(
395
+ "POST",
396
+ url,
397
+ body=data,
398
+ headers=self._headers,
399
+ )
400
+ status = response.status
401
+ if 200 <= status < 300:
402
+ if self._debug:
403
+ logger.debug("Sent %s event successfully", label)
404
+ elif status == 429:
405
+ self._handle_backoff()
406
+ if self._debug:
407
+ logger.debug("Rate limited (429) sending %s event", label)
408
+ else:
409
+ if self._debug:
410
+ logger.debug(
411
+ "Failed to send %s event: HTTP %d", label, status
412
+ )
413
+ except Exception as exc:
414
+ if self._debug:
415
+ logger.debug("Network error sending %s event: %s", label, exc)
416
+
417
+ def _handle_backoff(self) -> None:
418
+ """Handle HTTP 429 backoff: double flush interval for 60 seconds."""
419
+ self._flush_interval_s = self._original_flush_interval_s * 2
420
+
421
+ # Cancel existing backoff timer
422
+ if self._backoff_timer:
423
+ self._backoff_timer.cancel()
424
+
425
+ def reset_interval() -> None:
426
+ self._flush_interval_s = self._original_flush_interval_s
427
+
428
+ self._backoff_timer = threading.Timer(60.0, reset_interval)
429
+ self._backoff_timer.daemon = True
430
+ self._backoff_timer.start()
justanalytics/types.py ADDED
@@ -0,0 +1,214 @@
1
+ """
2
+ Shared types and enumerations for the JustAnalytics Python SDK.
3
+
4
+ Provides type definitions matching the wire protocol expected by
5
+ the JustAnalytics ingestion endpoints (POST /api/ingest/*).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from enum import Enum
11
+ from typing import Any, Dict, List, Optional
12
+
13
+ from dataclasses import dataclass, field
14
+
15
+
16
+ class SpanKind(str, Enum):
17
+ """Valid span kinds (OpenTelemetry-compatible)."""
18
+
19
+ CLIENT = "client"
20
+ SERVER = "server"
21
+ PRODUCER = "producer"
22
+ CONSUMER = "consumer"
23
+ INTERNAL = "internal"
24
+
25
+
26
+ class SpanStatus(str, Enum):
27
+ """Valid span statuses."""
28
+
29
+ OK = "ok"
30
+ ERROR = "error"
31
+ UNSET = "unset"
32
+
33
+
34
+ class LogLevel(str, Enum):
35
+ """Valid log severity levels matching POST /api/ingest/logs schema."""
36
+
37
+ DEBUG = "debug"
38
+ INFO = "info"
39
+ WARN = "warn"
40
+ ERROR = "error"
41
+ FATAL = "fatal"
42
+
43
+
44
+ class ErrorLevel(str, Enum):
45
+ """Valid error severity levels matching POST /api/ingest/errors schema."""
46
+
47
+ DEBUG = "debug"
48
+ INFO = "info"
49
+ WARNING = "warning"
50
+ ERROR = "error"
51
+ FATAL = "fatal"
52
+
53
+
54
+ class MechanismType(str, Enum):
55
+ """How the error was captured."""
56
+
57
+ MANUAL = "manual"
58
+ UNCAUGHT_EXCEPTION = "uncaughtException"
59
+ UNHANDLED_REJECTION = "unhandledRejection"
60
+
61
+
62
+ @dataclass
63
+ class UserContext:
64
+ """User context attached to spans, errors, and logs."""
65
+
66
+ id: Optional[str] = None
67
+ email: Optional[str] = None
68
+ username: Optional[str] = None
69
+
70
+ def to_dict(self) -> Optional[Dict[str, str]]:
71
+ """Serialize to dict, returning None if all fields are empty."""
72
+ result: Dict[str, str] = {}
73
+ if self.id:
74
+ result["id"] = self.id
75
+ if self.email:
76
+ result["email"] = self.email
77
+ if self.username:
78
+ result["username"] = self.username
79
+ return result if result else None
80
+
81
+
82
+ @dataclass
83
+ class SpanEvent:
84
+ """A timestamped event attached to a span."""
85
+
86
+ name: str
87
+ timestamp: str
88
+ attributes: Optional[Dict[str, Any]] = None
89
+
90
+ def to_dict(self) -> Dict[str, Any]:
91
+ """Serialize to dict for the wire protocol."""
92
+ result: Dict[str, Any] = {
93
+ "name": self.name,
94
+ "timestamp": self.timestamp,
95
+ }
96
+ if self.attributes:
97
+ result["attributes"] = self.attributes
98
+ return result
99
+
100
+
101
+ @dataclass
102
+ class SpanPayload:
103
+ """Serialized span payload matching POST /api/ingest/spans schema."""
104
+
105
+ id: str
106
+ trace_id: str
107
+ parent_span_id: Optional[str]
108
+ operation_name: str
109
+ service_name: str
110
+ kind: str
111
+ start_time: str
112
+ end_time: Optional[str]
113
+ duration: Optional[int]
114
+ status: str
115
+ status_message: Optional[str]
116
+ attributes: Dict[str, Any]
117
+ events: List[Dict[str, Any]]
118
+
119
+ def to_dict(self) -> Dict[str, Any]:
120
+ """Serialize to dict matching the server's Zod schema (camelCase keys)."""
121
+ return {
122
+ "id": self.id,
123
+ "traceId": self.trace_id,
124
+ "parentSpanId": self.parent_span_id,
125
+ "operationName": self.operation_name,
126
+ "serviceName": self.service_name,
127
+ "kind": self.kind,
128
+ "startTime": self.start_time,
129
+ "endTime": self.end_time,
130
+ "duration": self.duration,
131
+ "status": self.status,
132
+ "statusMessage": self.status_message,
133
+ "attributes": self.attributes,
134
+ "events": self.events,
135
+ }
136
+
137
+
138
+ @dataclass
139
+ class ErrorPayload:
140
+ """Serialized error payload matching POST /api/ingest/errors schema."""
141
+
142
+ event_id: str
143
+ timestamp: str
144
+ error: Dict[str, Any]
145
+ level: str
146
+ mechanism: Dict[str, Any]
147
+ context: Dict[str, Any]
148
+ trace: Optional[Dict[str, Optional[str]]]
149
+ user: Optional[Dict[str, str]]
150
+ tags: Optional[Dict[str, str]]
151
+ extra: Optional[Dict[str, Any]]
152
+ fingerprint: Optional[List[str]]
153
+
154
+ def to_dict(self) -> Dict[str, Any]:
155
+ """Serialize to dict matching the server's Zod schema (camelCase keys)."""
156
+ return {
157
+ "eventId": self.event_id,
158
+ "timestamp": self.timestamp,
159
+ "error": self.error,
160
+ "level": self.level,
161
+ "mechanism": self.mechanism,
162
+ "context": self.context,
163
+ "trace": self.trace,
164
+ "user": self.user,
165
+ "tags": self.tags,
166
+ "extra": self.extra,
167
+ "fingerprint": self.fingerprint,
168
+ }
169
+
170
+
171
+ @dataclass
172
+ class LogPayload:
173
+ """Serialized log entry matching POST /api/ingest/logs schema."""
174
+
175
+ level: str
176
+ message: str
177
+ service_name: str
178
+ timestamp: str
179
+ trace_id: Optional[str]
180
+ span_id: Optional[str]
181
+ attributes: Dict[str, Any] = field(default_factory=dict)
182
+
183
+ def to_dict(self) -> Dict[str, Any]:
184
+ """Serialize to dict matching the server's Zod schema (camelCase keys)."""
185
+ return {
186
+ "level": self.level,
187
+ "message": self.message,
188
+ "serviceName": self.service_name,
189
+ "timestamp": self.timestamp,
190
+ "traceId": self.trace_id,
191
+ "spanId": self.span_id,
192
+ "attributes": self.attributes,
193
+ }
194
+
195
+
196
+ @dataclass
197
+ class MetricPayload:
198
+ """Serialized metric data point matching POST /api/ingest/metrics schema."""
199
+
200
+ metric_name: str
201
+ value: float
202
+ service_name: str
203
+ timestamp: str
204
+ tags: Dict[str, Any] = field(default_factory=dict)
205
+
206
+ def to_dict(self) -> Dict[str, Any]:
207
+ """Serialize to dict matching the server's Zod schema (camelCase keys)."""
208
+ return {
209
+ "metricName": self.metric_name,
210
+ "value": self.value,
211
+ "serviceName": self.service_name,
212
+ "timestamp": self.timestamp,
213
+ "tags": self.tags,
214
+ }