traccia 0.1.2__py3-none-any.whl → 0.1.6__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.
- traccia/__init__.py +73 -0
- traccia/auto.py +748 -0
- traccia/auto_instrumentation.py +74 -0
- traccia/cli.py +349 -0
- traccia/config.py +699 -0
- traccia/context/__init__.py +33 -0
- traccia/context/context.py +67 -0
- traccia/context/propagators.py +283 -0
- traccia/errors.py +48 -0
- traccia/exporter/__init__.py +8 -0
- traccia/exporter/console_exporter.py +31 -0
- traccia/exporter/file_exporter.py +178 -0
- traccia/exporter/http_exporter.py +214 -0
- traccia/exporter/otlp_exporter.py +190 -0
- traccia/instrumentation/__init__.py +26 -0
- traccia/instrumentation/anthropic.py +92 -0
- traccia/instrumentation/decorator.py +263 -0
- traccia/instrumentation/fastapi.py +38 -0
- traccia/instrumentation/http_client.py +21 -0
- traccia/instrumentation/http_server.py +25 -0
- traccia/instrumentation/openai.py +358 -0
- traccia/instrumentation/requests.py +68 -0
- traccia/integrations/__init__.py +39 -0
- traccia/integrations/langchain/__init__.py +14 -0
- traccia/integrations/langchain/callback.py +418 -0
- traccia/integrations/langchain/utils.py +129 -0
- traccia/integrations/openai_agents/__init__.py +73 -0
- traccia/integrations/openai_agents/processor.py +262 -0
- traccia/pricing_config.py +58 -0
- traccia/processors/__init__.py +35 -0
- traccia/processors/agent_enricher.py +159 -0
- traccia/processors/batch_processor.py +140 -0
- traccia/processors/cost_engine.py +71 -0
- traccia/processors/cost_processor.py +70 -0
- traccia/processors/drop_policy.py +44 -0
- traccia/processors/logging_processor.py +31 -0
- traccia/processors/rate_limiter.py +223 -0
- traccia/processors/sampler.py +22 -0
- traccia/processors/token_counter.py +216 -0
- traccia/runtime_config.py +127 -0
- traccia/tracer/__init__.py +15 -0
- traccia/tracer/otel_adapter.py +577 -0
- traccia/tracer/otel_utils.py +24 -0
- traccia/tracer/provider.py +155 -0
- traccia/tracer/span.py +286 -0
- traccia/tracer/span_context.py +16 -0
- traccia/tracer/tracer.py +243 -0
- traccia/utils/__init__.py +19 -0
- traccia/utils/helpers.py +95 -0
- {traccia-0.1.2.dist-info → traccia-0.1.6.dist-info}/METADATA +72 -15
- traccia-0.1.6.dist-info/RECORD +55 -0
- traccia-0.1.6.dist-info/top_level.txt +1 -0
- traccia-0.1.2.dist-info/RECORD +0 -6
- traccia-0.1.2.dist-info/top_level.txt +0 -1
- {traccia-0.1.2.dist-info → traccia-0.1.6.dist-info}/WHEEL +0 -0
- {traccia-0.1.2.dist-info → traccia-0.1.6.dist-info}/entry_points.txt +0 -0
- {traccia-0.1.2.dist-info → traccia-0.1.6.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Context utilities for the tracing SDK."""
|
|
2
|
+
|
|
3
|
+
from traccia.context.context import get_current_span, pop_span, push_span
|
|
4
|
+
from traccia.context.propagators import (
|
|
5
|
+
extract_trace_context,
|
|
6
|
+
extract_tracestate,
|
|
7
|
+
extract_traceparent,
|
|
8
|
+
format_traceparent,
|
|
9
|
+
format_tracestate,
|
|
10
|
+
inject_traceparent,
|
|
11
|
+
inject_tracestate,
|
|
12
|
+
parse_tracestate,
|
|
13
|
+
parse_traceparent,
|
|
14
|
+
inject,
|
|
15
|
+
extract,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
__all__ = [
|
|
19
|
+
"get_current_span",
|
|
20
|
+
"push_span",
|
|
21
|
+
"pop_span",
|
|
22
|
+
"format_traceparent",
|
|
23
|
+
"inject_traceparent",
|
|
24
|
+
"parse_traceparent",
|
|
25
|
+
"extract_traceparent",
|
|
26
|
+
"format_tracestate",
|
|
27
|
+
"parse_tracestate",
|
|
28
|
+
"inject_tracestate",
|
|
29
|
+
"extract_tracestate",
|
|
30
|
+
"extract_trace_context",
|
|
31
|
+
"inject",
|
|
32
|
+
"extract",
|
|
33
|
+
]
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Context helpers for managing the active span stack - using OpenTelemetry directly."""
|
|
2
|
+
|
|
3
|
+
from contextvars import Token
|
|
4
|
+
from typing import Optional, TYPE_CHECKING
|
|
5
|
+
|
|
6
|
+
from opentelemetry.trace import get_current_span as otel_get_current_span
|
|
7
|
+
from opentelemetry.trace import set_span_in_context
|
|
8
|
+
from opentelemetry import context as context_api
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from traccia.tracer.span import Span
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def get_current_span() -> Optional["Span"]:
|
|
15
|
+
"""
|
|
16
|
+
Return the currently active span, if any.
|
|
17
|
+
|
|
18
|
+
Uses OpenTelemetry's context API internally.
|
|
19
|
+
"""
|
|
20
|
+
otel_span = otel_get_current_span()
|
|
21
|
+
if otel_span and otel_span.get_span_context().is_valid:
|
|
22
|
+
# Get tracer from span if available
|
|
23
|
+
try:
|
|
24
|
+
if hasattr(otel_span, '_traccia_tracer'):
|
|
25
|
+
tracer = otel_span._traccia_tracer
|
|
26
|
+
else:
|
|
27
|
+
# Fallback: create a tracer from global provider
|
|
28
|
+
from traccia import get_tracer_provider
|
|
29
|
+
provider = get_tracer_provider()
|
|
30
|
+
tracer = provider.get_tracer("context")
|
|
31
|
+
|
|
32
|
+
# Wrap in Traccia Span
|
|
33
|
+
from traccia.tracer.span import Span
|
|
34
|
+
return Span(otel_span, tracer)
|
|
35
|
+
except Exception:
|
|
36
|
+
# If wrapping fails, return None
|
|
37
|
+
return None
|
|
38
|
+
return None
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def push_span(span: "Span") -> Token:
|
|
42
|
+
"""
|
|
43
|
+
Push a span onto the context and set it as current.
|
|
44
|
+
|
|
45
|
+
Uses OpenTelemetry's context API internally.
|
|
46
|
+
|
|
47
|
+
Returns:
|
|
48
|
+
Token needed to restore the previous state
|
|
49
|
+
"""
|
|
50
|
+
if hasattr(span, '_otel_span'):
|
|
51
|
+
otel_span = span._otel_span
|
|
52
|
+
else:
|
|
53
|
+
otel_span = span
|
|
54
|
+
|
|
55
|
+
ctx = set_span_in_context(otel_span)
|
|
56
|
+
token = context_api.attach(ctx)
|
|
57
|
+
return token
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def pop_span(token: Token) -> None:
|
|
61
|
+
"""
|
|
62
|
+
Restore the previous span context using the provided token.
|
|
63
|
+
|
|
64
|
+
Args:
|
|
65
|
+
token: Token returned by push_span()
|
|
66
|
+
"""
|
|
67
|
+
context_api.detach(token)
|
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
"""W3C trace context propagation using OpenTelemetry's standard propagators."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Dict, Optional, Any
|
|
6
|
+
|
|
7
|
+
from opentelemetry.propagate import inject as otel_inject, extract as otel_extract
|
|
8
|
+
from opentelemetry.trace.propagation.tracecontext import TraceContextTextMapPropagator
|
|
9
|
+
from opentelemetry.trace import get_current_span, set_span_in_context
|
|
10
|
+
from opentelemetry.trace import SpanContext as OTelSpanContext, TraceFlags, TraceState
|
|
11
|
+
from opentelemetry.trace import NonRecordingSpan
|
|
12
|
+
from opentelemetry import context as context_api
|
|
13
|
+
|
|
14
|
+
from traccia.tracer.span_context import SpanContext
|
|
15
|
+
from traccia.utils.helpers import format_trace_id, format_span_id, parse_trace_id, parse_span_id
|
|
16
|
+
|
|
17
|
+
# Use OTel's W3C Trace Context propagator
|
|
18
|
+
_propagator = TraceContextTextMapPropagator()
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def format_traceparent(context: SpanContext) -> str:
|
|
22
|
+
"""
|
|
23
|
+
Format traceparent header value (W3C Trace Context standard).
|
|
24
|
+
|
|
25
|
+
Uses OpenTelemetry's propagator internally.
|
|
26
|
+
"""
|
|
27
|
+
# Convert Traccia SpanContext to OTel SpanContext
|
|
28
|
+
otel_context = _traccia_to_otel_context(context)
|
|
29
|
+
|
|
30
|
+
# Create a non-recording span with the context
|
|
31
|
+
span = NonRecordingSpan(otel_context)
|
|
32
|
+
ctx = set_span_in_context(span)
|
|
33
|
+
|
|
34
|
+
# Create a carrier dict and inject
|
|
35
|
+
carrier: Dict[str, str] = {}
|
|
36
|
+
_propagator.inject(carrier, context=ctx)
|
|
37
|
+
|
|
38
|
+
# Extract traceparent from carrier
|
|
39
|
+
return carrier.get("traceparent", "")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def format_tracestate(state: Dict[str, str]) -> str:
|
|
43
|
+
"""
|
|
44
|
+
Format tracestate header value from a dict.
|
|
45
|
+
|
|
46
|
+
Formats according to W3C Trace Context standard.
|
|
47
|
+
"""
|
|
48
|
+
if not state:
|
|
49
|
+
return ""
|
|
50
|
+
|
|
51
|
+
# Format manually (W3C format: key1=value1,key2=value2)
|
|
52
|
+
items = []
|
|
53
|
+
for k, v in state.items():
|
|
54
|
+
# Sanitize key and value
|
|
55
|
+
key = str(k).strip().lower()[:256]
|
|
56
|
+
value = str(v).strip().replace(",", "_").replace("=", "_")[:256]
|
|
57
|
+
if key and value:
|
|
58
|
+
items.append(f"{key}={value}")
|
|
59
|
+
|
|
60
|
+
return ",".join(items)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def parse_tracestate(header_value: str) -> Dict[str, str]:
|
|
64
|
+
"""
|
|
65
|
+
Parse a tracestate header into a dict.
|
|
66
|
+
|
|
67
|
+
Parses W3C Trace Context tracestate format: key1=value1,key2=value2
|
|
68
|
+
"""
|
|
69
|
+
if not header_value:
|
|
70
|
+
return {}
|
|
71
|
+
|
|
72
|
+
result = {}
|
|
73
|
+
for item in header_value.split(","):
|
|
74
|
+
item = item.strip()
|
|
75
|
+
if not item or "=" not in item:
|
|
76
|
+
continue
|
|
77
|
+
parts = item.split("=", 1)
|
|
78
|
+
if len(parts) == 2:
|
|
79
|
+
key = parts[0].strip().lower()
|
|
80
|
+
value = parts[1].strip()
|
|
81
|
+
if key and value:
|
|
82
|
+
result[key] = value
|
|
83
|
+
|
|
84
|
+
return result
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def parse_traceparent(header_value: str) -> Optional[SpanContext]:
|
|
88
|
+
"""
|
|
89
|
+
Parse a traceparent header into a SpanContext.
|
|
90
|
+
|
|
91
|
+
Uses OpenTelemetry's W3C Trace Context parser.
|
|
92
|
+
"""
|
|
93
|
+
if not header_value:
|
|
94
|
+
return None
|
|
95
|
+
|
|
96
|
+
# Use OTel to parse traceparent
|
|
97
|
+
carrier = {"traceparent": header_value}
|
|
98
|
+
ctx = _propagator.extract(carrier)
|
|
99
|
+
|
|
100
|
+
# Get span context from OTel context
|
|
101
|
+
span = get_current_span(context=ctx)
|
|
102
|
+
if span:
|
|
103
|
+
otel_context = span.get_span_context()
|
|
104
|
+
if otel_context.is_valid:
|
|
105
|
+
return _otel_to_traccia_context(otel_context)
|
|
106
|
+
|
|
107
|
+
return None
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def inject_traceparent(headers: Dict[str, str], context: SpanContext) -> None:
|
|
111
|
+
"""
|
|
112
|
+
Inject traceparent header into headers dict.
|
|
113
|
+
|
|
114
|
+
Uses OpenTelemetry's inject() function.
|
|
115
|
+
"""
|
|
116
|
+
# Convert Traccia context to OTel context
|
|
117
|
+
otel_context = _traccia_to_otel_context(context)
|
|
118
|
+
|
|
119
|
+
# Create OTel context with span
|
|
120
|
+
span = NonRecordingSpan(otel_context)
|
|
121
|
+
ctx = set_span_in_context(span)
|
|
122
|
+
|
|
123
|
+
# Inject using OTel
|
|
124
|
+
_propagator.inject(carrier=headers, context=ctx)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def inject_tracestate(headers: Dict[str, str], context: SpanContext) -> None:
|
|
128
|
+
"""
|
|
129
|
+
Inject tracestate header if present on the context.
|
|
130
|
+
|
|
131
|
+
Uses OpenTelemetry's inject() function.
|
|
132
|
+
"""
|
|
133
|
+
if not context.trace_state:
|
|
134
|
+
return
|
|
135
|
+
|
|
136
|
+
# Parse tracestate and create OTel TraceState
|
|
137
|
+
parsed = parse_tracestate(context.trace_state)
|
|
138
|
+
if parsed:
|
|
139
|
+
trace_state = TraceState([(k, v) for k, v in parsed.items()])
|
|
140
|
+
|
|
141
|
+
# Convert Traccia context to OTel context with tracestate
|
|
142
|
+
otel_context = _traccia_to_otel_context(context)
|
|
143
|
+
# Update trace_state
|
|
144
|
+
otel_context = OTelSpanContext(
|
|
145
|
+
trace_id=otel_context.trace_id,
|
|
146
|
+
span_id=otel_context.span_id,
|
|
147
|
+
is_remote=otel_context.is_remote,
|
|
148
|
+
trace_flags=otel_context.trace_flags,
|
|
149
|
+
trace_state=trace_state,
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
# Create span with context and inject
|
|
153
|
+
span = NonRecordingSpan(otel_context)
|
|
154
|
+
ctx = set_span_in_context(span)
|
|
155
|
+
_propagator.inject(carrier=headers, context=ctx)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def extract_traceparent(headers: Dict[str, str]) -> Optional[SpanContext]:
|
|
159
|
+
"""
|
|
160
|
+
Extract traceparent header from headers and parse it.
|
|
161
|
+
|
|
162
|
+
Uses OpenTelemetry's extract() function.
|
|
163
|
+
"""
|
|
164
|
+
# Use OTel to extract
|
|
165
|
+
ctx = _propagator.extract(carrier=headers)
|
|
166
|
+
|
|
167
|
+
# Get span from context
|
|
168
|
+
span = get_current_span(context=ctx)
|
|
169
|
+
if span:
|
|
170
|
+
otel_context = span.get_span_context()
|
|
171
|
+
if otel_context.is_valid:
|
|
172
|
+
return _otel_to_traccia_context(otel_context)
|
|
173
|
+
|
|
174
|
+
return None
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def extract_tracestate(headers: Dict[str, str]) -> Optional[str]:
|
|
178
|
+
"""
|
|
179
|
+
Extract tracestate header value (case-insensitive).
|
|
180
|
+
|
|
181
|
+
Returns the raw tracestate string.
|
|
182
|
+
"""
|
|
183
|
+
# Case-insensitive lookup
|
|
184
|
+
for key, value in headers.items():
|
|
185
|
+
if key.lower() == "tracestate":
|
|
186
|
+
return value
|
|
187
|
+
return None
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def extract_trace_context(headers: Dict[str, str]) -> Optional[SpanContext]:
|
|
191
|
+
"""
|
|
192
|
+
Extract both traceparent and tracestate and return a combined SpanContext.
|
|
193
|
+
|
|
194
|
+
Uses OpenTelemetry's extract() function.
|
|
195
|
+
"""
|
|
196
|
+
# Use OTel to extract both
|
|
197
|
+
ctx = _propagator.extract(carrier=headers)
|
|
198
|
+
|
|
199
|
+
# Get span from context
|
|
200
|
+
span = get_current_span(context=ctx)
|
|
201
|
+
if span:
|
|
202
|
+
otel_context = span.get_span_context()
|
|
203
|
+
if otel_context.is_valid:
|
|
204
|
+
# Extract tracestate separately
|
|
205
|
+
tracestate_str = extract_tracestate(headers)
|
|
206
|
+
return _otel_to_traccia_context(otel_context, tracestate_str)
|
|
207
|
+
|
|
208
|
+
return None
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
# Helper functions for context conversion
|
|
212
|
+
|
|
213
|
+
def _traccia_to_otel_context(traccia_context: SpanContext) -> OTelSpanContext:
|
|
214
|
+
"""Convert Traccia SpanContext to OTel SpanContext."""
|
|
215
|
+
trace_id = parse_trace_id(traccia_context.trace_id)
|
|
216
|
+
span_id = parse_span_id(traccia_context.span_id)
|
|
217
|
+
trace_flags = TraceFlags(traccia_context.trace_flags)
|
|
218
|
+
|
|
219
|
+
# Parse trace_state
|
|
220
|
+
trace_state = TraceState()
|
|
221
|
+
if traccia_context.trace_state:
|
|
222
|
+
parsed = parse_tracestate(traccia_context.trace_state)
|
|
223
|
+
if parsed:
|
|
224
|
+
items = [(k, v) for k, v in parsed.items()]
|
|
225
|
+
trace_state = TraceState(items)
|
|
226
|
+
|
|
227
|
+
return OTelSpanContext(
|
|
228
|
+
trace_id=trace_id,
|
|
229
|
+
span_id=span_id,
|
|
230
|
+
is_remote=False,
|
|
231
|
+
trace_flags=trace_flags,
|
|
232
|
+
trace_state=trace_state,
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _otel_to_traccia_context(otel_context: OTelSpanContext, tracestate_str: Optional[str] = None) -> SpanContext:
|
|
237
|
+
"""Convert OTel SpanContext to Traccia SpanContext."""
|
|
238
|
+
trace_id = format_trace_id(otel_context.trace_id)
|
|
239
|
+
span_id = format_span_id(otel_context.span_id)
|
|
240
|
+
trace_flags = 1 if otel_context.trace_flags.sampled else 0
|
|
241
|
+
|
|
242
|
+
# Format trace_state
|
|
243
|
+
trace_state = None
|
|
244
|
+
if tracestate_str:
|
|
245
|
+
trace_state = tracestate_str
|
|
246
|
+
elif otel_context.trace_state:
|
|
247
|
+
# Format OTel TraceState to string
|
|
248
|
+
items = []
|
|
249
|
+
for key, value in otel_context.trace_state.items():
|
|
250
|
+
items.append(f"{key}={value}")
|
|
251
|
+
if items:
|
|
252
|
+
trace_state = ",".join(items)
|
|
253
|
+
|
|
254
|
+
return SpanContext(
|
|
255
|
+
trace_id=trace_id,
|
|
256
|
+
span_id=span_id,
|
|
257
|
+
trace_flags=trace_flags,
|
|
258
|
+
trace_state=trace_state,
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
# Additional helper: inject/extract using OTel's standard API
|
|
263
|
+
|
|
264
|
+
def inject(carrier: Dict[str, str], context: Optional[Any] = None) -> None:
|
|
265
|
+
"""
|
|
266
|
+
Inject trace context into carrier using OpenTelemetry's standard API.
|
|
267
|
+
|
|
268
|
+
This is a convenience function that uses OTel's inject() directly.
|
|
269
|
+
If context is not provided, uses current context.
|
|
270
|
+
"""
|
|
271
|
+
if context is None:
|
|
272
|
+
otel_inject(carrier)
|
|
273
|
+
else:
|
|
274
|
+
otel_inject(carrier, context=context)
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def extract(carrier: Dict[str, str]) -> Any:
|
|
278
|
+
"""
|
|
279
|
+
Extract trace context from carrier using OpenTelemetry's standard API.
|
|
280
|
+
|
|
281
|
+
Returns OTel context that can be used with start_span().
|
|
282
|
+
"""
|
|
283
|
+
return otel_extract(carrier)
|
traccia/errors.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""Traccia SDK error hierarchy and exceptions."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class TracciaError(Exception):
|
|
7
|
+
"""Base exception for all Traccia SDK errors."""
|
|
8
|
+
|
|
9
|
+
def __init__(self, message: str, details: dict = None):
|
|
10
|
+
super().__init__(message)
|
|
11
|
+
self.message = message
|
|
12
|
+
self.details = details or {}
|
|
13
|
+
|
|
14
|
+
def __str__(self) -> str:
|
|
15
|
+
if self.details:
|
|
16
|
+
details_str = ", ".join(f"{k}={v}" for k, v in self.details.items())
|
|
17
|
+
return f"{self.message} ({details_str})"
|
|
18
|
+
return self.message
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class ConfigError(TracciaError):
|
|
22
|
+
"""Raised when configuration is invalid or conflicting."""
|
|
23
|
+
pass
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class ValidationError(TracciaError):
|
|
27
|
+
"""Raised when validation fails."""
|
|
28
|
+
pass
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class ExportError(TracciaError):
|
|
32
|
+
"""Raised when span export fails."""
|
|
33
|
+
pass
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class RateLimitError(TracciaError):
|
|
37
|
+
"""Raised when rate limit is exceeded (in strict mode)."""
|
|
38
|
+
pass
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class InitializationError(TracciaError):
|
|
42
|
+
"""Raised when SDK initialization fails."""
|
|
43
|
+
pass
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class InstrumentationError(TracciaError):
|
|
47
|
+
"""Raised when instrumentation/patching fails."""
|
|
48
|
+
pass
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""Exporters for delivering spans to backends."""
|
|
2
|
+
|
|
3
|
+
from traccia.exporter.http_exporter import HttpExporter
|
|
4
|
+
from traccia.exporter.console_exporter import ConsoleExporter
|
|
5
|
+
from traccia.exporter.file_exporter import FileExporter
|
|
6
|
+
from traccia.exporter.otlp_exporter import OTLPExporter
|
|
7
|
+
|
|
8
|
+
__all__ = ["HttpExporter", "ConsoleExporter", "FileExporter", "OTLPExporter"]
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Console exporter for developer visibility."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
from typing import Iterable
|
|
7
|
+
|
|
8
|
+
from traccia.tracer.span import Span
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class ConsoleExporter:
|
|
12
|
+
"""Simple exporter that prints spans to stdout (or provided stream)."""
|
|
13
|
+
|
|
14
|
+
def __init__(self, stream=None) -> None:
|
|
15
|
+
self.stream = stream or sys.stdout
|
|
16
|
+
|
|
17
|
+
def export(self, spans: Iterable[Span]) -> bool:
|
|
18
|
+
for span in spans:
|
|
19
|
+
line = (
|
|
20
|
+
f"[span] name={span.name} trace_id={span.context.trace_id} "
|
|
21
|
+
f"span_id={span.context.span_id} status={span.status.name} "
|
|
22
|
+
f"duration_ns={span.duration_ns}"
|
|
23
|
+
)
|
|
24
|
+
if span.attributes:
|
|
25
|
+
line += f" attrs={span.attributes}"
|
|
26
|
+
print(line, file=self.stream)
|
|
27
|
+
return True
|
|
28
|
+
|
|
29
|
+
def shutdown(self) -> None:
|
|
30
|
+
return None
|
|
31
|
+
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
"""File exporter for writing traces to a JSON file."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import threading
|
|
7
|
+
from typing import Any, Dict, Iterable, List
|
|
8
|
+
|
|
9
|
+
from traccia.tracer.span import Span
|
|
10
|
+
from traccia.tracer.span import SpanStatus
|
|
11
|
+
from traccia import runtime_config
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class FileExporter:
|
|
15
|
+
"""
|
|
16
|
+
Exporter that writes spans to a JSON file in JSONL format.
|
|
17
|
+
|
|
18
|
+
Each export() call writes one JSON object (containing resource and scopeSpans)
|
|
19
|
+
per line, following the same format as HttpExporter.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
def __init__(self, file_path: str = "traces.jsonl", reset_on_start: bool = False) -> None:
|
|
23
|
+
"""
|
|
24
|
+
Initialize the file exporter.
|
|
25
|
+
|
|
26
|
+
Args:
|
|
27
|
+
file_path: Path to the file where traces will be written (default: "traces.jsonl")
|
|
28
|
+
reset_on_start: If True, the file will be cleared on first export.
|
|
29
|
+
If False, traces will be appended to the file.
|
|
30
|
+
"""
|
|
31
|
+
self.file_path = file_path
|
|
32
|
+
self.reset_on_start = reset_on_start
|
|
33
|
+
self._lock = threading.Lock()
|
|
34
|
+
self._first_export = True
|
|
35
|
+
|
|
36
|
+
def export(self, spans: Iterable[Span]) -> bool:
|
|
37
|
+
"""
|
|
38
|
+
Export spans to the file in JSONL format.
|
|
39
|
+
|
|
40
|
+
Args:
|
|
41
|
+
spans: Iterable of Span objects to export
|
|
42
|
+
|
|
43
|
+
Returns:
|
|
44
|
+
True if export succeeded, False otherwise
|
|
45
|
+
"""
|
|
46
|
+
spans_list = list(spans)
|
|
47
|
+
if not spans_list:
|
|
48
|
+
return True
|
|
49
|
+
|
|
50
|
+
try:
|
|
51
|
+
payload = self._serialize(spans_list)
|
|
52
|
+
# Convert bytes to string for JSONL format
|
|
53
|
+
json_str = payload.decode("utf-8")
|
|
54
|
+
|
|
55
|
+
with self._lock:
|
|
56
|
+
# Handle reset_on_start: clear file on first export if True
|
|
57
|
+
if self.reset_on_start and self._first_export:
|
|
58
|
+
mode = "w"
|
|
59
|
+
self._first_export = False
|
|
60
|
+
else:
|
|
61
|
+
mode = "a"
|
|
62
|
+
if self._first_export:
|
|
63
|
+
self._first_export = False
|
|
64
|
+
|
|
65
|
+
with open(self.file_path, mode, encoding="utf-8") as f:
|
|
66
|
+
f.write(json_str)
|
|
67
|
+
f.write("\n") # JSONL format: one JSON object per line
|
|
68
|
+
|
|
69
|
+
return True
|
|
70
|
+
except Exception:
|
|
71
|
+
# Silently fail on file write errors to avoid breaking the application
|
|
72
|
+
return False
|
|
73
|
+
|
|
74
|
+
def shutdown(self) -> None:
|
|
75
|
+
"""Shutdown the exporter. No cleanup needed for file-based exporter."""
|
|
76
|
+
pass
|
|
77
|
+
|
|
78
|
+
def _serialize(self, spans: List[Span]) -> bytes:
|
|
79
|
+
"""
|
|
80
|
+
Serialize spans to JSON bytes using the same format as HttpExporter.
|
|
81
|
+
|
|
82
|
+
This method replicates the serialization logic from HttpExporter._serialize()
|
|
83
|
+
to ensure consistent format across exporters.
|
|
84
|
+
"""
|
|
85
|
+
trunc = runtime_config.get_attr_truncation_limit()
|
|
86
|
+
|
|
87
|
+
def _truncate_str(s: str) -> str:
|
|
88
|
+
if trunc is None or trunc <= 0:
|
|
89
|
+
return s
|
|
90
|
+
if len(s) <= trunc:
|
|
91
|
+
return s
|
|
92
|
+
return s[: max(0, trunc - 1)] + "…"
|
|
93
|
+
|
|
94
|
+
def _sanitize(value: Any, depth: int = 0) -> Any:
|
|
95
|
+
# Keep payload JSON-safe and bounded.
|
|
96
|
+
if value is None or isinstance(value, (bool, int, float)):
|
|
97
|
+
return value
|
|
98
|
+
if isinstance(value, str):
|
|
99
|
+
return _truncate_str(value)
|
|
100
|
+
if depth >= 6:
|
|
101
|
+
return _truncate_str(repr(value))
|
|
102
|
+
if isinstance(value, dict):
|
|
103
|
+
out: Dict[str, Any] = {}
|
|
104
|
+
for k, v in value.items():
|
|
105
|
+
try:
|
|
106
|
+
key = str(k)
|
|
107
|
+
except Exception:
|
|
108
|
+
key = "<unstringifiable>"
|
|
109
|
+
out[_truncate_str(key)] = _sanitize(v, depth + 1)
|
|
110
|
+
return out
|
|
111
|
+
if isinstance(value, (list, tuple, set)):
|
|
112
|
+
return [_sanitize(v, depth + 1) for v in list(value)[:100]]
|
|
113
|
+
# Fallback for unknown objects
|
|
114
|
+
return _truncate_str(repr(value))
|
|
115
|
+
|
|
116
|
+
def _status_code(status: SpanStatus) -> int:
|
|
117
|
+
# OTLP-style: 0=UNSET, 1=OK, 2=ERROR
|
|
118
|
+
if status == SpanStatus.OK:
|
|
119
|
+
return 1
|
|
120
|
+
if status == SpanStatus.ERROR:
|
|
121
|
+
return 2
|
|
122
|
+
return 0
|
|
123
|
+
|
|
124
|
+
def to_event(ev):
|
|
125
|
+
return {
|
|
126
|
+
"name": ev.get("name"),
|
|
127
|
+
"attributes": _sanitize(ev.get("attributes", {})),
|
|
128
|
+
"timestamp_ns": ev.get("timestamp_ns"),
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
resource_attrs: Dict[str, Any] = {}
|
|
132
|
+
try:
|
|
133
|
+
if spans and getattr(spans[0], "tracer", None) is not None:
|
|
134
|
+
provider = getattr(spans[0].tracer, "_provider", None)
|
|
135
|
+
if provider is not None:
|
|
136
|
+
resource_attrs.update(getattr(provider, "resource", {}) or {})
|
|
137
|
+
except Exception:
|
|
138
|
+
resource_attrs = resource_attrs
|
|
139
|
+
|
|
140
|
+
# Add optional session/user identifiers to resource for easier querying.
|
|
141
|
+
if runtime_config.get_session_id():
|
|
142
|
+
resource_attrs.setdefault("session.id", runtime_config.get_session_id())
|
|
143
|
+
if runtime_config.get_user_id():
|
|
144
|
+
resource_attrs.setdefault("user.id", runtime_config.get_user_id())
|
|
145
|
+
if runtime_config.get_tenant_id():
|
|
146
|
+
resource_attrs.setdefault("tenant.id", runtime_config.get_tenant_id())
|
|
147
|
+
if runtime_config.get_project_id():
|
|
148
|
+
resource_attrs.setdefault("project.id", runtime_config.get_project_id())
|
|
149
|
+
if runtime_config.get_debug():
|
|
150
|
+
resource_attrs.setdefault("trace.debug", True)
|
|
151
|
+
|
|
152
|
+
payload = {
|
|
153
|
+
"resource": {"attributes": _sanitize(resource_attrs)},
|
|
154
|
+
"scopeSpans": [
|
|
155
|
+
{
|
|
156
|
+
"scope": {"name": "agent-tracing-sdk", "version": "0.1.0"},
|
|
157
|
+
"spans": [
|
|
158
|
+
{
|
|
159
|
+
"traceId": span.context.trace_id,
|
|
160
|
+
"spanId": span.context.span_id,
|
|
161
|
+
"parentSpanId": span.parent_span_id,
|
|
162
|
+
"name": span.name,
|
|
163
|
+
"startTimeUnixNano": span.start_time_ns,
|
|
164
|
+
"endTimeUnixNano": span.end_time_ns,
|
|
165
|
+
"attributes": _sanitize(span.attributes),
|
|
166
|
+
"events": [to_event(e) for e in span.events],
|
|
167
|
+
"status": {
|
|
168
|
+
"code": _status_code(span.status),
|
|
169
|
+
"message": span.status_description or "",
|
|
170
|
+
},
|
|
171
|
+
}
|
|
172
|
+
for span in spans
|
|
173
|
+
],
|
|
174
|
+
}
|
|
175
|
+
]
|
|
176
|
+
}
|
|
177
|
+
# Ensure we never raise due to serialization edge cases.
|
|
178
|
+
return json.dumps(payload, ensure_ascii=False).encode("utf-8")
|