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
|
@@ -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
|
+
}
|