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/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}"