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.
Files changed (57) hide show
  1. traccia/__init__.py +73 -0
  2. traccia/auto.py +748 -0
  3. traccia/auto_instrumentation.py +74 -0
  4. traccia/cli.py +349 -0
  5. traccia/config.py +699 -0
  6. traccia/context/__init__.py +33 -0
  7. traccia/context/context.py +67 -0
  8. traccia/context/propagators.py +283 -0
  9. traccia/errors.py +48 -0
  10. traccia/exporter/__init__.py +8 -0
  11. traccia/exporter/console_exporter.py +31 -0
  12. traccia/exporter/file_exporter.py +178 -0
  13. traccia/exporter/http_exporter.py +214 -0
  14. traccia/exporter/otlp_exporter.py +190 -0
  15. traccia/instrumentation/__init__.py +26 -0
  16. traccia/instrumentation/anthropic.py +92 -0
  17. traccia/instrumentation/decorator.py +263 -0
  18. traccia/instrumentation/fastapi.py +38 -0
  19. traccia/instrumentation/http_client.py +21 -0
  20. traccia/instrumentation/http_server.py +25 -0
  21. traccia/instrumentation/openai.py +358 -0
  22. traccia/instrumentation/requests.py +68 -0
  23. traccia/integrations/__init__.py +39 -0
  24. traccia/integrations/langchain/__init__.py +14 -0
  25. traccia/integrations/langchain/callback.py +418 -0
  26. traccia/integrations/langchain/utils.py +129 -0
  27. traccia/integrations/openai_agents/__init__.py +73 -0
  28. traccia/integrations/openai_agents/processor.py +262 -0
  29. traccia/pricing_config.py +58 -0
  30. traccia/processors/__init__.py +35 -0
  31. traccia/processors/agent_enricher.py +159 -0
  32. traccia/processors/batch_processor.py +140 -0
  33. traccia/processors/cost_engine.py +71 -0
  34. traccia/processors/cost_processor.py +70 -0
  35. traccia/processors/drop_policy.py +44 -0
  36. traccia/processors/logging_processor.py +31 -0
  37. traccia/processors/rate_limiter.py +223 -0
  38. traccia/processors/sampler.py +22 -0
  39. traccia/processors/token_counter.py +216 -0
  40. traccia/runtime_config.py +127 -0
  41. traccia/tracer/__init__.py +15 -0
  42. traccia/tracer/otel_adapter.py +577 -0
  43. traccia/tracer/otel_utils.py +24 -0
  44. traccia/tracer/provider.py +155 -0
  45. traccia/tracer/span.py +286 -0
  46. traccia/tracer/span_context.py +16 -0
  47. traccia/tracer/tracer.py +243 -0
  48. traccia/utils/__init__.py +19 -0
  49. traccia/utils/helpers.py +95 -0
  50. {traccia-0.1.2.dist-info → traccia-0.1.6.dist-info}/METADATA +72 -15
  51. traccia-0.1.6.dist-info/RECORD +55 -0
  52. traccia-0.1.6.dist-info/top_level.txt +1 -0
  53. traccia-0.1.2.dist-info/RECORD +0 -6
  54. traccia-0.1.2.dist-info/top_level.txt +0 -1
  55. {traccia-0.1.2.dist-info → traccia-0.1.6.dist-info}/WHEEL +0 -0
  56. {traccia-0.1.2.dist-info → traccia-0.1.6.dist-info}/entry_points.txt +0 -0
  57. {traccia-0.1.2.dist-info → traccia-0.1.6.dist-info}/licenses/LICENSE +0 -0
traccia/auto.py ADDED
@@ -0,0 +1,748 @@
1
+ """Initialization helpers for wiring tracer provider, processors, and patches."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import atexit
6
+ import inspect
7
+ import os
8
+ import sys
9
+ from pathlib import Path
10
+ from typing import Optional, Any
11
+
12
+ from traccia.exporter import HttpExporter, ConsoleExporter, FileExporter, OTLPExporter
13
+ from traccia.exporter.http_exporter import DEFAULT_ENDPOINT
14
+ from traccia.instrumentation import patch_anthropic, patch_openai, patch_requests
15
+ from traccia.processors import (
16
+ BatchSpanProcessor,
17
+ Sampler,
18
+ TokenCountingProcessor,
19
+ CostAnnotatingProcessor,
20
+ LoggingSpanProcessor,
21
+ AgentEnrichmentProcessor,
22
+ )
23
+ from traccia import pricing_config
24
+ import threading
25
+ import time
26
+ from traccia.tracer.provider import TracerProvider
27
+ from traccia import config as sdk_config
28
+ from traccia import runtime_config
29
+ from traccia import auto_instrumentation
30
+
31
+ _started = False
32
+ _registered_shutdown = False
33
+ _active_processor: Optional[BatchSpanProcessor] = None
34
+ _init_method: Optional[str] = None # Track how SDK was initialized: "init" or "start_tracing"
35
+ _auto_trace_context: Optional[Any] = None # Context for auto-started trace
36
+ _auto_trace_name: str = "root" # Default name for auto-started trace
37
+
38
+
39
+ def init(
40
+ api_key: Optional[str] = None,
41
+ *,
42
+ auto_start_trace: bool = True,
43
+ auto_trace_name: str = "root",
44
+ config_file: Optional[str] = None,
45
+ **kwargs
46
+ ) -> TracerProvider:
47
+ """
48
+ Simplified initialization for Traccia SDK with config file support.
49
+
50
+ Configuration priority (highest to lowest):
51
+ 1. Explicit parameters (kwargs)
52
+ 2. Environment variables
53
+ 3. Config file (./traccia.toml or ~/.traccia/config.toml)
54
+
55
+ Args:
56
+ api_key: Optional API key (required for SaaS, optional for open-source)
57
+ auto_start_trace: If True, automatically start a root trace (default: True)
58
+ auto_trace_name: Name for auto-started trace (default: "root")
59
+ config_file: Optional explicit path to config file
60
+ **kwargs: All parameters from start_tracing() can be passed here
61
+
62
+ Returns:
63
+ TracerProvider instance
64
+
65
+ Example:
66
+ >>> import traccia
67
+ >>> traccia.init(api_key="...")
68
+ >>> # All spans created after this are children of auto-started trace
69
+ """
70
+ global _started, _init_method, _auto_trace_context, _auto_trace_name
71
+
72
+ # Check if already initialized
73
+ if _started:
74
+ if _init_method == "start_tracing":
75
+ import logging
76
+ logger = logging.getLogger(__name__)
77
+ logger.warning(
78
+ "SDK was initialized with start_tracing(). "
79
+ "Calling init() will not re-initialize. "
80
+ "Use stop_tracing() first if you need to re-initialize."
81
+ )
82
+ return _get_provider()
83
+
84
+ # Load config file if exists (lowest priority)
85
+ merged_config = {}
86
+ if config_file or sdk_config.find_config_file():
87
+ file_config = sdk_config.load_config_with_priority(config_file=config_file)
88
+ merged_config.update(file_config)
89
+
90
+ # Override with explicit parameters (highest priority)
91
+ if api_key is not None:
92
+ merged_config['api_key'] = api_key
93
+ for key, value in kwargs.items():
94
+ if value is not None:
95
+ merged_config[key] = value
96
+
97
+ # Handle auto_start_trace and auto_trace_name - these are init() specific, not start_tracing()
98
+ # Get auto_start_trace from merged config or use default
99
+ final_auto_start = merged_config.pop('auto_start_trace', auto_start_trace)
100
+ if isinstance(final_auto_start, str):
101
+ # Convert string to bool if needed
102
+ final_auto_start = final_auto_start.lower() in ('true', '1', 'yes')
103
+
104
+ # Store auto-trace config before calling start_tracing
105
+ _auto_trace_name = merged_config.pop('auto_trace_name', auto_trace_name)
106
+
107
+ # Map config file keys to start_tracing() parameter names
108
+ # Config file uses shorter names, start_tracing() uses full names
109
+ key_mapping = {
110
+ 'enable_console': 'enable_console_exporter',
111
+ 'enable_file': 'enable_file_exporter',
112
+ }
113
+ for old_key, new_key in key_mapping.items():
114
+ if old_key in merged_config:
115
+ merged_config[new_key] = merged_config.pop(old_key)
116
+
117
+ # Extract rate limiting config to pass separately to start_tracing
118
+ rate_limit_config = {
119
+ 'max_spans_per_second': merged_config.pop('max_spans_per_second', None),
120
+ 'max_block_ms': merged_config.pop('max_block_ms', 100),
121
+ }
122
+
123
+ # Add rate limiting config back into merged_config for start_tracing
124
+ merged_config.update(rate_limit_config)
125
+
126
+ # Extract openai_agents config (not passed to start_tracing)
127
+ openai_agents_enabled = merged_config.pop('openai_agents', True)
128
+
129
+ # Initialize via start_tracing with full config
130
+ provider = start_tracing(**merged_config)
131
+ _init_method = "init"
132
+
133
+ # Auto-install OpenAI Agents SDK integration if available
134
+ if openai_agents_enabled:
135
+ try:
136
+ from traccia.integrations.openai_agents import install as install_openai_agents
137
+ install_openai_agents(enabled=True)
138
+ except Exception:
139
+ # Agents SDK not installed or error during install, skip silently
140
+ pass
141
+
142
+ # Auto-start trace if requested
143
+ if final_auto_start:
144
+ _auto_trace_context = _start_auto_trace(provider, _auto_trace_name)
145
+ if not _registered_shutdown:
146
+ atexit.register(_cleanup_auto_trace)
147
+
148
+ return provider
149
+
150
+
151
+ def _start_auto_trace(provider: TracerProvider, name: str = "root") -> Any:
152
+ """
153
+ Start an auto-managed root trace.
154
+
155
+ Args:
156
+ provider: TracerProvider instance
157
+ name: Name for the root trace span
158
+
159
+ Returns:
160
+ Span context for cleanup
161
+ """
162
+ import logging
163
+ logger = logging.getLogger(__name__)
164
+
165
+ try:
166
+ tracer = provider.get_tracer("traccia.auto")
167
+
168
+ # Create root span and make it current
169
+ span = tracer.start_span(
170
+ name=name,
171
+ attributes={"traccia.auto_started": True}
172
+ )
173
+
174
+ # Make this span the current span in the context
175
+ from opentelemetry import context
176
+ from opentelemetry.trace import set_span_in_context
177
+
178
+ token = context.attach(set_span_in_context(span))
179
+
180
+ logger.debug(f"Auto-started trace '{name}' created")
181
+
182
+ return {"span": span, "token": token}
183
+
184
+ except Exception as e:
185
+ logger.error(f"Failed to start auto-trace: {e}")
186
+ return None
187
+
188
+
189
+ def _cleanup_auto_trace() -> None:
190
+ """Cleanup auto-started trace on program exit."""
191
+ global _auto_trace_context
192
+
193
+ if _auto_trace_context and _auto_trace_context.get("span"):
194
+ import logging
195
+ logger = logging.getLogger(__name__)
196
+
197
+ try:
198
+ span = _auto_trace_context["span"]
199
+ if hasattr(span, "is_recording") and span.is_recording():
200
+ span.end()
201
+ logger.debug("Auto-started trace ended")
202
+
203
+ # Detach context
204
+ if _auto_trace_context.get("token"):
205
+ from opentelemetry import context
206
+ context.detach(_auto_trace_context["token"])
207
+
208
+ except Exception as e:
209
+ logger.error(f"Error cleaning up auto-trace: {e}")
210
+
211
+ finally:
212
+ _auto_trace_context = None
213
+
214
+
215
+ def end_auto_trace() -> None:
216
+ """
217
+ Explicitly end the auto-started trace.
218
+
219
+ This allows users to end the auto-trace and create their own root traces.
220
+ """
221
+ global _auto_trace_context
222
+
223
+ if _auto_trace_context:
224
+ _cleanup_auto_trace()
225
+
226
+
227
+ class trace:
228
+ """
229
+ Context manager for explicit trace management.
230
+
231
+ Ends auto-trace if active and starts a new explicit trace.
232
+
233
+ Example:
234
+ >>> import traccia
235
+ >>> traccia.init()
236
+ >>> with traccia.trace("custom-trace"):
237
+ ... # Your code here
238
+ ... pass
239
+ """
240
+
241
+ def __init__(self, name: str = "trace", **kwargs):
242
+ """
243
+ Initialize trace context manager.
244
+
245
+ Args:
246
+ name: Name for the trace span
247
+ **kwargs: Additional span attributes
248
+ """
249
+ self.name = name
250
+ self.kwargs = kwargs
251
+ self.span = None
252
+ self.token = None
253
+
254
+ def __enter__(self):
255
+ """Start the explicit trace."""
256
+ import logging
257
+ logger = logging.getLogger(__name__)
258
+
259
+ # End auto-trace if active
260
+ if _auto_trace_context:
261
+ logger.debug("Ending auto-trace to start explicit trace")
262
+ end_auto_trace()
263
+
264
+ # Start new explicit trace
265
+ try:
266
+ provider = _get_provider()
267
+ tracer = provider.get_tracer("traccia.explicit")
268
+
269
+ self.span = tracer.start_span(
270
+ name=self.name,
271
+ attributes=self.kwargs
272
+ )
273
+
274
+ # Make this span the current span
275
+ from opentelemetry import context
276
+ from opentelemetry.trace import set_span_in_context
277
+
278
+ self.token = context.attach(set_span_in_context(self.span._otel_span))
279
+
280
+ return self.span
281
+
282
+ except Exception as e:
283
+ logger.error(f"Failed to start explicit trace: {e}")
284
+ return None
285
+
286
+ def __exit__(self, exc_type, exc_val, exc_tb):
287
+ """End the explicit trace."""
288
+ if self.span:
289
+ try:
290
+ if exc_type:
291
+ # Record exception if one occurred
292
+ self.span.record_exception(exc_val)
293
+ from traccia.tracer.span import SpanStatus
294
+ self.span.set_status(SpanStatus.ERROR, str(exc_val))
295
+
296
+ self.span.end()
297
+ except Exception:
298
+ pass
299
+
300
+ if self.token:
301
+ try:
302
+ from opentelemetry import context
303
+ context.detach(self.token)
304
+ except Exception:
305
+ pass
306
+
307
+ return False # Don't suppress exceptions
308
+
309
+
310
+ def start_tracing(
311
+ *,
312
+ api_key: Optional[str] = None,
313
+ endpoint: Optional[str] = None,
314
+ sample_rate: float = 1.0,
315
+ max_queue_size: int = 5000,
316
+ max_export_batch_size: int = 512,
317
+ schedule_delay_millis: int = 5000,
318
+ exporter: Optional[Any] = None,
319
+ use_otlp: bool = True, # Use OTLP exporter by default
320
+ transport=None,
321
+ enable_patching: bool = True,
322
+ enable_token_counting: bool = True,
323
+ enable_costs: bool = True,
324
+ pricing_override=None,
325
+ pricing_refresh_seconds: Optional[int] = None,
326
+ enable_console_exporter: bool = False,
327
+ enable_file_exporter: bool = False,
328
+ file_exporter_path: str = "traces.jsonl",
329
+ reset_trace_file: bool = False,
330
+ load_env: bool = True,
331
+ enable_span_logging: bool = False,
332
+ auto_instrument_tools: bool = False,
333
+ tool_include: Optional[list] = None,
334
+ max_tool_spans: int = 100,
335
+ max_span_depth: int = 10,
336
+ session_id: Optional[str] = None,
337
+ user_id: Optional[str] = None,
338
+ tenant_id: Optional[str] = None,
339
+ project_id: Optional[str] = None,
340
+ agent_id: Optional[str] = None,
341
+ debug: bool = False,
342
+ attr_truncation_limit: Optional[int] = None,
343
+ service_name: Optional[str] = None,
344
+ max_spans_per_second: Optional[float] = None, # Rate limiting
345
+ max_block_ms: int = 100, # Rate limiting block time
346
+ ) -> TracerProvider:
347
+ """
348
+ Initialize global tracing:
349
+ - Builds HttpExporter (or uses provided one)
350
+ - Attaches BatchSpanProcessor with sampling and bounded queue
351
+ - Registers monkey patches (OpenAI, Anthropic, requests)
352
+ - Registers atexit shutdown hook
353
+ """
354
+ global _started, _active_processor, _init_method
355
+ if _started:
356
+ if _init_method == "init":
357
+ import logging
358
+ logger = logging.getLogger(__name__)
359
+ logger.warning(
360
+ "SDK was initialized with init(). "
361
+ "Calling start_tracing() will not re-initialize. "
362
+ "Use stop_tracing() first if you need to re-initialize."
363
+ )
364
+ return _get_provider()
365
+
366
+ if load_env:
367
+ sdk_config.load_dotenv()
368
+
369
+ # Load config from environment (backward compatible)
370
+ env_cfg = sdk_config.load_config_from_env()
371
+
372
+ # Apply any explicit overrides
373
+ if api_key:
374
+ env_cfg['api_key'] = api_key
375
+ if endpoint:
376
+ env_cfg['endpoint'] = endpoint
377
+
378
+ # Resolve agent configuration path automatically if not provided by env.
379
+ agent_cfg_path = _resolve_agent_config_path()
380
+ if agent_cfg_path:
381
+ os.environ.setdefault("AGENT_DASHBOARD_AGENT_CONFIG", agent_cfg_path)
382
+
383
+ provider = _get_provider()
384
+ key = env_cfg.get("api_key") or api_key
385
+ endpoint = env_cfg.get("endpoint") or endpoint
386
+ try:
387
+ sample_rate = float(env_cfg.get("sample_rate", sample_rate))
388
+ except Exception:
389
+ sample_rate = sample_rate
390
+
391
+ # Set runtime config for auto-instrumentation
392
+ runtime_config.set_auto_instrument_tools(auto_instrument_tools)
393
+ runtime_config.set_tool_include(tool_include or [])
394
+ runtime_config.set_max_tool_spans(max_tool_spans)
395
+ runtime_config.set_max_span_depth(max_span_depth)
396
+ runtime_config.set_session_id(session_id)
397
+ runtime_config.set_user_id(user_id)
398
+ runtime_config.set_tenant_id(_resolve_tenant_id(tenant_id))
399
+ runtime_config.set_project_id(_resolve_project_id(project_id))
400
+ runtime_config.set_agent_id(agent_id)
401
+ runtime_config.set_debug(_resolve_debug(debug))
402
+ runtime_config.set_attr_truncation_limit(attr_truncation_limit)
403
+
404
+ # Build resource attributes from runtime config
405
+ # This ensures tenant.id, project.id, etc. are included in OTLP exports
406
+ resource_attrs = {}
407
+
408
+ # Set service.name - required for proper service identification in Tempo/Grafana
409
+ # This prevents "unknown_service" from appearing
410
+ from opentelemetry.semconv.resource import ResourceAttributes
411
+ service_name_value = _resolve_service_name(service_name)
412
+ resource_attrs[ResourceAttributes.SERVICE_NAME] = service_name_value
413
+
414
+ if runtime_config.get_tenant_id():
415
+ resource_attrs["tenant.id"] = runtime_config.get_tenant_id()
416
+ if runtime_config.get_project_id():
417
+ resource_attrs["project.id"] = runtime_config.get_project_id()
418
+ if runtime_config.get_session_id():
419
+ resource_attrs["session.id"] = runtime_config.get_session_id()
420
+ if runtime_config.get_user_id():
421
+ resource_attrs["user.id"] = runtime_config.get_user_id()
422
+ if runtime_config.get_agent_id():
423
+ resource_attrs["agent.id"] = runtime_config.get_agent_id()
424
+ if runtime_config.get_debug():
425
+ resource_attrs["trace.debug"] = True
426
+
427
+ # Update provider resource dict (for HttpExporter compatibility)
428
+ if resource_attrs:
429
+ provider.resource.update(resource_attrs)
430
+
431
+ # For OTLP, we need to recreate the provider with updated resource
432
+ # since OTel Resource is immutable
433
+ if resource_attrs and use_otlp:
434
+ from opentelemetry.sdk.resources import Resource as OTelResource
435
+ from opentelemetry.sdk.trace import TracerProvider as OTelTracerProvider
436
+ # Merge with existing resource attributes
437
+ existing_resource = provider._otel_provider.resource
438
+ existing_attrs = dict(existing_resource.attributes) if existing_resource.attributes else {}
439
+ existing_attrs.update(resource_attrs)
440
+ # Create new resource with merged attributes
441
+ new_resource = OTelResource.create(existing_attrs)
442
+ # Recreate OTel provider with updated resource
443
+ provider._otel_provider = OTelTracerProvider(resource=new_resource)
444
+ # Re-add any existing export processors to the new provider
445
+ for proc in provider._export_processors:
446
+ provider._otel_provider.add_span_processor(proc)
447
+
448
+ # Use OTLP exporter by default, fall back to HttpExporter if use_otlp=False
449
+ if exporter:
450
+ network_exporter = exporter
451
+ elif use_otlp:
452
+ # Use OTLP exporter (OpenTelemetry standard)
453
+ network_exporter = OTLPExporter(
454
+ endpoint=endpoint or DEFAULT_ENDPOINT,
455
+ api_key=key,
456
+ )
457
+ else:
458
+ # Legacy HttpExporter for backward compatibility
459
+ network_exporter = HttpExporter(
460
+ endpoint=endpoint or DEFAULT_ENDPOINT,
461
+ api_key=key,
462
+ transport=transport,
463
+ )
464
+
465
+ if enable_console_exporter:
466
+ network_exporter = _combine_exporters(network_exporter, ConsoleExporter())
467
+
468
+ if enable_file_exporter:
469
+ # If reset_trace_file is True, clear the file when start_tracing is called
470
+ if reset_trace_file:
471
+ try:
472
+ with open(file_exporter_path, "w", encoding="utf-8") as f:
473
+ pass # Truncate file to empty
474
+ except Exception:
475
+ pass # Silently fail if file cannot be cleared
476
+ network_exporter = _combine_exporters(
477
+ network_exporter,
478
+ FileExporter(file_path=file_exporter_path, reset_on_start=False)
479
+ )
480
+
481
+ sampler = Sampler(sample_rate)
482
+ # Use the sampler at trace start (head sampling) and also to make the
483
+ # batch processor respect trace_flags.
484
+ try:
485
+ provider.set_sampler(sampler)
486
+ except Exception:
487
+ pass
488
+
489
+ # Ordering matters: enrich spans before batching/export.
490
+ if enable_token_counting:
491
+ provider.add_span_processor(TokenCountingProcessor())
492
+ cost_processor = None
493
+ if enable_costs:
494
+ pricing_table, pricing_source = pricing_config.load_pricing_with_source(pricing_override)
495
+ cost_processor = CostAnnotatingProcessor(
496
+ pricing_table=pricing_table, pricing_source=pricing_source
497
+ )
498
+ provider.add_span_processor(cost_processor)
499
+ if enable_span_logging:
500
+ provider.add_span_processor(LoggingSpanProcessor())
501
+ # Agent enrichment should run after cost/token processors so it can fill any gaps.
502
+ provider.add_span_processor(
503
+ AgentEnrichmentProcessor(agent_config_path=os.getenv("AGENT_DASHBOARD_AGENT_CONFIG"))
504
+ )
505
+
506
+ # For OTLP exporter, use OTel's BatchSpanProcessor directly
507
+ # For HttpExporter, use our custom BatchSpanProcessor
508
+ if use_otlp and isinstance(network_exporter, OTLPExporter) and hasattr(network_exporter, '_otel_exporter'):
509
+ # Use OTel's BatchSpanProcessor for OTLP export
510
+ from opentelemetry.sdk.trace.export import BatchSpanProcessor as OTelBatchSpanProcessor
511
+ otel_processor = OTelBatchSpanProcessor(
512
+ network_exporter._otel_exporter,
513
+ max_queue_size=max_queue_size,
514
+ max_export_batch_size=max_export_batch_size,
515
+ schedule_delay_millis=schedule_delay_millis,
516
+ )
517
+
518
+ # Wrap with rate limiting if configured
519
+ if max_spans_per_second is not None and max_spans_per_second > 0:
520
+ from traccia.processors.rate_limiter import RateLimitingSpanProcessor
521
+ rate_limited_processor = RateLimitingSpanProcessor(
522
+ next_processor=otel_processor,
523
+ max_spans_per_second=max_spans_per_second,
524
+ max_block_ms=max_block_ms,
525
+ )
526
+ provider._otel_provider.add_span_processor(rate_limited_processor)
527
+ else:
528
+ provider._otel_provider.add_span_processor(otel_processor)
529
+ _active_processor = None # OTel handles this
530
+ else:
531
+ # Use our custom BatchSpanProcessor for HttpExporter
532
+ processor = BatchSpanProcessor(
533
+ exporter=network_exporter,
534
+ sampler=sampler,
535
+ max_queue_size=max_queue_size,
536
+ max_export_batch_size=max_export_batch_size,
537
+ schedule_delay_millis=schedule_delay_millis,
538
+ )
539
+
540
+ # Wrap with rate limiting if configured
541
+ if max_spans_per_second is not None and max_spans_per_second > 0:
542
+ from traccia.processors.rate_limiter import RateLimitingSpanProcessor
543
+ rate_limited_processor = RateLimitingSpanProcessor(
544
+ next_processor=processor,
545
+ max_spans_per_second=max_spans_per_second,
546
+ max_block_ms=max_block_ms,
547
+ )
548
+ provider.add_span_processor(rate_limited_processor)
549
+ _active_processor = rate_limited_processor
550
+ else:
551
+ provider.add_span_processor(processor)
552
+ _active_processor = processor
553
+
554
+ if _active_processor:
555
+ _register_shutdown(provider, _active_processor)
556
+ _start_pricing_refresh(cost_processor, pricing_override, pricing_refresh_seconds)
557
+
558
+ # Auto-instrument in-repo functions/tools if enabled
559
+ if auto_instrument_tools and tool_include:
560
+ try:
561
+ auto_instrumentation.instrument_functions(tool_include or [])
562
+ except Exception:
563
+ pass
564
+
565
+ if enable_patching:
566
+ try:
567
+ patch_openai()
568
+ except Exception:
569
+ pass
570
+ try:
571
+ patch_anthropic()
572
+ except Exception:
573
+ pass
574
+ try:
575
+ patch_requests()
576
+ except Exception:
577
+ pass
578
+
579
+ _started = True
580
+ if _init_method is None:
581
+ _init_method = "start_tracing"
582
+ return provider
583
+
584
+
585
+ def stop_tracing(flush_timeout: Optional[float] = None) -> None:
586
+ """Force flush and shutdown registered processors and provider."""
587
+ global _started, _init_method, _auto_trace_context
588
+
589
+ # End auto-trace if active
590
+ if _auto_trace_context:
591
+ _cleanup_auto_trace()
592
+
593
+ _stop_pricing_refresh()
594
+ provider = _get_provider()
595
+ if _active_processor:
596
+ try:
597
+ _active_processor.force_flush(timeout=flush_timeout)
598
+ finally:
599
+ _active_processor.shutdown()
600
+ provider.shutdown()
601
+ _started = False
602
+ _init_method = None
603
+
604
+
605
+ def _register_shutdown(provider: TracerProvider, processor: Optional[BatchSpanProcessor]) -> None:
606
+ global _registered_shutdown
607
+ if _registered_shutdown:
608
+ return
609
+
610
+ def _cleanup():
611
+ try:
612
+ if processor:
613
+ processor.force_flush()
614
+ processor.shutdown()
615
+ finally:
616
+ provider.shutdown()
617
+
618
+ atexit.register(_cleanup)
619
+ _registered_shutdown = True
620
+
621
+
622
+ def _resolve_service_name(service_name: Optional[str]) -> str:
623
+ """Resolve service.name using override, env, or inferred entrypoint."""
624
+ if service_name:
625
+ return service_name
626
+ env_name = os.getenv("OTEL_SERVICE_NAME") or os.getenv("SERVICE_NAME")
627
+ if env_name:
628
+ return env_name
629
+ # Use current working directory name
630
+ cwd_name = Path.cwd().name
631
+ if cwd_name:
632
+ return cwd_name
633
+ # Infer from entry script if available (e.g., "app.py" -> "app")
634
+ argv0 = sys.argv[0] if sys.argv else ""
635
+ if argv0 and argv0 not in ("-c", "-m"):
636
+ script_name = Path(argv0).name
637
+ if script_name:
638
+ return Path(script_name).stem or script_name
639
+ return "traccia_app"
640
+
641
+
642
+ def _get_provider() -> TracerProvider:
643
+ import traccia
644
+
645
+ return traccia.get_tracer_provider()
646
+
647
+
648
+ def _resolve_agent_config_path() -> Optional[str]:
649
+ """
650
+ Locate agent_config.json for users automatically:
651
+ 1) Respect AGENT_DASHBOARD_AGENT_CONFIG if set and file exists
652
+ 2) Use ./agent_config.json from current working directory if present
653
+ 3) Try to find agent_config.json adjacent to the first non-sdk caller
654
+ """
655
+ env_path = os.getenv("AGENT_DASHBOARD_AGENT_CONFIG")
656
+ if env_path:
657
+ path = Path(env_path)
658
+ if path.exists():
659
+ return str(path.resolve())
660
+
661
+ cwd_path = Path.cwd() / "agent_config.json"
662
+ if cwd_path.exists():
663
+ return str(cwd_path.resolve())
664
+
665
+ try:
666
+ for frame in inspect.stack():
667
+ frame_path = Path(frame.filename)
668
+ # Skip SDK internal files
669
+ if "traccia" in frame_path.parts:
670
+ continue
671
+ candidate = frame_path.parent / "agent_config.json"
672
+ if candidate.exists():
673
+ return str(candidate.resolve())
674
+ except Exception:
675
+ return None
676
+ return None
677
+
678
+
679
+ def _resolve_debug(cli_value: bool) -> bool:
680
+ raw = os.getenv("AGENT_DASHBOARD_DEBUG")
681
+ if raw is None:
682
+ return bool(cli_value)
683
+ return raw.strip().lower() in {"1", "true", "yes", "y", "on"}
684
+
685
+
686
+ def _resolve_tenant_id(cli_value: Optional[str]) -> str:
687
+ return (
688
+ cli_value
689
+ or os.getenv("AGENT_DASHBOARD_TENANT_ID")
690
+ or "study-agent-sf23jj56c34234"
691
+ )
692
+
693
+
694
+ def _resolve_project_id(cli_value: Optional[str]) -> str:
695
+ return cli_value or os.getenv("AGENT_DASHBOARD_PROJECT_ID") or "gmail"
696
+
697
+
698
+ def _combine_exporters(primary, secondary):
699
+ if primary is None:
700
+ return secondary
701
+ if secondary is None:
702
+ return primary
703
+
704
+ class _Multi:
705
+ def export(self, spans):
706
+ ok1 = primary.export(spans)
707
+ ok2 = secondary.export(spans)
708
+ return ok1 and ok2
709
+
710
+ def shutdown(self):
711
+ for exp in (primary, secondary):
712
+ if hasattr(exp, "shutdown"):
713
+ exp.shutdown()
714
+
715
+ return _Multi()
716
+
717
+
718
+ _pricing_refresh_stop: Optional[threading.Event] = None
719
+ _pricing_refresh_thread: Optional[threading.Thread] = None
720
+
721
+
722
+ def _start_pricing_refresh(cost_processor: Optional[CostAnnotatingProcessor], override, interval: Optional[int]) -> None:
723
+ global _pricing_refresh_stop, _pricing_refresh_thread
724
+ if not cost_processor or not interval or interval <= 0:
725
+ return
726
+ _pricing_refresh_stop = threading.Event()
727
+
728
+ def _loop():
729
+ while not _pricing_refresh_stop.is_set():
730
+ time.sleep(interval)
731
+ if _pricing_refresh_stop.is_set():
732
+ break
733
+ try:
734
+ table, source = pricing_config.load_pricing_with_source(override)
735
+ cost_processor.update_pricing_table(table, pricing_source=source)
736
+ except Exception:
737
+ continue
738
+
739
+ _pricing_refresh_thread = threading.Thread(target=_loop, daemon=True)
740
+ _pricing_refresh_thread.start()
741
+
742
+
743
+ def _stop_pricing_refresh() -> None:
744
+ if _pricing_refresh_stop:
745
+ _pricing_refresh_stop.set()
746
+ if _pricing_refresh_thread:
747
+ _pricing_refresh_thread.join(timeout=1)
748
+