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/span.py
ADDED
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Span class for distributed tracing with high-resolution timing.
|
|
3
|
+
|
|
4
|
+
Each Span records a timed operation within a trace. Spans support:
|
|
5
|
+
- Context manager protocol (``with`` statement)
|
|
6
|
+
- Decorator usage (``@span``)
|
|
7
|
+
- Manual start/end via ``span.end()``
|
|
8
|
+
|
|
9
|
+
The ``to_dict()`` method serializes the span to the exact format expected by
|
|
10
|
+
``POST /api/ingest/spans`` (matching the Zod schema in the server route).
|
|
11
|
+
|
|
12
|
+
Timing uses ``time.perf_counter_ns()`` for nanosecond-resolution monotonic
|
|
13
|
+
measurement, converted to integer milliseconds via rounding.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import os
|
|
19
|
+
import time
|
|
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
|
+
overload,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
from . import context as ctx
|
|
35
|
+
from .types import SpanEvent, SpanKind, SpanPayload, SpanStatus
|
|
36
|
+
|
|
37
|
+
F = TypeVar("F", bound=Callable[..., Any])
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _generate_trace_id() -> str:
|
|
41
|
+
"""Generate a 32-char lowercase hex trace ID (128 bits)."""
|
|
42
|
+
return os.urandom(16).hex()
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _generate_span_id() -> str:
|
|
46
|
+
"""Generate a 16-char lowercase hex span ID (64 bits)."""
|
|
47
|
+
return os.urandom(8).hex()
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class Span:
|
|
51
|
+
"""
|
|
52
|
+
Represents a single timed operation within a distributed trace.
|
|
53
|
+
|
|
54
|
+
Spans are typically created via ``justanalytics.start_span()`` and ended
|
|
55
|
+
automatically when the context manager exits. They can also be ended
|
|
56
|
+
manually via ``span.end()``.
|
|
57
|
+
|
|
58
|
+
Example::
|
|
59
|
+
|
|
60
|
+
with justanalytics.start_span("process-order") as span:
|
|
61
|
+
span.set_attribute("order.id", "12345")
|
|
62
|
+
process_order()
|
|
63
|
+
# span is automatically ended here
|
|
64
|
+
|
|
65
|
+
Attributes:
|
|
66
|
+
id: 16-char hex span ID.
|
|
67
|
+
trace_id: 32-char hex trace ID (shared across all spans in a trace).
|
|
68
|
+
parent_span_id: Parent span ID, or None for root spans.
|
|
69
|
+
operation_name: Human-readable name for this operation.
|
|
70
|
+
service_name: Name of the service producing this span.
|
|
71
|
+
kind: The span kind (client, server, internal, etc.).
|
|
72
|
+
start_time: Wall-clock start time (UTC datetime).
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
__slots__ = (
|
|
76
|
+
"id",
|
|
77
|
+
"trace_id",
|
|
78
|
+
"parent_span_id",
|
|
79
|
+
"operation_name",
|
|
80
|
+
"service_name",
|
|
81
|
+
"kind",
|
|
82
|
+
"start_time",
|
|
83
|
+
"_start_perf_ns",
|
|
84
|
+
"_end_time",
|
|
85
|
+
"_duration_ms",
|
|
86
|
+
"_status",
|
|
87
|
+
"_status_message",
|
|
88
|
+
"_attributes",
|
|
89
|
+
"_events",
|
|
90
|
+
"_ended",
|
|
91
|
+
"_span_token",
|
|
92
|
+
"_trace_token",
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
def __init__(
|
|
96
|
+
self,
|
|
97
|
+
operation_name: str,
|
|
98
|
+
service_name: str,
|
|
99
|
+
kind: SpanKind = SpanKind.INTERNAL,
|
|
100
|
+
trace_id: Optional[str] = None,
|
|
101
|
+
parent_span_id: Optional[str] = None,
|
|
102
|
+
attributes: Optional[Dict[str, Any]] = None,
|
|
103
|
+
) -> None:
|
|
104
|
+
self.id: str = _generate_span_id()
|
|
105
|
+
self.trace_id: str = trace_id or _generate_trace_id()
|
|
106
|
+
self.parent_span_id: Optional[str] = parent_span_id
|
|
107
|
+
self.operation_name: str = operation_name
|
|
108
|
+
self.service_name: str = service_name
|
|
109
|
+
self.kind: SpanKind = kind
|
|
110
|
+
self.start_time: datetime = datetime.now(timezone.utc)
|
|
111
|
+
self._start_perf_ns: int = time.perf_counter_ns()
|
|
112
|
+
self._end_time: Optional[datetime] = None
|
|
113
|
+
self._duration_ms: Optional[int] = None
|
|
114
|
+
self._status: SpanStatus = SpanStatus.UNSET
|
|
115
|
+
self._status_message: Optional[str] = None
|
|
116
|
+
self._attributes: Dict[str, Any] = dict(attributes) if attributes else {}
|
|
117
|
+
self._events: List[SpanEvent] = []
|
|
118
|
+
self._ended: bool = False
|
|
119
|
+
self._span_token: Any = None
|
|
120
|
+
self._trace_token: Any = None
|
|
121
|
+
|
|
122
|
+
# --- Attribute Methods ---
|
|
123
|
+
|
|
124
|
+
def set_attribute(self, key: str, value: Any) -> "Span":
|
|
125
|
+
"""Set a single attribute on the span.
|
|
126
|
+
|
|
127
|
+
Args:
|
|
128
|
+
key: Attribute key (e.g., "http.method", "db.statement").
|
|
129
|
+
value: Attribute value.
|
|
130
|
+
|
|
131
|
+
Returns:
|
|
132
|
+
self, for chaining.
|
|
133
|
+
"""
|
|
134
|
+
if not self._ended:
|
|
135
|
+
self._attributes[key] = value
|
|
136
|
+
return self
|
|
137
|
+
|
|
138
|
+
def set_attributes(self, attrs: Dict[str, Any]) -> "Span":
|
|
139
|
+
"""Set multiple attributes on the span.
|
|
140
|
+
|
|
141
|
+
Args:
|
|
142
|
+
attrs: Key-value pairs to merge into attributes.
|
|
143
|
+
|
|
144
|
+
Returns:
|
|
145
|
+
self, for chaining.
|
|
146
|
+
"""
|
|
147
|
+
if not self._ended:
|
|
148
|
+
self._attributes.update(attrs)
|
|
149
|
+
return self
|
|
150
|
+
|
|
151
|
+
# --- Status Methods ---
|
|
152
|
+
|
|
153
|
+
def set_status(self, status: SpanStatus, message: Optional[str] = None) -> "Span":
|
|
154
|
+
"""Set the span's status.
|
|
155
|
+
|
|
156
|
+
Args:
|
|
157
|
+
status: One of SpanStatus.OK, SpanStatus.ERROR, SpanStatus.UNSET.
|
|
158
|
+
message: Optional status message (typically for errors).
|
|
159
|
+
|
|
160
|
+
Returns:
|
|
161
|
+
self, for chaining.
|
|
162
|
+
"""
|
|
163
|
+
if not self._ended:
|
|
164
|
+
self._status = status
|
|
165
|
+
self._status_message = message
|
|
166
|
+
return self
|
|
167
|
+
|
|
168
|
+
# --- Event Methods ---
|
|
169
|
+
|
|
170
|
+
def add_event(
|
|
171
|
+
self, name: str, attributes: Optional[Dict[str, Any]] = None
|
|
172
|
+
) -> "Span":
|
|
173
|
+
"""Add a timestamped event to the span.
|
|
174
|
+
|
|
175
|
+
Events represent notable moments during the span's lifetime
|
|
176
|
+
(e.g., "cache.miss", "retry.attempt", "payment.processed").
|
|
177
|
+
|
|
178
|
+
Args:
|
|
179
|
+
name: Event name.
|
|
180
|
+
attributes: Optional event attributes.
|
|
181
|
+
|
|
182
|
+
Returns:
|
|
183
|
+
self, for chaining.
|
|
184
|
+
"""
|
|
185
|
+
if not self._ended:
|
|
186
|
+
event = SpanEvent(
|
|
187
|
+
name=name,
|
|
188
|
+
timestamp=datetime.now(timezone.utc).isoformat(),
|
|
189
|
+
attributes=attributes,
|
|
190
|
+
)
|
|
191
|
+
self._events.append(event)
|
|
192
|
+
return self
|
|
193
|
+
|
|
194
|
+
# --- Lifecycle ---
|
|
195
|
+
|
|
196
|
+
def end(self) -> None:
|
|
197
|
+
"""Mark the span as ended.
|
|
198
|
+
|
|
199
|
+
Calculates duration using ``time.perf_counter_ns()`` for sub-millisecond
|
|
200
|
+
precision, rounded to integer milliseconds.
|
|
201
|
+
|
|
202
|
+
Calling ``end()`` multiple times is a no-op (idempotent).
|
|
203
|
+
"""
|
|
204
|
+
if self._ended:
|
|
205
|
+
return
|
|
206
|
+
self._ended = True
|
|
207
|
+
self._end_time = datetime.now(timezone.utc)
|
|
208
|
+
elapsed_ns = time.perf_counter_ns() - self._start_perf_ns
|
|
209
|
+
self._duration_ms = round(elapsed_ns / 1_000_000)
|
|
210
|
+
|
|
211
|
+
@property
|
|
212
|
+
def is_ended(self) -> bool:
|
|
213
|
+
"""Whether this span has been ended."""
|
|
214
|
+
return self._ended
|
|
215
|
+
|
|
216
|
+
# --- Context Manager Protocol ---
|
|
217
|
+
|
|
218
|
+
def __enter__(self) -> "Span":
|
|
219
|
+
"""Enter the span context: set this span as active in contextvars."""
|
|
220
|
+
self._span_token = ctx.set_active_span(self)
|
|
221
|
+
self._trace_token = ctx.set_trace_id(self.trace_id)
|
|
222
|
+
return self
|
|
223
|
+
|
|
224
|
+
def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
|
|
225
|
+
"""Exit the span context: end the span and restore previous context."""
|
|
226
|
+
if exc_val is not None and not self._ended:
|
|
227
|
+
msg = str(exc_val) if exc_val else (exc_type.__name__ if exc_type else None)
|
|
228
|
+
self.set_status(SpanStatus.ERROR, msg)
|
|
229
|
+
if not self._ended:
|
|
230
|
+
self.end()
|
|
231
|
+
# Restore previous context
|
|
232
|
+
if self._span_token is not None:
|
|
233
|
+
ctx._active_span_var.reset(self._span_token)
|
|
234
|
+
self._span_token = None
|
|
235
|
+
if self._trace_token is not None:
|
|
236
|
+
ctx._trace_id_var.reset(self._trace_token)
|
|
237
|
+
self._trace_token = None
|
|
238
|
+
|
|
239
|
+
# --- Serialization ---
|
|
240
|
+
|
|
241
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
242
|
+
"""Serialize to the wire protocol dict matching the server's Zod schema."""
|
|
243
|
+
return {
|
|
244
|
+
"id": self.id,
|
|
245
|
+
"traceId": self.trace_id,
|
|
246
|
+
"parentSpanId": self.parent_span_id,
|
|
247
|
+
"operationName": self.operation_name,
|
|
248
|
+
"serviceName": self.service_name,
|
|
249
|
+
"kind": self.kind.value,
|
|
250
|
+
"startTime": self.start_time.isoformat(),
|
|
251
|
+
"endTime": self._end_time.isoformat() if self._end_time else None,
|
|
252
|
+
"duration": self._duration_ms,
|
|
253
|
+
"status": self._status.value,
|
|
254
|
+
"statusMessage": self._status_message,
|
|
255
|
+
"attributes": dict(self._attributes),
|
|
256
|
+
"events": [e.to_dict() for e in self._events],
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
def to_payload(self) -> SpanPayload:
|
|
260
|
+
"""Serialize to a SpanPayload dataclass."""
|
|
261
|
+
return SpanPayload(
|
|
262
|
+
id=self.id,
|
|
263
|
+
trace_id=self.trace_id,
|
|
264
|
+
parent_span_id=self.parent_span_id,
|
|
265
|
+
operation_name=self.operation_name,
|
|
266
|
+
service_name=self.service_name,
|
|
267
|
+
kind=self.kind.value,
|
|
268
|
+
start_time=self.start_time.isoformat(),
|
|
269
|
+
end_time=self._end_time.isoformat() if self._end_time else None,
|
|
270
|
+
duration=self._duration_ms,
|
|
271
|
+
status=self._status.value,
|
|
272
|
+
status_message=self._status_message,
|
|
273
|
+
attributes=dict(self._attributes),
|
|
274
|
+
events=[e.to_dict() for e in self._events],
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
def __repr__(self) -> str:
|
|
278
|
+
return (
|
|
279
|
+
f"Span(name={self.operation_name!r}, id={self.id!r}, "
|
|
280
|
+
f"trace_id={self.trace_id!r}, ended={self._ended})"
|
|
281
|
+
)
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""
|
|
2
|
+
W3C Trace Context (traceparent) header parsing and serialization.
|
|
3
|
+
|
|
4
|
+
Format: "00-{traceId}-{spanId}-{flags}"
|
|
5
|
+
- version: "00" (only supported version)
|
|
6
|
+
- traceId: 32-char lowercase hex (128-bit, must not be all zeros)
|
|
7
|
+
- spanId: 16-char lowercase hex (64-bit, must not be all zeros)
|
|
8
|
+
- flags: 2-char hex ("01" = sampled, "00" = not sampled)
|
|
9
|
+
|
|
10
|
+
References:
|
|
11
|
+
- W3C Trace Context: https://www.w3.org/TR/trace-context/
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import re
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
from typing import Optional
|
|
19
|
+
|
|
20
|
+
# Validation patterns
|
|
21
|
+
_TRACE_ID_RE = re.compile(r"^[0-9a-f]{32}$")
|
|
22
|
+
_SPAN_ID_RE = re.compile(r"^[0-9a-f]{16}$")
|
|
23
|
+
_FLAGS_RE = re.compile(r"^[0-9a-f]{2}$")
|
|
24
|
+
|
|
25
|
+
# Invalid all-zero IDs per W3C spec
|
|
26
|
+
_ZERO_TRACE_ID = "0" * 32
|
|
27
|
+
_ZERO_SPAN_ID = "0" * 16
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True)
|
|
31
|
+
class TraceparentData:
|
|
32
|
+
"""Parsed W3C traceparent header data."""
|
|
33
|
+
|
|
34
|
+
version: str
|
|
35
|
+
trace_id: str
|
|
36
|
+
parent_span_id: str
|
|
37
|
+
trace_flags: str
|
|
38
|
+
|
|
39
|
+
@property
|
|
40
|
+
def sampled(self) -> bool:
|
|
41
|
+
"""Whether the trace is sampled (flags bit 0 set)."""
|
|
42
|
+
return int(self.trace_flags, 16) & 0x01 == 1
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def parse_traceparent(header: str) -> Optional[TraceparentData]:
|
|
46
|
+
"""
|
|
47
|
+
Parse a W3C traceparent header string.
|
|
48
|
+
|
|
49
|
+
Returns None for invalid or malformed headers (never raises).
|
|
50
|
+
|
|
51
|
+
Args:
|
|
52
|
+
header: The traceparent header string to parse.
|
|
53
|
+
|
|
54
|
+
Returns:
|
|
55
|
+
Parsed TraceparentData, or None if invalid.
|
|
56
|
+
|
|
57
|
+
Example::
|
|
58
|
+
|
|
59
|
+
data = parse_traceparent("00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01")
|
|
60
|
+
assert data.trace_id == "4bf92f3577b34da6a3ce929d0e0e4736"
|
|
61
|
+
assert data.parent_span_id == "00f067aa0ba902b7"
|
|
62
|
+
assert data.sampled is True
|
|
63
|
+
"""
|
|
64
|
+
if not header or not isinstance(header, str):
|
|
65
|
+
return None
|
|
66
|
+
|
|
67
|
+
parts = header.split("-")
|
|
68
|
+
if len(parts) != 4:
|
|
69
|
+
return None
|
|
70
|
+
|
|
71
|
+
version, trace_id, parent_span_id, trace_flags = parts
|
|
72
|
+
|
|
73
|
+
# Only version "00" is supported
|
|
74
|
+
if version != "00":
|
|
75
|
+
return None
|
|
76
|
+
|
|
77
|
+
# Validate traceId: 32 hex chars, not all zeros
|
|
78
|
+
if not _TRACE_ID_RE.match(trace_id) or trace_id == _ZERO_TRACE_ID:
|
|
79
|
+
return None
|
|
80
|
+
|
|
81
|
+
# Validate parentSpanId: 16 hex chars, not all zeros
|
|
82
|
+
if not _SPAN_ID_RE.match(parent_span_id) or parent_span_id == _ZERO_SPAN_ID:
|
|
83
|
+
return None
|
|
84
|
+
|
|
85
|
+
# Validate traceFlags: 2 hex chars
|
|
86
|
+
if not _FLAGS_RE.match(trace_flags):
|
|
87
|
+
return None
|
|
88
|
+
|
|
89
|
+
return TraceparentData(
|
|
90
|
+
version=version,
|
|
91
|
+
trace_id=trace_id,
|
|
92
|
+
parent_span_id=parent_span_id,
|
|
93
|
+
trace_flags=trace_flags,
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def serialize_traceparent(
|
|
98
|
+
trace_id: str,
|
|
99
|
+
span_id: str,
|
|
100
|
+
sampled: bool = True,
|
|
101
|
+
) -> str:
|
|
102
|
+
"""
|
|
103
|
+
Serialize a W3C traceparent header string.
|
|
104
|
+
|
|
105
|
+
Produces: "00-{traceId}-{spanId}-{flags}"
|
|
106
|
+
|
|
107
|
+
Args:
|
|
108
|
+
trace_id: 32-character hex trace ID.
|
|
109
|
+
span_id: 16-character hex span ID.
|
|
110
|
+
sampled: Whether the trace is sampled (default True).
|
|
111
|
+
|
|
112
|
+
Returns:
|
|
113
|
+
W3C traceparent header string.
|
|
114
|
+
|
|
115
|
+
Example::
|
|
116
|
+
|
|
117
|
+
header = serialize_traceparent(
|
|
118
|
+
"4bf92f3577b34da6a3ce929d0e0e4736",
|
|
119
|
+
"00f067aa0ba902b7",
|
|
120
|
+
)
|
|
121
|
+
assert header == "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01"
|
|
122
|
+
"""
|
|
123
|
+
flags = "01" if sampled else "00"
|
|
124
|
+
return f"00-{trace_id}-{span_id}-{flags}"
|