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/__init__.py +43 -0
- traceact/budget.py +103 -0
- traceact/config.py +198 -0
- traceact/context.py +130 -0
- traceact/decorators.py +430 -0
- traceact/helpers.py +103 -0
- traceact/ids.py +62 -0
- traceact/sinks.py +177 -0
- traceact/trace.py +1176 -0
- traceact-0.1.0.dist-info/METADATA +94 -0
- traceact-0.1.0.dist-info/RECORD +14 -0
- traceact-0.1.0.dist-info/WHEEL +5 -0
- traceact-0.1.0.dist-info/licenses/LICENSE +21 -0
- traceact-0.1.0.dist-info/top_level.txt +1 -0
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))
|