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/config.py ADDED
@@ -0,0 +1,699 @@
1
+ """Configuration management with Pydantic models and validation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from pathlib import Path
7
+ from typing import Any, Dict, Optional, Literal
8
+ from pydantic import BaseModel, Field, field_validator, model_validator, HttpUrl
9
+
10
+ from traccia.errors import ConfigError, ValidationError
11
+
12
+
13
+ # Environment variable mapping
14
+ ENV_VAR_MAPPING = {
15
+ # Tracing config
16
+ "api_key": ["TRACCIA_API_KEY", "AGENT_DASHBOARD_API_KEY"],
17
+ "endpoint": ["TRACCIA_ENDPOINT", "AGENT_DASHBOARD_ENDPOINT"],
18
+ "sample_rate": ["TRACCIA_SAMPLE_RATE", "AGENT_DASHBOARD_SAMPLE_RATE"],
19
+ "auto_start_trace": ["TRACCIA_AUTO_START_TRACE", "AGENT_DASHBOARD_AUTO_START_TRACE"],
20
+ "auto_trace_name": ["TRACCIA_AUTO_TRACE_NAME"],
21
+ "use_otlp": ["TRACCIA_USE_OTLP"],
22
+ "service_name": ["TRACCIA_SERVICE_NAME"],
23
+
24
+ # Exporter config
25
+ "enable_console": ["TRACCIA_ENABLE_CONSOLE", "AGENT_DASHBOARD_ENABLE_CONSOLE_EXPORTER"],
26
+ "enable_file": ["TRACCIA_ENABLE_FILE", "AGENT_DASHBOARD_ENABLE_FILE_EXPORTER"],
27
+ "file_exporter_path": ["TRACCIA_FILE_PATH"],
28
+ "reset_trace_file": ["TRACCIA_RESET_TRACE_FILE"],
29
+
30
+ # Instrumentation config
31
+ "enable_patching": ["TRACCIA_ENABLE_PATCHING", "AGENT_DASHBOARD_ENABLE_PATCHING"],
32
+ "enable_token_counting": ["TRACCIA_ENABLE_TOKEN_COUNTING", "AGENT_DASHBOARD_ENABLE_TOKEN_COUNTING"],
33
+ "enable_costs": ["TRACCIA_ENABLE_COSTS", "AGENT_DASHBOARD_ENABLE_COSTS"],
34
+ "auto_instrument_tools": ["TRACCIA_AUTO_INSTRUMENT_TOOLS"],
35
+ "max_tool_spans": ["TRACCIA_MAX_TOOL_SPANS"],
36
+ "max_span_depth": ["TRACCIA_MAX_SPAN_DEPTH"],
37
+ "openai_agents": ["TRACCIA_OPENAI_AGENTS"],
38
+
39
+ # Rate limiting & Batching
40
+ "max_spans_per_second": ["TRACCIA_MAX_SPANS_PER_SECOND"],
41
+ "max_queue_size": ["TRACCIA_MAX_QUEUE_SIZE"],
42
+ "max_block_ms": ["TRACCIA_MAX_BLOCK_MS"],
43
+ "max_export_batch_size": ["TRACCIA_MAX_EXPORT_BATCH_SIZE"],
44
+ "schedule_delay_millis": ["TRACCIA_SCHEDULE_DELAY_MILLIS"],
45
+
46
+ # Runtime metadata
47
+ "session_id": ["TRACCIA_SESSION_ID"],
48
+ "user_id": ["TRACCIA_USER_ID"],
49
+ "tenant_id": ["TRACCIA_TENANT_ID"],
50
+ "project_id": ["TRACCIA_PROJECT_ID"],
51
+ "agent_id": ["TRACCIA_AGENT_ID", "AGENT_DASHBOARD_AGENT_ID"],
52
+
53
+ # Logging
54
+ "debug": ["TRACCIA_DEBUG"],
55
+ "enable_span_logging": ["TRACCIA_ENABLE_SPAN_LOGGING"],
56
+
57
+ # Advanced
58
+ "attr_truncation_limit": ["TRACCIA_ATTR_TRUNCATION_LIMIT"],
59
+ }
60
+
61
+
62
+ class TracingConfig(BaseModel):
63
+ """Tracing configuration section."""
64
+
65
+ api_key: Optional[str] = Field(
66
+ default=None,
67
+ description="API key for authentication (required for SaaS, optional for open-source)"
68
+ )
69
+ endpoint: Optional[str] = Field(
70
+ default=None,
71
+ description="Endpoint URL for trace ingestion"
72
+ )
73
+ sample_rate: float = Field(
74
+ default=1.0,
75
+ ge=0.0,
76
+ le=1.0,
77
+ description="Sampling rate (0.0 to 1.0)"
78
+ )
79
+ auto_start_trace: bool = Field(
80
+ default=True,
81
+ description="Automatically start a root trace on init"
82
+ )
83
+ auto_trace_name: str = Field(
84
+ default="root",
85
+ description="Name for the auto-started root trace"
86
+ )
87
+ use_otlp: bool = Field(
88
+ default=True,
89
+ description="Use OTLP exporter (set to false for console/file exporters)"
90
+ )
91
+ service_name: Optional[str] = Field(
92
+ default=None,
93
+ description="Service name for the application"
94
+ )
95
+
96
+
97
+ class ExporterConfig(BaseModel):
98
+ """Exporter configuration section."""
99
+
100
+ enable_console: bool = Field(
101
+ default=False,
102
+ description="Enable console exporter for debugging"
103
+ )
104
+ enable_file: bool = Field(
105
+ default=False,
106
+ description="Enable file exporter to write traces to local file"
107
+ )
108
+ file_exporter_path: str = Field(
109
+ default="traces.jsonl",
110
+ description="File path for file exporter"
111
+ )
112
+ reset_trace_file: bool = Field(
113
+ default=False,
114
+ description="Reset/clear trace file on initialization"
115
+ )
116
+
117
+ @model_validator(mode='after')
118
+ def check_single_exporter(self) -> 'ExporterConfig':
119
+ """Ensure only one exporter is enabled at a time."""
120
+ enabled_count = sum([
121
+ self.enable_console,
122
+ self.enable_file,
123
+ ])
124
+ if enabled_count > 1:
125
+ raise ValidationError(
126
+ "Only one exporter can be enabled at a time. "
127
+ "Choose either console or file exporter (OTLP is controlled by use_otlp in tracing section).",
128
+ details={
129
+ "enable_console": self.enable_console,
130
+ "enable_file": self.enable_file,
131
+ }
132
+ )
133
+ return self
134
+
135
+
136
+ class InstrumentationConfig(BaseModel):
137
+ """Instrumentation configuration section."""
138
+
139
+ enable_patching: bool = Field(
140
+ default=True,
141
+ description="Auto-patch popular libraries (OpenAI, Anthropic, requests)"
142
+ )
143
+ enable_token_counting: bool = Field(
144
+ default=True,
145
+ description="Count tokens for LLM calls"
146
+ )
147
+ enable_costs: bool = Field(
148
+ default=True,
149
+ description="Calculate costs for LLM calls"
150
+ )
151
+ auto_instrument_tools: bool = Field(
152
+ default=False,
153
+ description="Automatically instrument tool calls"
154
+ )
155
+ max_tool_spans: int = Field(
156
+ default=100,
157
+ gt=0,
158
+ description="Maximum number of tool spans to create"
159
+ )
160
+ max_span_depth: int = Field(
161
+ default=10,
162
+ gt=0,
163
+ description="Maximum depth of nested spans"
164
+ )
165
+ openai_agents: bool = Field(
166
+ default=True,
167
+ description="Auto-install OpenAI Agents SDK integration when available"
168
+ )
169
+
170
+
171
+ class RateLimitConfig(BaseModel):
172
+ """Rate limiting and batching configuration section."""
173
+
174
+ max_spans_per_second: Optional[float] = Field(
175
+ default=None,
176
+ gt=0,
177
+ description="Maximum spans per second (None = unlimited)"
178
+ )
179
+ max_queue_size: int = Field(
180
+ default=5000,
181
+ gt=0,
182
+ description="Maximum queue size for buffered spans"
183
+ )
184
+ max_block_ms: int = Field(
185
+ default=100,
186
+ ge=0,
187
+ description="Maximum milliseconds to block before dropping spans"
188
+ )
189
+ max_export_batch_size: int = Field(
190
+ default=512,
191
+ gt=0,
192
+ description="Maximum number of spans in a single export batch"
193
+ )
194
+ schedule_delay_millis: int = Field(
195
+ default=5000,
196
+ gt=0,
197
+ description="Delay in milliseconds between export batches"
198
+ )
199
+
200
+
201
+ class LoggingConfig(BaseModel):
202
+ """Logging configuration section."""
203
+
204
+ debug: bool = Field(
205
+ default=False,
206
+ description="Enable debug logging"
207
+ )
208
+ enable_span_logging: bool = Field(
209
+ default=False,
210
+ description="Enable span-level logging for debugging"
211
+ )
212
+
213
+
214
+ class RuntimeConfig(BaseModel):
215
+ """Runtime metadata configuration section."""
216
+
217
+ session_id: Optional[str] = Field(
218
+ default=None,
219
+ description="Session identifier for grouping traces"
220
+ )
221
+ user_id: Optional[str] = Field(
222
+ default=None,
223
+ description="User identifier for the current session"
224
+ )
225
+ tenant_id: Optional[str] = Field(
226
+ default=None,
227
+ description="Tenant/organization identifier"
228
+ )
229
+ project_id: Optional[str] = Field(
230
+ default=None,
231
+ description="Project identifier"
232
+ )
233
+ agent_id: Optional[str] = Field(
234
+ default=None,
235
+ description="Agent identifier for the current session"
236
+ )
237
+
238
+
239
+ class AdvancedConfig(BaseModel):
240
+ """Advanced configuration options."""
241
+
242
+ attr_truncation_limit: Optional[int] = Field(
243
+ default=None,
244
+ gt=0,
245
+ description="Maximum length for attribute values (None = no limit)"
246
+ )
247
+
248
+
249
+ class TracciaConfig(BaseModel):
250
+ """
251
+ Complete Traccia SDK configuration.
252
+
253
+ This model validates and merges configuration from multiple sources:
254
+ 1. Config file (traccia.toml)
255
+ 2. Environment variables
256
+ 3. Explicit parameters
257
+ """
258
+
259
+ tracing: TracingConfig = Field(default_factory=TracingConfig)
260
+ exporters: ExporterConfig = Field(default_factory=ExporterConfig)
261
+ instrumentation: InstrumentationConfig = Field(default_factory=InstrumentationConfig)
262
+ rate_limiting: RateLimitConfig = Field(default_factory=RateLimitConfig)
263
+ runtime: RuntimeConfig = Field(default_factory=RuntimeConfig)
264
+ logging: LoggingConfig = Field(default_factory=LoggingConfig)
265
+ advanced: AdvancedConfig = Field(default_factory=AdvancedConfig)
266
+
267
+ @model_validator(mode='after')
268
+ def validate_complete_config(self) -> 'TracciaConfig':
269
+ """Validate the complete configuration for conflicts."""
270
+ # If OTLP is disabled, at least one other exporter must be enabled
271
+ if not self.tracing.use_otlp:
272
+ if not (self.exporters.enable_console or self.exporters.enable_file):
273
+ raise ConfigError(
274
+ "When use_otlp is false, you must enable either console or file exporter.",
275
+ details={
276
+ "use_otlp": self.tracing.use_otlp,
277
+ "enable_console": self.exporters.enable_console,
278
+ "enable_file": self.exporters.enable_file,
279
+ }
280
+ )
281
+
282
+ return self
283
+
284
+ def to_flat_dict(self) -> Dict[str, Any]:
285
+ """Convert to flat dictionary for backward compatibility."""
286
+ return {
287
+ # Tracing
288
+ "api_key": self.tracing.api_key,
289
+ "endpoint": self.tracing.endpoint,
290
+ "sample_rate": self.tracing.sample_rate,
291
+ "auto_start_trace": self.tracing.auto_start_trace,
292
+ "auto_trace_name": self.tracing.auto_trace_name,
293
+ "use_otlp": self.tracing.use_otlp,
294
+ "service_name": self.tracing.service_name,
295
+ # Exporters
296
+ "enable_console": self.exporters.enable_console,
297
+ "enable_file": self.exporters.enable_file,
298
+ "file_exporter_path": self.exporters.file_exporter_path,
299
+ "reset_trace_file": self.exporters.reset_trace_file,
300
+ # Instrumentation
301
+ "enable_patching": self.instrumentation.enable_patching,
302
+ "enable_token_counting": self.instrumentation.enable_token_counting,
303
+ "enable_costs": self.instrumentation.enable_costs,
304
+ "auto_instrument_tools": self.instrumentation.auto_instrument_tools,
305
+ "max_tool_spans": self.instrumentation.max_tool_spans,
306
+ "max_span_depth": self.instrumentation.max_span_depth,
307
+ "openai_agents": self.instrumentation.openai_agents,
308
+ # Rate limiting & Batching
309
+ "max_spans_per_second": self.rate_limiting.max_spans_per_second,
310
+ "max_queue_size": self.rate_limiting.max_queue_size,
311
+ "max_block_ms": self.rate_limiting.max_block_ms,
312
+ "max_export_batch_size": self.rate_limiting.max_export_batch_size,
313
+ "schedule_delay_millis": self.rate_limiting.schedule_delay_millis,
314
+ # Runtime
315
+ "session_id": self.runtime.session_id,
316
+ "user_id": self.runtime.user_id,
317
+ "tenant_id": self.runtime.tenant_id,
318
+ "project_id": self.runtime.project_id,
319
+ "agent_id": self.runtime.agent_id,
320
+ # Logging
321
+ "debug": self.logging.debug,
322
+ "enable_span_logging": self.logging.enable_span_logging,
323
+ # Advanced
324
+ "attr_truncation_limit": self.advanced.attr_truncation_limit,
325
+ }
326
+
327
+
328
+ def load_dotenv(path: str = ".env") -> None:
329
+ """Minimal .env loader (no external dependency)."""
330
+ if not os.path.exists(path):
331
+ return
332
+ try:
333
+ with open(path, "r", encoding="utf-8") as f:
334
+ for line in f:
335
+ line = line.strip()
336
+ if not line or line.startswith("#"):
337
+ continue
338
+ if "=" not in line:
339
+ continue
340
+ key, value = line.split("=", 1)
341
+ key, value = key.strip(), value.strip().strip("\"'")
342
+ if key and key not in os.environ:
343
+ os.environ[key] = value
344
+ except Exception:
345
+ # Fail silently; this loader is best-effort.
346
+ return
347
+
348
+
349
+ def find_config_file() -> Optional[str]:
350
+ """
351
+ Find traccia.toml config file in standard locations.
352
+
353
+ Lookup order:
354
+ 1. ./traccia.toml (current directory)
355
+ 2. ~/.traccia/config.toml (user home)
356
+
357
+ Returns:
358
+ Path to config file if found, None otherwise
359
+ """
360
+ # Check current directory
361
+ cwd_config = Path.cwd() / "traccia.toml"
362
+ if cwd_config.exists():
363
+ return str(cwd_config)
364
+
365
+ # Check user home directory
366
+ home_config = Path.home() / ".traccia" / "config.toml"
367
+ if home_config.exists():
368
+ return str(home_config)
369
+
370
+ return None
371
+
372
+
373
+ def load_toml_config(path: str) -> Dict[str, Any]:
374
+ """
375
+ Load configuration from a TOML file.
376
+
377
+ Args:
378
+ path: Path to the TOML config file
379
+
380
+ Returns:
381
+ Dictionary with nested config structure
382
+ """
383
+ if not os.path.exists(path):
384
+ return {}
385
+
386
+ try:
387
+ # Try to import tomli for Python 3.11+, fall back to toml
388
+ try:
389
+ import tomli as toml_lib
390
+ with open(path, "rb") as f:
391
+ data = toml_lib.load(f)
392
+ except ImportError:
393
+ try:
394
+ import toml as toml_lib # type: ignore
395
+ with open(path, "r", encoding="utf-8") as f:
396
+ data = toml_lib.load(f)
397
+ except ImportError:
398
+ raise ConfigError(
399
+ "No TOML library available. Install tomli or toml: pip install tomli"
400
+ )
401
+
402
+ return data
403
+
404
+ except Exception as e:
405
+ if isinstance(e, ConfigError):
406
+ raise
407
+ raise ConfigError(f"Failed to load config file: {e}")
408
+
409
+
410
+ def get_env_value(config_key: str) -> Optional[str]:
411
+ """
412
+ Get environment variable value for a config key.
413
+
414
+ Tries multiple environment variable names in order of preference.
415
+ """
416
+ env_vars = ENV_VAR_MAPPING.get(config_key, [])
417
+ for env_var in env_vars:
418
+ value = os.getenv(env_var)
419
+ if value is not None:
420
+ return value
421
+ return None
422
+
423
+
424
+ def load_config_from_env(flat: bool = False) -> Dict[str, Any]:
425
+ """
426
+ Load configuration from environment variables.
427
+
428
+ Args:
429
+ flat: If True, return flat dictionary for backward compatibility
430
+
431
+ Returns:
432
+ Dictionary of config values from environment (nested structure by default)
433
+ """
434
+ env_config = {
435
+ "tracing": {},
436
+ "exporters": {},
437
+ "instrumentation": {},
438
+ "rate_limiting": {},
439
+ "runtime": {},
440
+ "logging": {},
441
+ "advanced": {},
442
+ }
443
+
444
+ # Tracing section
445
+ for key in ["api_key", "endpoint", "sample_rate", "auto_start_trace", "auto_trace_name", "use_otlp", "service_name"]:
446
+ value = get_env_value(key)
447
+ if value is not None:
448
+ # Convert string to appropriate type
449
+ if key in ["auto_start_trace", "use_otlp"]:
450
+ env_config["tracing"][key] = value.lower() in ("true", "1", "yes")
451
+ elif key == "sample_rate":
452
+ try:
453
+ env_config["tracing"][key] = float(value)
454
+ except ValueError:
455
+ raise ConfigError(f"Invalid sample_rate value: {value}. Must be a float between 0.0 and 1.0.")
456
+ else:
457
+ env_config["tracing"][key] = value
458
+
459
+ # Exporters section
460
+ for key in ["enable_console", "enable_file", "file_exporter_path", "reset_trace_file"]:
461
+ value = get_env_value(key)
462
+ if value is not None:
463
+ if key in ["enable_console", "enable_file", "reset_trace_file"]:
464
+ env_config["exporters"][key] = value.lower() in ("true", "1", "yes")
465
+ else:
466
+ env_config["exporters"][key] = value
467
+
468
+ # Instrumentation section
469
+ for key in ["enable_patching", "enable_token_counting", "enable_costs", "auto_instrument_tools", "openai_agents"]:
470
+ value = get_env_value(key)
471
+ if value is not None:
472
+ env_config["instrumentation"][key] = value.lower() in ("true", "1", "yes")
473
+
474
+ for key in ["max_tool_spans", "max_span_depth"]:
475
+ value = get_env_value(key)
476
+ if value is not None:
477
+ try:
478
+ env_config["instrumentation"][key] = int(value)
479
+ except ValueError:
480
+ raise ConfigError(f"Invalid {key} value: {value}. Must be an integer.")
481
+
482
+ # Rate limiting section
483
+ for key in ["max_spans_per_second"]:
484
+ value = get_env_value(key)
485
+ if value is not None:
486
+ try:
487
+ env_config["rate_limiting"][key] = float(value) if value else None
488
+ except ValueError:
489
+ raise ConfigError(f"Invalid {key} value: {value}. Must be a number.")
490
+
491
+ for key in ["max_queue_size", "max_block_ms", "max_export_batch_size", "schedule_delay_millis"]:
492
+ value = get_env_value(key)
493
+ if value is not None:
494
+ try:
495
+ env_config["rate_limiting"][key] = int(value)
496
+ except ValueError:
497
+ raise ConfigError(f"Invalid {key} value: {value}. Must be a number.")
498
+
499
+ # Runtime section
500
+ for key in ["session_id", "user_id", "tenant_id", "project_id", "agent_id"]:
501
+ value = get_env_value(key)
502
+ if value is not None:
503
+ env_config["runtime"][key] = value
504
+
505
+ # Logging section
506
+ for key in ["debug", "enable_span_logging"]:
507
+ value = get_env_value(key)
508
+ if value is not None:
509
+ env_config["logging"][key] = value.lower() in ("true", "1", "yes")
510
+
511
+ # Advanced section
512
+ value = get_env_value("attr_truncation_limit")
513
+ if value is not None:
514
+ try:
515
+ env_config["advanced"]["attr_truncation_limit"] = int(value)
516
+ except ValueError:
517
+ raise ConfigError(f"Invalid attr_truncation_limit value: {value}. Must be an integer.")
518
+
519
+ # Remove empty sections
520
+ nested_result = {k: v for k, v in env_config.items() if v}
521
+
522
+ # Flatten if requested (for backward compatibility)
523
+ if flat:
524
+ flat_result = {}
525
+ for section, values in nested_result.items():
526
+ flat_result.update(values)
527
+ return flat_result
528
+
529
+ return nested_result
530
+
531
+
532
+ def merge_configs(base: Dict[str, Any], override: Dict[str, Any]) -> Dict[str, Any]:
533
+ """
534
+ Deep merge two config dictionaries.
535
+
536
+ Args:
537
+ base: Base configuration
538
+ override: Override configuration (takes precedence)
539
+
540
+ Returns:
541
+ Merged configuration
542
+ """
543
+ result = base.copy()
544
+ for key, value in override.items():
545
+ if key in result and isinstance(result[key], dict) and isinstance(value, dict):
546
+ result[key] = merge_configs(result[key], value)
547
+ else:
548
+ result[key] = value
549
+ return result
550
+
551
+
552
+ def load_config(
553
+ config_file: Optional[str] = None,
554
+ overrides: Optional[Dict[str, Any]] = None
555
+ ) -> TracciaConfig:
556
+ """
557
+ Load and validate Traccia configuration from multiple sources.
558
+
559
+ Priority (highest to lowest):
560
+ 1. Explicit overrides (passed as parameters)
561
+ 2. Environment variables
562
+ 3. Config file (./traccia.toml or ~/.traccia/config.toml)
563
+ 4. Defaults
564
+
565
+ Args:
566
+ config_file: Optional explicit path to config file
567
+ overrides: Optional dict of explicit parameter overrides
568
+
569
+ Returns:
570
+ Validated TracciaConfig instance
571
+
572
+ Raises:
573
+ ConfigError: If configuration is invalid or conflicting
574
+ """
575
+ merged_config: Dict[str, Any] = {}
576
+
577
+ # 1. Load from config file (lowest priority)
578
+ if config_file:
579
+ file_config = load_toml_config(config_file)
580
+ merged_config = merge_configs(merged_config, file_config)
581
+ else:
582
+ # Try to find config file automatically
583
+ found_config = find_config_file()
584
+ if found_config:
585
+ file_config = load_toml_config(found_config)
586
+ merged_config = merge_configs(merged_config, file_config)
587
+
588
+ # 2. Override with environment variables (medium priority)
589
+ env_config = load_config_from_env()
590
+ merged_config = merge_configs(merged_config, env_config)
591
+
592
+ # 3. Override with explicit parameters (highest priority)
593
+ if overrides:
594
+ merged_config = merge_configs(merged_config, overrides)
595
+
596
+ # 4. Create and validate Pydantic model
597
+ try:
598
+ return TracciaConfig(**merged_config)
599
+ except Exception as e:
600
+ raise ConfigError(f"Configuration validation failed: {e}")
601
+
602
+
603
+ def validate_config(
604
+ config_file: Optional[str] = None,
605
+ overrides: Optional[Dict[str, Any]] = None
606
+ ) -> tuple[bool, str, Optional[TracciaConfig]]:
607
+ """
608
+ Validate configuration without loading it.
609
+
610
+ Used by the `traccia doctor` CLI command.
611
+
612
+ Args:
613
+ config_file: Optional explicit path to config file
614
+ overrides: Optional dict of explicit parameter overrides
615
+
616
+ Returns:
617
+ Tuple of (is_valid, message, config_or_none)
618
+ """
619
+ try:
620
+ config = load_config(config_file=config_file, overrides=overrides)
621
+ return True, "Configuration is valid", config
622
+ except ConfigError as e:
623
+ return False, f"Configuration error: {e}", None
624
+ except Exception as e:
625
+ return False, f"Unexpected error: {e}", None
626
+
627
+
628
+ # Backward compatibility functions
629
+ def load_config_with_priority(
630
+ config_file: Optional[str] = None,
631
+ overrides: Optional[Dict[str, Any]] = None
632
+ ) -> Dict[str, Any]:
633
+ """
634
+ Legacy function for backward compatibility.
635
+
636
+ Returns flattened config dictionary instead of Pydantic model.
637
+ Accepts flat overrides and converts them to nested format.
638
+ """
639
+ # Convert flat overrides to nested format
640
+ nested_overrides = None
641
+ if overrides:
642
+ nested_overrides = {
643
+ "tracing": {},
644
+ "exporters": {},
645
+ "instrumentation": {},
646
+ "rate_limiting": {},
647
+ "runtime": {},
648
+ "logging": {},
649
+ "advanced": {}
650
+ }
651
+
652
+ # Map flat keys to nested structure
653
+ flat_to_nested = {
654
+ # Tracing
655
+ "api_key": ("tracing", "api_key"),
656
+ "endpoint": ("tracing", "endpoint"),
657
+ "sample_rate": ("tracing", "sample_rate"),
658
+ "auto_start_trace": ("tracing", "auto_start_trace"),
659
+ "auto_trace_name": ("tracing", "auto_trace_name"),
660
+ "use_otlp": ("tracing", "use_otlp"),
661
+ "service_name": ("tracing", "service_name"),
662
+ # Exporters
663
+ "enable_console": ("exporters", "enable_console"),
664
+ "enable_file": ("exporters", "enable_file"),
665
+ "file_exporter_path": ("exporters", "file_exporter_path"),
666
+ "reset_trace_file": ("exporters", "reset_trace_file"),
667
+ # Instrumentation
668
+ "enable_patching": ("instrumentation", "enable_patching"),
669
+ "enable_token_counting": ("instrumentation", "enable_token_counting"),
670
+ "enable_costs": ("instrumentation", "enable_costs"),
671
+ "auto_instrument_tools": ("instrumentation", "auto_instrument_tools"),
672
+ "max_tool_spans": ("instrumentation", "max_tool_spans"),
673
+ "max_span_depth": ("instrumentation", "max_span_depth"),
674
+ # Rate limiting & Batching
675
+ "max_spans_per_second": ("rate_limiting", "max_spans_per_second"),
676
+ "max_queue_size": ("rate_limiting", "max_queue_size"),
677
+ "max_block_ms": ("rate_limiting", "max_block_ms"),
678
+ "max_export_batch_size": ("rate_limiting", "max_export_batch_size"),
679
+ "schedule_delay_millis": ("rate_limiting", "schedule_delay_millis"),
680
+ # Runtime
681
+ "session_id": ("runtime", "session_id"),
682
+ "user_id": ("runtime", "user_id"),
683
+ "tenant_id": ("runtime", "tenant_id"),
684
+ "project_id": ("runtime", "project_id"),
685
+ "agent_id": ("runtime", "agent_id"),
686
+ # Logging
687
+ "debug": ("logging", "debug"),
688
+ "enable_span_logging": ("logging", "enable_span_logging"),
689
+ # Advanced
690
+ "attr_truncation_limit": ("advanced", "attr_truncation_limit"),
691
+ }
692
+
693
+ for flat_key, value in overrides.items():
694
+ if flat_key in flat_to_nested:
695
+ section, nested_key = flat_to_nested[flat_key]
696
+ nested_overrides[section][nested_key] = value
697
+
698
+ config = load_config(config_file=config_file, overrides=nested_overrides)
699
+ return config.to_flat_dict()