traceact 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.
traceact/trace.py ADDED
@@ -0,0 +1,1176 @@
1
+ # trace.py
2
+ #
3
+ # Defines ActionTrace — the central object in TraceAct.
4
+ #
5
+ # An ActionTrace is the live record of one action as it executes. It accumulates
6
+ # steps, events, touches, inputs, outputs, and errors. When the action finishes,
7
+ # it serialises itself to a dict and writes to the configured sinks.
8
+ #
9
+ # How a trace is born:
10
+ # 1. A @traced_action decorator fires, or ActionTrace.start() is called.
11
+ # 2. The active trace is read from the ContextVar (context.py).
12
+ # 3. If there is an active trace, the new trace becomes a child of it.
13
+ # 4. If there is no active trace, the new trace becomes a root trace.
14
+ # 5. The new trace is pushed onto the ContextVar as the new active trace.
15
+ # 6. The wrapped function runs.
16
+ # 7. When the function exits (success or exception), the trace is finished.
17
+ # 8. The finished trace is written to sinks.
18
+ # 9. A compact child summary is sent to the parent trace (if one exists).
19
+ # 10. The ContextVar is restored to whatever it held before.
20
+ #
21
+ # How deduplication works:
22
+ # Touches and errors are stored in two forms:
23
+ # - A public list (_touches, _errors) for readable output.
24
+ # - An internal set (_touch_index, _error_index) for O(1) membership checks.
25
+ # When a new touch or error arrives, it is checked against the index first.
26
+ # If it is already there, nothing is added to the list. This prevents repeated
27
+ # events (e.g. hitting the same DB table 50 times) from filling the trace summary
28
+ # with redundant entries.
29
+ #
30
+ # How config and budget resolution works:
31
+ # Both TraceConfig and TraceBudget use None to mean "not specified." The
32
+ # _resolve_config() and _resolve_budget() functions apply a three-level merge:
33
+ # Package defaults → package-level configure() → parent trace → local override
34
+ # The result is a fully resolved object with no None fields — an "_Effective"
35
+ # version that the trace can use directly without further checking.
36
+
37
+ import json
38
+ from datetime import datetime, timezone
39
+ from typing import Any, Dict, List, Optional, Union
40
+
41
+ from traceact.budget import BUDGET_DEFAULTS, TraceBudget
42
+ from traceact.config import (
43
+ TraceConfig,
44
+ get_package_budget,
45
+ get_package_config,
46
+ get_package_sinks,
47
+ )
48
+ from traceact.context import SKIP, get_active_trace, is_skip, pop_trace, push_trace
49
+ from traceact.helpers import TraceHelpersMixin
50
+ from traceact.ids import new_event_id, new_step_id, new_trace_id
51
+ from traceact.sinks import ConsoleSink, buffer_record, flush_buffer
52
+
53
+
54
+ # ---------------------------------------------------------------------------
55
+ # Sensitive field name patterns
56
+ # ---------------------------------------------------------------------------
57
+ #
58
+ # When capture_inputs is enabled and redact_by_default is True, any argument
59
+ # whose name matches one of these patterns has its value replaced with the
60
+ # string "[redacted]" before being stored. This prevents passwords, tokens, and
61
+ # other secrets from appearing in trace output.
62
+ #
63
+ # Matching is case-insensitive and uses substring matching: "user_password"
64
+ # matches because "password" appears in it.
65
+
66
+ _SENSITIVE_PATTERNS = frozenset({
67
+ "password", "passwd", "pwd",
68
+ "secret", "token", "api_key", "apikey",
69
+ "private_key", "privatekey",
70
+ "access_key", "accesskey",
71
+ "auth", "credential", "credentials",
72
+ "credit_card", "card_number", "cvv", "ssn",
73
+ })
74
+
75
+
76
+ def _is_sensitive(field_name: str) -> bool:
77
+ """
78
+ Return True if the field name matches a known sensitive pattern.
79
+
80
+ Args:
81
+ field_name: The argument or field name to check (e.g. "user_password").
82
+
83
+ Returns:
84
+ True if any sensitive pattern is a substring of the lowercased name.
85
+ """
86
+ lower = field_name.lower()
87
+ return any(pattern in lower for pattern in _SENSITIVE_PATTERNS)
88
+
89
+
90
+ # ---------------------------------------------------------------------------
91
+ # Event-kind to touch-kind mapping
92
+ # ---------------------------------------------------------------------------
93
+ #
94
+ # When an event is recorded, TraceAct automatically derives a touch from it if
95
+ # the event has a target. The touch's kind is more specific than the event's
96
+ # kind: a "db" event touching "notes" produces a touch with kind "db_table".
97
+ #
98
+ # This mapping defines those translations. Kinds not in the mapping fall back to
99
+ # using the event kind directly as the touch kind.
100
+
101
+ _EVENT_TO_TOUCH_KIND: Dict[str, str] = {
102
+ "db": "db_table",
103
+ "http": "http_endpoint",
104
+ "file": "file",
105
+ "model": "model",
106
+ "cache": "cache_key",
107
+ "queue": "queue",
108
+ "auth": "auth_provider",
109
+ "email": "email_service",
110
+ }
111
+
112
+
113
+ # ---------------------------------------------------------------------------
114
+ # _EffectiveConfig and _EffectiveBudget
115
+ # ---------------------------------------------------------------------------
116
+ #
117
+ # These are simple containers for fully resolved settings — every field is
118
+ # guaranteed to be non-None. They are internal and never exposed publicly.
119
+ # The resolution functions below produce them from the three-layer merge.
120
+
121
+ class _EffectiveConfig:
122
+ """Fully resolved TraceConfig with no None fields."""
123
+ __slots__ = (
124
+ "enabled", "sink_mode", "strict",
125
+ "redact_by_default", "capture_inputs", "capture_outputs",
126
+ )
127
+
128
+ def __init__(
129
+ self,
130
+ enabled: bool,
131
+ sink_mode: str,
132
+ strict: bool,
133
+ redact_by_default: bool,
134
+ capture_inputs: Any,
135
+ capture_outputs: bool,
136
+ ) -> None:
137
+ self.enabled = enabled
138
+ self.sink_mode = sink_mode
139
+ self.strict = strict
140
+ self.redact_by_default = redact_by_default
141
+ self.capture_inputs = capture_inputs
142
+ self.capture_outputs = capture_outputs
143
+
144
+
145
+ class _EffectiveBudget:
146
+ """Fully resolved TraceBudget with no None fields."""
147
+ __slots__ = (
148
+ "max_events", "max_steps", "max_depth",
149
+ "max_payload_bytes", "sample_rate", "always_trace_errors",
150
+ )
151
+
152
+ def __init__(
153
+ self,
154
+ max_events: int,
155
+ max_steps: int,
156
+ max_depth: int,
157
+ max_payload_bytes: int,
158
+ sample_rate: float,
159
+ always_trace_errors: bool,
160
+ ) -> None:
161
+ self.max_events = max_events
162
+ self.max_steps = max_steps
163
+ self.max_depth = max_depth
164
+ self.max_payload_bytes = max_payload_bytes
165
+ self.sample_rate = sample_rate
166
+ self.always_trace_errors = always_trace_errors
167
+
168
+
169
+ # ---------------------------------------------------------------------------
170
+ # Resolution helpers
171
+ # ---------------------------------------------------------------------------
172
+
173
+ def _resolve_config(
174
+ local_override: Optional[TraceConfig],
175
+ parent: Optional["ActionTrace"],
176
+ ) -> _EffectiveConfig:
177
+ """
178
+ Build a fully resolved _EffectiveConfig by merging (lowest to highest):
179
+ 1. Package defaults (hard-coded in this function)
180
+ 2. Package-level config from configure()
181
+ 3. Local override (from the @traced_action decorator or ActionTrace.start())
182
+
183
+ Parent config is not currently inherited (config is package-scoped). Budget
184
+ is the thing that inherits from parent to child. This may change in a future
185
+ version.
186
+
187
+ Args:
188
+ local_override: The TraceConfig passed to the decorator or start(), if any.
189
+ parent: The parent ActionTrace, if this is a child trace.
190
+
191
+ Returns:
192
+ An _EffectiveConfig with every field filled in.
193
+ """
194
+ # Start with hard-coded defaults.
195
+ enabled = True
196
+ sink_mode = "buffered"
197
+ strict = False
198
+ redact_by_default = True
199
+ capture_inputs: Any = False # safe default: no automatic capture
200
+ capture_outputs = True
201
+
202
+ # Apply package-level config from configure(), if set.
203
+ pkg = get_package_config()
204
+ if pkg is not None:
205
+ if pkg.enabled is not None: enabled = pkg.enabled
206
+ if pkg.sink_mode is not None: sink_mode = pkg.sink_mode
207
+ if pkg.strict is not None: strict = pkg.strict
208
+ if pkg.redact_by_default is not None: redact_by_default = pkg.redact_by_default
209
+ if pkg.capture_inputs is not None: capture_inputs = pkg.capture_inputs
210
+ if pkg.capture_outputs is not None: capture_outputs = pkg.capture_outputs
211
+
212
+ # Apply local override from this specific decorator or start() call.
213
+ if local_override is not None:
214
+ if local_override.enabled is not None: enabled = local_override.enabled
215
+ if local_override.sink_mode is not None: sink_mode = local_override.sink_mode
216
+ if local_override.strict is not None: strict = local_override.strict
217
+ if local_override.redact_by_default is not None: redact_by_default = local_override.redact_by_default
218
+ if local_override.capture_inputs is not None: capture_inputs = local_override.capture_inputs
219
+ if local_override.capture_outputs is not None: capture_outputs = local_override.capture_outputs
220
+
221
+ # Safety: if the package-level config explicitly set capture_inputs=False,
222
+ # that is the global kill switch and it cannot be overridden by a decorator.
223
+ # We re-apply the package setting last to enforce this.
224
+ if pkg is not None and pkg.capture_inputs is False:
225
+ capture_inputs = False
226
+
227
+ return _EffectiveConfig(
228
+ enabled=enabled,
229
+ sink_mode=sink_mode,
230
+ strict=strict,
231
+ redact_by_default=redact_by_default,
232
+ capture_inputs=capture_inputs,
233
+ capture_outputs=capture_outputs,
234
+ )
235
+
236
+
237
+ def _resolve_budget(
238
+ local_override: Optional[TraceBudget],
239
+ parent: Optional["ActionTrace"],
240
+ ) -> _EffectiveBudget:
241
+ """
242
+ Build a fully resolved _EffectiveBudget by merging (lowest to highest):
243
+ 1. Package defaults (BUDGET_DEFAULTS from budget.py)
244
+ 2. Package-level budget from configure()
245
+ 3. Parent trace's effective budget (inherited by child traces)
246
+ 4. Local override (from the @traced_action decorator or ActionTrace.start())
247
+
248
+ Child traces inherit the parent's budget because the parent may have been
249
+ given a more generous budget (e.g. an agent loop with max_events=500).
250
+ Without inheritance, every nested call would silently revert to the default
251
+ 100-event limit.
252
+
253
+ Only non-None fields in each layer override the layer below. This means
254
+ TraceBudget(max_events=300) overrides only max_events — all other fields
255
+ come from the parent or package default.
256
+
257
+ Args:
258
+ local_override: The TraceBudget passed to the decorator or start(), if any.
259
+ parent: The parent ActionTrace, if this is a child trace.
260
+
261
+ Returns:
262
+ An _EffectiveBudget with every field filled in.
263
+ """
264
+ # Start with package defaults.
265
+ values: Dict[str, Any] = dict(BUDGET_DEFAULTS)
266
+
267
+ # Apply package-level budget from configure(), if set.
268
+ pkg_budget = get_package_budget()
269
+ if pkg_budget is not None:
270
+ for field in ("max_events", "max_steps", "max_depth",
271
+ "max_payload_bytes", "sample_rate", "always_trace_errors"):
272
+ v = getattr(pkg_budget, field, None)
273
+ if v is not None:
274
+ values[field] = v
275
+
276
+ # Inherit from the parent trace's already-resolved budget.
277
+ # This is why child traces see the same generous limits as their parent
278
+ # without needing to repeat them in every decorator.
279
+ if parent is not None and parent._effective_budget is not None:
280
+ eb = parent._effective_budget
281
+ values["max_events"] = eb.max_events
282
+ values["max_steps"] = eb.max_steps
283
+ values["max_depth"] = eb.max_depth
284
+ values["max_payload_bytes"] = eb.max_payload_bytes
285
+ values["sample_rate"] = eb.sample_rate
286
+ values["always_trace_errors"] = eb.always_trace_errors
287
+
288
+ # Apply local override — only the fields the caller explicitly set.
289
+ if local_override is not None:
290
+ for field in ("max_events", "max_steps", "max_depth",
291
+ "max_payload_bytes", "sample_rate", "always_trace_errors"):
292
+ v = getattr(local_override, field, None)
293
+ if v is not None:
294
+ values[field] = v
295
+
296
+ return _EffectiveBudget(**values)
297
+
298
+
299
+ # ---------------------------------------------------------------------------
300
+ # Payload safety helpers
301
+ # ---------------------------------------------------------------------------
302
+
303
+ def _safe_value(
304
+ field_name: str,
305
+ value: Any,
306
+ max_bytes: int,
307
+ redact: bool,
308
+ ) -> Any:
309
+ """
310
+ Sanitise a single value before storing it in a trace record.
311
+
312
+ Applies two safety rules:
313
+ 1. Redaction: if the field name matches a sensitive pattern and
314
+ redact=True, replace the value with "[redacted]".
315
+ 2. Size limit: if the JSON representation exceeds max_bytes, replace
316
+ the value with a "[truncated: N chars]" summary.
317
+ 3. Serialisability: if the value cannot be JSON-serialised (e.g. a
318
+ complex object), replace it with a "[TypeName]" summary.
319
+
320
+ Args:
321
+ field_name: The name of the field (used for redaction pattern matching).
322
+ value: The raw value to sanitise.
323
+ max_bytes: Maximum allowed size in bytes after JSON encoding.
324
+ redact: Whether to apply redaction rules.
325
+
326
+ Returns:
327
+ The original value, "[redacted]", "[truncated: N chars]", or "[TypeName]".
328
+ """
329
+ # Rule 1: Redact sensitive fields.
330
+ if redact and _is_sensitive(field_name):
331
+ return "[redacted]"
332
+
333
+ # Rule 2 & 3: Check serialisability and size.
334
+ try:
335
+ serialised = json.dumps(value, default=str)
336
+ byte_count = len(serialised.encode("utf-8"))
337
+ if byte_count > max_bytes:
338
+ return f"[truncated: {len(serialised)} chars]"
339
+ # Re-parse so the stored value is always a JSON-native type (not an
340
+ # object that will fail later during sink serialisation).
341
+ return json.loads(serialised)
342
+ except (TypeError, ValueError):
343
+ # Value is not JSON-serialisable at all.
344
+ return f"[{type(value).__name__}]"
345
+
346
+
347
+ def _sanitise_dict(
348
+ data: Dict[str, Any],
349
+ max_bytes: int,
350
+ redact: bool,
351
+ ) -> Dict[str, Any]:
352
+ """Apply _safe_value to every key-value pair in a dict."""
353
+ return {k: _safe_value(k, v, max_bytes, redact) for k, v in data.items()}
354
+
355
+
356
+ # ---------------------------------------------------------------------------
357
+ # _NoOpTrace
358
+ # ---------------------------------------------------------------------------
359
+ #
360
+ # A no-op object returned by ActionTrace.start() when tracing is disabled,
361
+ # sampled out, or the depth limit is exceeded. It implements the full public
362
+ # ActionTrace API but does nothing. This allows callers to use it safely in a
363
+ # with-block without checking for None.
364
+
365
+ class _NoOpTrace:
366
+ """
367
+ A silent stand-in for ActionTrace when tracing cannot run.
368
+
369
+ Why does this exist?
370
+ When ActionTrace.start() is called but tracing is disabled (or sampled out,
371
+ or depth-exceeded), we could return None. But then every caller would need
372
+ to guard:
373
+ trace = ActionTrace.start(...)
374
+ if trace:
375
+ with trace as t:
376
+ ...
377
+
378
+ That is ugly and error-prone. Instead, we return a _NoOpTrace that safely
379
+ accepts all the same calls and does nothing. The with-block still works:
380
+ with ActionTrace.start(...) as trace:
381
+ trace.step("...") # silently ignored
382
+ """
383
+
384
+ def __enter__(self) -> "_NoOpTrace":
385
+ return self
386
+
387
+ def __exit__(self, *args: Any) -> bool:
388
+ return False # do not suppress exceptions
389
+
390
+ # All public ActionTrace methods are present as no-ops.
391
+ def step(self, *args: Any, **kwargs: Any) -> None: pass
392
+ def event(self, *args: Any, **kwargs: Any) -> None: pass
393
+ def touch(self, *args: Any, **kwargs: Any) -> None: pass
394
+ def input(self, *args: Any, **kwargs: Any) -> None: pass
395
+ def output(self, *args: Any, **kwargs: Any) -> None: pass
396
+ def set_meta(self, *args: Any, **kwargs: Any) -> None: pass
397
+ def db(self, *args: Any, **kwargs: Any) -> None: pass
398
+ def http(self, *args: Any, **kwargs: Any) -> None: pass
399
+ def file(self, *args: Any, **kwargs: Any) -> None: pass
400
+ def model(self, *args: Any, **kwargs: Any) -> None: pass
401
+
402
+
403
+ # ---------------------------------------------------------------------------
404
+ # ActionTrace
405
+ # ---------------------------------------------------------------------------
406
+
407
+ class ActionTrace(TraceHelpersMixin):
408
+ """
409
+ The live record of one action as it executes.
410
+
411
+ ActionTrace accumulates steps, events, touches, inputs, outputs, and errors
412
+ as the action runs. When the action finishes (successfully or with an error),
413
+ the trace is serialised and written to the configured sinks.
414
+
415
+ Two ways to create a trace:
416
+
417
+ 1. Decorator (preferred for most cases):
418
+ @traced_action(action="note.create", kind="app")
419
+ def create_note(title, body):
420
+ ...
421
+
422
+ 2. Context manager (for manual, granular control):
423
+ with ActionTrace.start(action="note.create", kind="app") as trace:
424
+ trace.input({"title": title})
425
+ trace.step("Validating input")
426
+ trace.event(kind="db", operation="insert", target="notes")
427
+ trace.output({"note_id": "note_123"})
428
+
429
+ Both forms automatically detect and participate in parent-child nesting
430
+ through the ContextVar in context.py.
431
+ """
432
+
433
+ # ------------------------------------------------------------------
434
+ # Construction
435
+ # ------------------------------------------------------------------
436
+
437
+ def __init__(
438
+ self,
439
+ action: str,
440
+ kind: str = "app",
441
+ actor: Optional[str] = None,
442
+ project: Optional[str] = None,
443
+ parent: Optional["ActionTrace"] = None,
444
+ correlation_id: Optional[str] = None,
445
+ meta: Optional[Dict[str, Any]] = None,
446
+ depth: int = 0,
447
+ effective_config: Optional[_EffectiveConfig] = None,
448
+ effective_budget: Optional[_EffectiveBudget] = None,
449
+ ) -> None:
450
+ """
451
+ Initialise a new trace. You should not call this directly — use
452
+ ActionTrace.start() or @traced_action instead.
453
+
454
+ Args:
455
+ action: The name of the action being traced (e.g. "note.create").
456
+ kind: The category of work (e.g. "app", "db", "http").
457
+ actor: Who or what initiated the action (e.g. "user", "cron").
458
+ project: The application or project name (for grouping in output).
459
+ parent: The parent ActionTrace, if this is a child trace.
460
+ correlation_id: A shared ID linking related traces across a workflow.
461
+ meta: Arbitrary developer-supplied metadata.
462
+ depth: Nesting depth (0 = root, 1 = first child, etc.).
463
+ effective_config: Pre-resolved config (from _resolve_config).
464
+ effective_budget: Pre-resolved budget (from _resolve_budget).
465
+ """
466
+ # --- Identity fields ---
467
+ self.trace_id: str = new_trace_id()
468
+
469
+ # root_trace_id always points to the first trace in the chain.
470
+ # For a root trace, it points to itself.
471
+ # For a child, it inherits the parent's root_trace_id.
472
+ self.root_trace_id: str = (
473
+ parent.root_trace_id if parent is not None else self.trace_id
474
+ )
475
+ self.parent_trace_id: Optional[str] = (
476
+ parent.trace_id if parent is not None else None
477
+ )
478
+ self.correlation_id: Optional[str] = correlation_id
479
+
480
+ # --- Descriptive fields ---
481
+ self.project: Optional[str] = project
482
+ self.action: str = action
483
+ self.kind: str = kind
484
+ self.actor: Optional[str] = actor
485
+
486
+ # --- Status fields ---
487
+ self.status: str = "running"
488
+ self.budget_hit: bool = False
489
+
490
+ # --- Timing fields ---
491
+ self._started_at: datetime = datetime.now(timezone.utc)
492
+ self.started_at: str = _iso(self._started_at)
493
+ self.ended_at: Optional[str] = None
494
+ self.duration_ms: Optional[float] = None
495
+
496
+ # --- Record lists (public output) ---
497
+ self._inputs: Dict[str, Any] = {}
498
+ self._steps: List[Dict[str, Any]] = []
499
+ self._events: List[Dict[str, Any]] = []
500
+ self._touches: List[Dict[str, Any]] = []
501
+ self._outputs: Dict[str, Any] = {}
502
+ self._errors: List[Dict[str, Any]] = []
503
+ self._child_summaries: List[Dict[str, Any]] = []
504
+ self._meta: Dict[str, Any] = dict(meta) if meta else {}
505
+
506
+ # --- Internal deduplication sets ---
507
+ # These sets hold canonical string keys (e.g. "db_table:notes") and
508
+ # allow O(1) checks before appending to the public lists. This prevents
509
+ # a hot loop that hits the same DB table 1000 times from producing 1000
510
+ # identical touch entries in the output.
511
+ self._touch_index: set = set()
512
+ self._error_index: set = set()
513
+
514
+ # --- Budget counters ---
515
+ # Tracked separately from the list lengths to avoid repeated len() calls.
516
+ self._event_count: int = 0
517
+ self._step_count: int = 0
518
+
519
+ # --- Nesting ---
520
+ self._depth: int = depth
521
+ self._parent: Optional["ActionTrace"] = parent
522
+
523
+ # --- Resolved settings ---
524
+ self._effective_config: _EffectiveConfig = (
525
+ effective_config or _resolve_config(None, parent)
526
+ )
527
+ self._effective_budget: _EffectiveBudget = (
528
+ effective_budget or _resolve_budget(None, parent)
529
+ )
530
+
531
+ # --- Context management token ---
532
+ # Stores the ContextVar token from push_trace() so we can restore the
533
+ # previous active trace when this trace finishes.
534
+ self._context_token: Any = None
535
+
536
+ # ------------------------------------------------------------------
537
+ # Factory (context manager entry point)
538
+ # ------------------------------------------------------------------
539
+
540
+ @classmethod
541
+ def start(
542
+ cls,
543
+ action: str,
544
+ kind: str = "app",
545
+ actor: Optional[str] = None,
546
+ project: Optional[str] = None,
547
+ correlation_id: Optional[str] = None,
548
+ meta: Optional[Dict[str, Any]] = None,
549
+ config: Optional[TraceConfig] = None,
550
+ budget: Optional[TraceBudget] = None,
551
+ ) -> Union["ActionTrace", _NoOpTrace]:
552
+ """
553
+ Create a trace for use as a context manager.
554
+
555
+ ActionTrace.start() is the manual entry point. Use it when you want
556
+ explicit control over what is recorded, rather than relying on the
557
+ decorator to capture everything automatically.
558
+
559
+ Usage:
560
+ with ActionTrace.start(action="note.create", kind="app") as trace:
561
+ trace.input({"title": title})
562
+ trace.step("Validating input")
563
+ trace.event(kind="db", operation="insert", target="notes")
564
+ trace.output({"note_id": "note_123"})
565
+
566
+ Returns:
567
+ An ActionTrace (if tracing is active and not sampled out) or a
568
+ _NoOpTrace (if tracing is disabled, sampled out, or depth exceeded).
569
+ Both support the with-block interface safely.
570
+ """
571
+ return _create_trace(
572
+ action=action,
573
+ kind=kind,
574
+ actor=actor,
575
+ project=project,
576
+ correlation_id=correlation_id,
577
+ meta=meta,
578
+ config_override=config,
579
+ budget_override=budget,
580
+ )
581
+
582
+ # ------------------------------------------------------------------
583
+ # Context manager protocol
584
+ # ------------------------------------------------------------------
585
+
586
+ def __enter__(self) -> "ActionTrace":
587
+ """
588
+ Enter the trace context. Sets this trace as the active trace in the
589
+ ContextVar so that nested @traced_action calls know to create children
590
+ rather than new roots.
591
+ """
592
+ self._context_token = push_trace(self)
593
+ return self
594
+
595
+ def __exit__(
596
+ self,
597
+ exc_type: Any,
598
+ exc_val: Any,
599
+ exc_tb: Any,
600
+ ) -> bool:
601
+ """
602
+ Exit the trace context. Finishes the trace (success or failure) and
603
+ restores the ContextVar to its previous value.
604
+
605
+ Returns False so exceptions are never suppressed.
606
+ """
607
+ if exc_type is not None:
608
+ # An exception escaped the with-block — the trace failed.
609
+ self._finish(status="failed", error=exc_val)
610
+ else:
611
+ self._finish(status="completed")
612
+
613
+ # Restore whatever was active before this trace started.
614
+ if self._context_token is not None:
615
+ pop_trace(self._context_token)
616
+ self._context_token = None
617
+
618
+ return False # do not suppress the exception
619
+
620
+ # ------------------------------------------------------------------
621
+ # Public recording methods
622
+ # ------------------------------------------------------------------
623
+
624
+ def step(self, label: str) -> None:
625
+ """
626
+ Record a human-readable step marker on the trace timeline.
627
+
628
+ Steps are flat markers — they are not structural parents of events.
629
+ Think of them as scene headings in a film script: they say where the
630
+ story is, while events record what technically happened.
631
+
632
+ Example:
633
+ trace.step("Validated input")
634
+ trace.event(kind="db", operation="insert", target="notes")
635
+ trace.step("Returned response")
636
+
637
+ Args:
638
+ label: A short, readable description of where the trace is
639
+ (e.g. "Saved note", "Called payment provider").
640
+ """
641
+ # Respect the step budget. Once hit, further steps are silently dropped
642
+ # and budget_hit is flagged. The function continues running normally.
643
+ if self._step_count >= self._effective_budget.max_steps:
644
+ self.budget_hit = True
645
+ return
646
+
647
+ self._steps.append({
648
+ "step_id": new_step_id(),
649
+ "label": label,
650
+ "recorded_at": _now_iso(),
651
+ })
652
+ self._step_count += 1
653
+
654
+ def event(
655
+ self,
656
+ kind: str,
657
+ operation: Optional[str] = None,
658
+ target: Optional[str] = None,
659
+ status: Optional[str] = None,
660
+ duration_ms: Optional[float] = None,
661
+ result: Optional[Any] = None,
662
+ error: Optional[Any] = None,
663
+ parent_event_id: Optional[str] = None,
664
+ **kwargs: Any,
665
+ ) -> None:
666
+ """
667
+ Record a structured event — a specific operation that happened during
668
+ the trace.
669
+
670
+ Events are the machine-readable counterpart to steps. A step says
671
+ "we are at the save stage"; an event says "a DB insert ran against
672
+ the notes table and returned 1 row."
673
+
674
+ When an event has a target, TraceAct automatically derives and records
675
+ a touch for that resource.
676
+
677
+ Args:
678
+ kind: The category of operation ("db", "http", "file",
679
+ "model", "cache", etc.).
680
+ operation: The specific operation ("insert", "post", "write", etc.).
681
+ target: The resource involved ("notes", "stripe", "data/notes.json").
682
+ status: "completed" or "failed". Defaults to "completed".
683
+ duration_ms: How long this specific operation took in milliseconds.
684
+ result: What the event produced (rows returned, response status, etc.).
685
+ error: An error dict or exception string if the event failed.
686
+ parent_event_id: ID of a parent event if this event was caused by another.
687
+ **kwargs: Any additional fields to store with the event (database,
688
+ rows, tokens_in, tokens_out, etc.).
689
+
690
+ Example:
691
+ trace.event(kind="db", operation="insert", target="notes", rows=1)
692
+ """
693
+ # Respect the event budget.
694
+ if self._event_count >= self._effective_budget.max_events:
695
+ self.budget_hit = True
696
+ return
697
+
698
+ # Respect the depth budget.
699
+ if self._depth > self._effective_budget.max_depth:
700
+ self.budget_hit = True
701
+ return
702
+
703
+ # Apply payload safety to the result field.
704
+ safe_result = None
705
+ if result is not None:
706
+ safe_result = _safe_value(
707
+ "result",
708
+ result,
709
+ self._effective_budget.max_payload_bytes,
710
+ self._effective_config.redact_by_default,
711
+ )
712
+
713
+ # Build the event record. Extra kwargs (rows, database, tokens_in, etc.)
714
+ # are stored directly on the event dict so callers can attach any fields
715
+ # they need without TraceAct needing to enumerate them all.
716
+ evt: Dict[str, Any] = {
717
+ "event_id": new_event_id(),
718
+ "parent_event_id": parent_event_id,
719
+ "kind": kind,
720
+ "action": self.action,
721
+ "operation": operation,
722
+ "target": target,
723
+ "status": status or "completed",
724
+ "started_at": _now_iso(),
725
+ "ended_at": _now_iso(),
726
+ "duration_ms": duration_ms,
727
+ "result": safe_result,
728
+ "error": error,
729
+ "depth": self._depth,
730
+ }
731
+
732
+ # Merge any extra kwargs into the event dict.
733
+ if kwargs:
734
+ evt.update(kwargs)
735
+
736
+ self._events.append(evt)
737
+ self._event_count += 1
738
+
739
+ # Auto-derive a touch from the event's target. The event kind is mapped
740
+ # to a more specific touch kind (e.g. "db" → "db_table").
741
+ if target:
742
+ touch_kind = _EVENT_TO_TOUCH_KIND.get(kind, kind)
743
+ self._add_touch(touch_kind, target)
744
+
745
+ # If the event carries an error, add it to the trace-level error summary.
746
+ if error:
747
+ self._add_error(evt["event_id"], error)
748
+
749
+ def touch(self, kind: str, target: str) -> None:
750
+ """
751
+ Record that a resource was involved in this trace.
752
+
753
+ Touches record which things the trace made contact with: files, tables,
754
+ endpoints, models, queues. They are deduplicated — touching the same
755
+ resource multiple times produces only one touch entry.
756
+
757
+ Most touches are derived automatically from events (see the event()
758
+ method). Use trace.touch() for resources that an event does not cover —
759
+ for example, a module that was loaded, a config file that was read, or
760
+ a third-party service that was contacted without an explicit event.
761
+
762
+ Args:
763
+ kind: The kind of resource ("db_table", "file", "http_endpoint",
764
+ "module", "config", etc.).
765
+ target: The specific resource identifier ("notes", "data/config.json").
766
+
767
+ Example:
768
+ trace.touch(kind="file", target="data/config.json")
769
+ """
770
+ self._add_touch(kind, target)
771
+
772
+ def input(self, data: Dict[str, Any]) -> None:
773
+ """
774
+ Record the inputs for this trace.
775
+
776
+ Call this when you want to explicitly capture what came into the action.
777
+ This is always available regardless of the capture_inputs config setting
778
+ — the config only controls whether the decorator captures arguments
779
+ automatically. Manual calls to trace.input() are always intentional and
780
+ always recorded.
781
+
782
+ Redaction and size limits still apply.
783
+
784
+ Args:
785
+ data: A dict of field names to values. For example:
786
+ {"title": "My note", "user_id": "user_123"}
787
+
788
+ Example:
789
+ trace.input({"title": title, "user_id": user_id})
790
+ """
791
+ sanitised = _sanitise_dict(
792
+ data,
793
+ self._effective_budget.max_payload_bytes,
794
+ self._effective_config.redact_by_default,
795
+ )
796
+ self._inputs.update(sanitised)
797
+
798
+ def output(self, data: Dict[str, Any]) -> None:
799
+ """
800
+ Record the outputs of this trace — what the action produced.
801
+
802
+ Args:
803
+ data: A dict of field names to values. For example:
804
+ {"note_id": "note_123", "created": True}
805
+
806
+ Example:
807
+ trace.output({"note_id": note.id})
808
+ """
809
+ # Respect the capture_outputs setting.
810
+ if not self._effective_config.capture_outputs:
811
+ return
812
+
813
+ sanitised = _sanitise_dict(
814
+ data,
815
+ self._effective_budget.max_payload_bytes,
816
+ self._effective_config.redact_by_default,
817
+ )
818
+ self._outputs.update(sanitised)
819
+
820
+ def set_meta(self, key: str, value: Any) -> None:
821
+ """
822
+ Store arbitrary developer-supplied metadata on the trace.
823
+
824
+ TraceAct stores meta fields but does not interpret them. Use them for
825
+ anything that helps you understand a trace in context: release versions,
826
+ feature flags, experiment IDs, environment names, etc.
827
+
828
+ Args:
829
+ key: The metadata key (e.g. "release", "env", "experiment_id").
830
+ value: Any JSON-serialisable value.
831
+
832
+ Example:
833
+ trace.set_meta("release", "v1.2")
834
+ trace.set_meta("env", "production")
835
+ """
836
+ self._meta[key] = value
837
+
838
+ # ------------------------------------------------------------------
839
+ # Internal helpers
840
+ # ------------------------------------------------------------------
841
+
842
+ def _add_touch(self, kind: str, target: str) -> None:
843
+ """
844
+ Add a touch to the deduped touch list.
845
+
846
+ The touch index (a set) is checked first. If the touch has already been
847
+ recorded, nothing is added. This keeps the public touches list clean
848
+ even when the same resource is accessed hundreds of times.
849
+ """
850
+ key = f"{kind}:{target}"
851
+ if key not in self._touch_index:
852
+ self._touch_index.add(key)
853
+ self._touches.append({"kind": kind, "target": target})
854
+
855
+ def _add_error(self, event_id: str, error: Any) -> None:
856
+ """
857
+ Add an error to the deduped trace-level error summary.
858
+
859
+ Errors are kept in two forms:
860
+ - On the event itself (full detail, always present).
861
+ - In the trace's _errors list (deduplicated summary for quick scanning).
862
+
863
+ If the same error type and message appears multiple times (e.g. the same
864
+ DB constraint violation fires in a retry loop), the event list captures
865
+ every occurrence while the trace-level summary shows it once.
866
+
867
+ The deduplication key is: "{error_type}:{message}".
868
+ """
869
+ # Normalise the error to a dict so the summary is consistent.
870
+ if isinstance(error, Exception):
871
+ error_type = type(error).__name__
872
+ message = str(error)
873
+ elif isinstance(error, dict):
874
+ error_type = error.get("type", "Error")
875
+ message = error.get("message", str(error))
876
+ else:
877
+ error_type = "Error"
878
+ message = str(error)
879
+
880
+ key = f"{error_type}:{message}"
881
+ if key not in self._error_index:
882
+ self._error_index.add(key)
883
+ self._errors.append({
884
+ "event_id": event_id,
885
+ "type": error_type,
886
+ "message": message,
887
+ })
888
+
889
+ def _make_child_summary(self) -> Dict[str, Any]:
890
+ """
891
+ Build the compact summary that this trace sends to its parent when it
892
+ finishes.
893
+
894
+ The summary contains enough information for the parent to:
895
+ 1. Understand what happened in this child.
896
+ 2. Merge this child's touches and errors into its own deduped sets.
897
+
898
+ It deliberately omits the full event list, step list, and inputs/outputs
899
+ to keep the parent trace record manageable.
900
+ """
901
+ return {
902
+ "trace_id": self.trace_id,
903
+ "action": self.action,
904
+ "kind": self.kind,
905
+ "status": self.status,
906
+ "budget_hit": self.budget_hit,
907
+ "duration_ms": self.duration_ms,
908
+ "event_count": self._event_count,
909
+ "step_count": self._step_count,
910
+ "touches": list(self._touches),
911
+ "errors": list(self._errors),
912
+ }
913
+
914
+ def _receive_child_summary(self, summary: Dict[str, Any]) -> None:
915
+ """
916
+ Accept a compact summary from a finished child trace and merge its
917
+ touches and errors into this trace's own deduped sets.
918
+
919
+ This is the upward propagation step. It runs once when a child trace
920
+ finishes — not on every event — which keeps the propagation cost at
921
+ O(summary size) rather than O(total events).
922
+ """
923
+ self._child_summaries.append(summary)
924
+
925
+ # Merge touches from the child.
926
+ for touch in summary.get("touches", []):
927
+ self._add_touch(touch["kind"], touch["target"])
928
+
929
+ # Merge errors from the child.
930
+ for error in summary.get("errors", []):
931
+ self._add_error(summary["trace_id"], error)
932
+
933
+ def _finish(self, status: str, error: Optional[Any] = None) -> None:
934
+ """
935
+ Finalise the trace: record timing, status, top-level error (if any),
936
+ push a summary to the parent, and write the record to sinks.
937
+
938
+ Args:
939
+ status: "completed", "failed", or "cancelled".
940
+ error: The exception that caused failure, if status is "failed".
941
+ """
942
+ # Record timing.
943
+ ended = datetime.now(timezone.utc)
944
+ self.ended_at = _iso(ended)
945
+ self.duration_ms = round(
946
+ (ended - self._started_at).total_seconds() * 1000, 3
947
+ )
948
+ self.status = status
949
+
950
+ # If the trace failed, add the top-level error to the error summary.
951
+ if error is not None:
952
+ self._add_error(self.trace_id, error)
953
+
954
+ # Push a compact summary to the direct parent (if this is a child trace).
955
+ # The parent merges our touches and errors into its own sets.
956
+ if self._parent is not None:
957
+ summary = self._make_child_summary()
958
+ self._parent._receive_child_summary(summary)
959
+
960
+ # Write to sinks.
961
+ self._write_to_sinks()
962
+
963
+ def _write_to_sinks(self) -> None:
964
+ """
965
+ Serialise the trace to a dict and send it to the configured sinks,
966
+ respecting the sink_mode setting.
967
+
968
+ Modes:
969
+ "blocking" — write immediately to each sink.
970
+ "buffered" — add to the in-memory buffer; flush later.
971
+ "disabled" — do nothing.
972
+
973
+ Sink failures are swallowed (unless strict=True) so that a broken sink
974
+ never crashes the application being traced.
975
+ """
976
+ mode = self._effective_config.sink_mode
977
+
978
+ if mode == "disabled":
979
+ return
980
+
981
+ sinks = get_package_sinks()
982
+
983
+ # If no sinks have been configured, fall back to ConsoleSink so that
984
+ # traces are visible without any setup. This is purely a developer
985
+ # convenience — configure() with an explicit sink list overrides it.
986
+ if not sinks:
987
+ sinks = [ConsoleSink()]
988
+
989
+ record = self.to_dict()
990
+
991
+ if mode == "blocking":
992
+ for sink in sinks:
993
+ try:
994
+ sink.write(record)
995
+ except Exception as e:
996
+ if self._effective_config.strict:
997
+ raise
998
+ # Swallow silently in non-strict mode.
999
+ _ = e
1000
+
1001
+ elif mode == "buffered":
1002
+ # buffer_record also registers the atexit flush handler.
1003
+ buffer_record(record)
1004
+
1005
+ # ------------------------------------------------------------------
1006
+ # Serialisation
1007
+ # ------------------------------------------------------------------
1008
+
1009
+ def to_dict(self) -> Dict[str, Any]:
1010
+ """
1011
+ Serialise the trace to a plain Python dict suitable for JSON output.
1012
+
1013
+ This is the canonical trace record shape. It matches the example in
1014
+ PRD section 43.
1015
+ """
1016
+ return {
1017
+ "trace_id": self.trace_id,
1018
+ "root_trace_id": self.root_trace_id,
1019
+ "parent_trace_id": self.parent_trace_id,
1020
+ "correlation_id": self.correlation_id,
1021
+ "project": self.project,
1022
+ "action": self.action,
1023
+ "kind": self.kind,
1024
+ "actor": self.actor,
1025
+ "status": self.status,
1026
+ "budget_hit": self.budget_hit,
1027
+ "started_at": self.started_at,
1028
+ "ended_at": self.ended_at,
1029
+ "duration_ms": self.duration_ms,
1030
+ "inputs": self._inputs,
1031
+ "steps": self._steps,
1032
+ "events": self._events,
1033
+ "touches": self._touches,
1034
+ "outputs": self._outputs,
1035
+ "errors": self._errors,
1036
+ "child_summaries": self._child_summaries,
1037
+ "meta": self._meta,
1038
+ }
1039
+
1040
+
1041
+ # ---------------------------------------------------------------------------
1042
+ # _create_trace — the shared factory used by both the decorator and start()
1043
+ # ---------------------------------------------------------------------------
1044
+
1045
+ def _create_trace(
1046
+ action: str,
1047
+ kind: str = "app",
1048
+ actor: Optional[str] = None,
1049
+ project: Optional[str] = None,
1050
+ correlation_id: Optional[str] = None,
1051
+ meta: Optional[Dict[str, Any]] = None,
1052
+ config_override: Optional[TraceConfig] = None,
1053
+ budget_override: Optional[TraceBudget] = None,
1054
+ operation: Optional[str] = None,
1055
+ target: Optional[str] = None,
1056
+ database: Optional[str] = None,
1057
+ ) -> Union[ActionTrace, _NoOpTrace]:
1058
+ """
1059
+ Shared factory for creating an ActionTrace (or returning a _NoOpTrace when
1060
+ tracing cannot run).
1061
+
1062
+ Both the @traced_action decorator and ActionTrace.start() call this function.
1063
+ It centralises the checks that decide whether to create a real trace or skip:
1064
+ 1. Is tracing enabled?
1065
+ 2. Is the current context a skip sentinel (sampled-out parent)?
1066
+ 3. Should this trace be sampled out?
1067
+ 4. Would this trace exceed the depth limit?
1068
+
1069
+ If the trace is created, it is not yet pushed onto the ContextVar — that
1070
+ happens in ActionTrace.__enter__() (for the context manager API) or directly
1071
+ in the decorator wrapper.
1072
+
1073
+ Args:
1074
+ action, kind, actor, project, correlation_id, meta:
1075
+ Standard trace fields. See ActionTrace.__init__ for descriptions.
1076
+ config_override: Optional TraceConfig to override package settings.
1077
+ budget_override: Optional TraceBudget to override inherited limits.
1078
+ operation, target, database:
1079
+ If provided, an initial event is added to the trace using these
1080
+ values. This is how @traced_action(kind="db", operation="insert",
1081
+ target="notes") works — the operation and target appear on the first
1082
+ event, not on the trace root.
1083
+
1084
+ Returns:
1085
+ An ActionTrace ready to be entered (via with or the decorator), or a
1086
+ _NoOpTrace if tracing cannot run.
1087
+ """
1088
+ # --- Check 1: is tracing enabled? ---
1089
+ # Resolve config first so we can check the enabled flag.
1090
+ current = get_active_trace()
1091
+ parent: Optional[ActionTrace] = current if isinstance(current, ActionTrace) else None
1092
+
1093
+ effective_config = _resolve_config(config_override, parent)
1094
+ if not effective_config.enabled:
1095
+ return _NoOpTrace()
1096
+
1097
+ # --- Check 2: are we inside a sampled-out parent? ---
1098
+ # If the ContextVar holds the SKIP sentinel, a parent was sampled out and
1099
+ # we must also skip. Return a _NoOpTrace so the function still runs.
1100
+ if is_skip(current):
1101
+ return _NoOpTrace()
1102
+
1103
+ # --- Check 3: sampling decision ---
1104
+ effective_budget = _resolve_budget(budget_override, parent)
1105
+ import random
1106
+
1107
+ # always_trace_errors cannot be applied here because we haven't run yet
1108
+ # and don't know if this call will fail. The sampling decision is final.
1109
+ if effective_budget.sample_rate < 1.0:
1110
+ if random.random() > effective_budget.sample_rate:
1111
+ # This trace is sampled out. Return a _NoOpTrace but also push SKIP
1112
+ # onto the ContextVar so nested calls inherit the skip decision.
1113
+ # The caller (decorator or context manager) is responsible for
1114
+ # pushing SKIP and restoring it. We flag it here so the caller knows.
1115
+ return _SkippedTrace()
1116
+
1117
+ # --- Check 4: depth limit ---
1118
+ depth = (parent._depth + 1) if parent is not None else 0
1119
+ if depth > effective_budget.max_depth:
1120
+ return _NoOpTrace()
1121
+
1122
+ # --- Create the real trace ---
1123
+ trace = ActionTrace(
1124
+ action=action,
1125
+ kind=kind,
1126
+ actor=actor,
1127
+ project=project,
1128
+ parent=parent,
1129
+ correlation_id=correlation_id,
1130
+ meta=meta,
1131
+ depth=depth,
1132
+ effective_config=effective_config,
1133
+ effective_budget=effective_budget,
1134
+ )
1135
+
1136
+ # If the decorator passed operation and/or target, create an initial event.
1137
+ # These fields belong on the event, not the trace root — a trace can span
1138
+ # many operations, so putting one at the root would be misleading.
1139
+ if operation or target:
1140
+ extra: Dict[str, Any] = {}
1141
+ if database:
1142
+ extra["database"] = database
1143
+ trace.event(
1144
+ kind=kind,
1145
+ operation=operation,
1146
+ target=target,
1147
+ **extra,
1148
+ )
1149
+
1150
+ return trace
1151
+
1152
+
1153
+ class _SkippedTrace(_NoOpTrace):
1154
+ """
1155
+ A no-op trace marker that tells the decorator the trace was sampled out.
1156
+
1157
+ The decorator needs to push SKIP onto the ContextVar when sampling drops a
1158
+ trace, so nested calls also skip. A plain _NoOpTrace is returned for cases
1159
+ where we do not need to push SKIP (e.g. tracing disabled, depth exceeded).
1160
+ _SkippedTrace is the signal that SKIP propagation is needed.
1161
+ """
1162
+ pass
1163
+
1164
+
1165
+ # ---------------------------------------------------------------------------
1166
+ # Timestamp utilities
1167
+ # ---------------------------------------------------------------------------
1168
+
1169
+ def _iso(dt: datetime) -> str:
1170
+ """Format a datetime as an ISO 8601 UTC string with millisecond precision."""
1171
+ return dt.strftime("%Y-%m-%dT%H:%M:%S.") + f"{dt.microsecond // 1000:03d}Z"
1172
+
1173
+
1174
+ def _now_iso() -> str:
1175
+ """Return the current UTC time as an ISO 8601 string."""
1176
+ return _iso(datetime.now(timezone.utc))