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/decorators.py ADDED
@@ -0,0 +1,430 @@
1
+ # decorators.py
2
+ #
3
+ # Defines @traced_action — the primary developer-facing API for TraceAct.
4
+ #
5
+ # Why a decorator?
6
+ # The decorator form is the most natural way to attach tracing to existing
7
+ # functions. It requires no changes to the function body and wraps the entire
8
+ # call lifecycle (start, input capture, success, error, finish) automatically.
9
+ # The developer focuses on what the function does; TraceAct handles recording
10
+ # the story.
11
+ #
12
+ # How the decorator works:
13
+ # 1. @traced_action(action="...", kind="...") returns a decorator.
14
+ # 2. That decorator wraps the target function in a wrapper.
15
+ # 3. The wrapper calls _create_trace() to build (or skip) a trace.
16
+ # 4. If the trace is a _SkippedTrace, SKIP is pushed onto the ContextVar and
17
+ # the function runs without recording anything.
18
+ # 5. If the trace is a real ActionTrace, it is pushed onto the ContextVar as
19
+ # the active trace, inputs are optionally captured, the function runs, and
20
+ # the trace is finished on success or failure.
21
+ # 6. The ContextVar is always restored in a finally block.
22
+ #
23
+ # Async support:
24
+ # The decorator detects whether the wrapped function is a coroutine function
25
+ # (async def) and returns either a sync or async wrapper accordingly. The logic
26
+ # in both wrappers is identical — the only difference is the use of "await".
27
+ #
28
+ # Input capture:
29
+ # When capture_inputs is specified, the decorator calls _capture_args() to map
30
+ # the positional and keyword arguments to their parameter names via
31
+ # inspect.signature(), applies redaction and size limits, and stores the result
32
+ # on the trace's inputs dict. This never breaks the function call — if capture
33
+ # fails for any reason, it is silently skipped (unless strict=True).
34
+
35
+ import functools
36
+ import inspect
37
+ from typing import Any, Dict, List, Optional, Union
38
+
39
+ from traceact.budget import TraceBudget
40
+ from traceact.config import TraceConfig
41
+ from traceact.context import SKIP, pop_trace, push_trace
42
+ from traceact.trace import (
43
+ ActionTrace,
44
+ _NoOpTrace,
45
+ _SkippedTrace,
46
+ _create_trace,
47
+ _is_sensitive,
48
+ _safe_value,
49
+ )
50
+
51
+
52
+ def traced_action(
53
+ action: str,
54
+ kind: str = "app",
55
+ actor: Optional[str] = None,
56
+ project: Optional[str] = None,
57
+ operation: Optional[str] = None,
58
+ target: Optional[str] = None,
59
+ database: Optional[str] = None,
60
+ capture_inputs: Any = False,
61
+ meta: Optional[Dict[str, Any]] = None,
62
+ config: Optional[TraceConfig] = None,
63
+ budget: Optional[TraceBudget] = None,
64
+ correlation_id: Optional[str] = None,
65
+ ) -> Any:
66
+ """
67
+ Decorator that traces the execution of a function.
68
+
69
+ Attach this to any function — sync or async — to automatically record a
70
+ trace whenever the function is called. The trace captures timing, status,
71
+ errors, and optionally inputs.
72
+
73
+ Args:
74
+ action:
75
+ The name of the action being traced. Use dot-notation to describe
76
+ the action clearly. Examples: "note.create", "payment.authorise",
77
+ "agent.run", "report.export".
78
+
79
+ kind:
80
+ The category of work. This tells readers what kind of system the
81
+ function interacts with. Standard values: "app", "db", "http",
82
+ "file", "model", "cache", "queue", "job", "auth", "payment",
83
+ "email", "export". Default: "app".
84
+
85
+ actor:
86
+ Who or what triggered the action. Examples: "user", "cron",
87
+ "webhook", "agent". Optional.
88
+
89
+ project:
90
+ The application or service this trace belongs to. Useful for
91
+ grouping traces from multiple services in the same output.
92
+
93
+ operation:
94
+ For kind="db", "http", "file" etc. — the specific operation being
95
+ performed (e.g. "insert", "post", "write"). When provided alongside
96
+ target, an initial event is automatically created inside the trace.
97
+
98
+ target:
99
+ The resource the operation acts on (e.g. "notes", "stripe",
100
+ "data/output.json"). Works alongside operation.
101
+
102
+ database:
103
+ For kind="db" traces — the database name or driver (e.g. "sqlite",
104
+ "postgres"). Stored on the initial event alongside operation/target.
105
+
106
+ capture_inputs:
107
+ Controls whether function arguments are automatically recorded.
108
+ False (default): no automatic capture.
109
+ True: capture all named arguments (excluding self/cls), with
110
+ redaction and size limits applied.
111
+ list of strings: capture only the named arguments in the list.
112
+ This is the safest and most explicit form.
113
+
114
+ meta:
115
+ Arbitrary key-value data to attach to the trace. TraceAct stores
116
+ it but does not interpret it. Examples: {"release": "v1.2"}.
117
+
118
+ config:
119
+ A TraceConfig that overrides package-level settings for this trace
120
+ only. Only non-None fields in the config are applied.
121
+
122
+ budget:
123
+ A TraceBudget that overrides inherited limits for this trace only.
124
+ Only non-None fields in the budget are applied. Other fields are
125
+ inherited from the parent trace or the package default.
126
+
127
+ correlation_id:
128
+ A shared ID to link this trace with other traces in the same
129
+ logical workflow (e.g. the same API request, the same job, the
130
+ same order). Useful for connecting traces across functions or
131
+ services.
132
+
133
+ Returns:
134
+ A decorator that wraps the target function.
135
+
136
+ Examples:
137
+
138
+ Basic usage:
139
+ @traced_action(action="note.create", kind="app")
140
+ def create_note(title, body):
141
+ ...
142
+
143
+ With selective input capture:
144
+ @traced_action(
145
+ action="note.create",
146
+ kind="app",
147
+ capture_inputs=["title", "user_id"],
148
+ )
149
+ def create_note(title, body, user_id):
150
+ ...
151
+
152
+ Database function:
153
+ @traced_action(
154
+ action="note.save",
155
+ kind="db",
156
+ operation="insert",
157
+ target="notes",
158
+ database="sqlite",
159
+ )
160
+ def save_note(note):
161
+ ...
162
+
163
+ Async function:
164
+ @traced_action(action="payment.authorise", kind="payment")
165
+ async def authorise_payment(amount, currency):
166
+ ...
167
+ """
168
+ def decorator(func: Any) -> Any:
169
+ # Decide now (at decoration time, not call time) which wrapper to use.
170
+ # This avoids an inspect.iscoroutinefunction() check on every call.
171
+ if inspect.iscoroutinefunction(func):
172
+ return _async_wrapper(
173
+ func, action, kind, actor, project, operation, target,
174
+ database, capture_inputs, meta, config, budget, correlation_id,
175
+ )
176
+ else:
177
+ return _sync_wrapper(
178
+ func, action, kind, actor, project, operation, target,
179
+ database, capture_inputs, meta, config, budget, correlation_id,
180
+ )
181
+
182
+ return decorator
183
+
184
+
185
+ # ---------------------------------------------------------------------------
186
+ # Sync wrapper
187
+ # ---------------------------------------------------------------------------
188
+
189
+ def _sync_wrapper(
190
+ func: Any,
191
+ action: str,
192
+ kind: str,
193
+ actor: Optional[str],
194
+ project: Optional[str],
195
+ operation: Optional[str],
196
+ target: Optional[str],
197
+ database: Optional[str],
198
+ capture_inputs: Any,
199
+ meta: Optional[Dict[str, Any]],
200
+ config: Optional[TraceConfig],
201
+ budget: Optional[TraceBudget],
202
+ correlation_id: Optional[str],
203
+ ) -> Any:
204
+ """
205
+ Build and return a sync wrapper for the given function.
206
+
207
+ The wrapper follows this flow on every call:
208
+ 1. Ask _create_trace() whether to create a real trace or skip.
209
+ 2. If skipped (sampled out): push SKIP onto ContextVar, run function, restore.
210
+ 3. If no-op (disabled / depth exceeded): run function with no context changes.
211
+ 4. If real trace: push trace, capture inputs, run function, finish, restore.
212
+ """
213
+
214
+ @functools.wraps(func)
215
+ def wrapper(*args: Any, **kwargs: Any) -> Any:
216
+ # Build (or decide to skip) the trace. _create_trace handles all the
217
+ # checks: enabled?, skip sentinel?, sampling?, depth limit?.
218
+ trace_or_noop = _create_trace(
219
+ action=action,
220
+ kind=kind,
221
+ actor=actor,
222
+ project=project,
223
+ correlation_id=correlation_id,
224
+ meta=meta,
225
+ config_override=config,
226
+ budget_override=budget,
227
+ operation=operation,
228
+ target=target,
229
+ database=database,
230
+ )
231
+
232
+ # Case 1: The trace was sampled out. We must push SKIP onto the
233
+ # ContextVar so that nested @traced_action calls also skip.
234
+ if isinstance(trace_or_noop, _SkippedTrace):
235
+ token = push_trace(SKIP)
236
+ try:
237
+ return func(*args, **kwargs)
238
+ finally:
239
+ pop_trace(token)
240
+
241
+ # Case 2: Tracing is disabled or depth exceeded. No context changes
242
+ # needed — just run the function directly.
243
+ if isinstance(trace_or_noop, _NoOpTrace):
244
+ return func(*args, **kwargs)
245
+
246
+ # Case 3: We have a real trace. Run the full tracing lifecycle.
247
+ trace: ActionTrace = trace_or_noop
248
+
249
+ # Push the trace onto the ContextVar. Any @traced_action calls made
250
+ # inside func() will find this trace as their parent.
251
+ token = push_trace(trace)
252
+
253
+ try:
254
+ # Optionally capture function arguments as trace inputs.
255
+ # This happens before the function runs so inputs are recorded even
256
+ # if the function raises immediately.
257
+ if capture_inputs is not False:
258
+ _capture_inputs(trace, func, args, kwargs, capture_inputs)
259
+
260
+ # Run the actual function.
261
+ result = func(*args, **kwargs)
262
+
263
+ # Success path.
264
+ trace._finish(status="completed")
265
+ return result
266
+
267
+ except Exception as exc:
268
+ # Failure path. The exception is re-raised after the trace is
269
+ # finished — TraceAct never suppresses exceptions.
270
+ trace._finish(status="failed", error=exc)
271
+ raise
272
+
273
+ finally:
274
+ # Always restore the ContextVar, regardless of success or failure.
275
+ pop_trace(token)
276
+
277
+ return wrapper
278
+
279
+
280
+ # ---------------------------------------------------------------------------
281
+ # Async wrapper
282
+ # ---------------------------------------------------------------------------
283
+
284
+ def _async_wrapper(
285
+ func: Any,
286
+ action: str,
287
+ kind: str,
288
+ actor: Optional[str],
289
+ project: Optional[str],
290
+ operation: Optional[str],
291
+ target: Optional[str],
292
+ database: Optional[str],
293
+ capture_inputs: Any,
294
+ meta: Optional[Dict[str, Any]],
295
+ config: Optional[TraceConfig],
296
+ budget: Optional[TraceBudget],
297
+ correlation_id: Optional[str],
298
+ ) -> Any:
299
+ """
300
+ Build and return an async wrapper for the given coroutine function.
301
+
302
+ The logic is identical to _sync_wrapper except that the function is awaited.
303
+ ContextVar is async-safe in Python 3.7+: each asyncio Task inherits a copy
304
+ of the current context from the task that created it. Within a single Task,
305
+ the ContextVar behaves like a call-stack variable.
306
+ """
307
+
308
+ @functools.wraps(func)
309
+ async def wrapper(*args: Any, **kwargs: Any) -> Any:
310
+ trace_or_noop = _create_trace(
311
+ action=action,
312
+ kind=kind,
313
+ actor=actor,
314
+ project=project,
315
+ correlation_id=correlation_id,
316
+ meta=meta,
317
+ config_override=config,
318
+ budget_override=budget,
319
+ operation=operation,
320
+ target=target,
321
+ database=database,
322
+ )
323
+
324
+ # Sampled out — push SKIP, await, restore.
325
+ if isinstance(trace_or_noop, _SkippedTrace):
326
+ token = push_trace(SKIP)
327
+ try:
328
+ return await func(*args, **kwargs)
329
+ finally:
330
+ pop_trace(token)
331
+
332
+ # Disabled / depth exceeded — just await.
333
+ if isinstance(trace_or_noop, _NoOpTrace):
334
+ return await func(*args, **kwargs)
335
+
336
+ # Real trace.
337
+ trace: ActionTrace = trace_or_noop
338
+ token = push_trace(trace)
339
+
340
+ try:
341
+ if capture_inputs is not False:
342
+ _capture_inputs(trace, func, args, kwargs, capture_inputs)
343
+
344
+ result = await func(*args, **kwargs)
345
+ trace._finish(status="completed")
346
+ return result
347
+
348
+ except Exception as exc:
349
+ trace._finish(status="failed", error=exc)
350
+ raise
351
+
352
+ finally:
353
+ pop_trace(token)
354
+
355
+ return wrapper
356
+
357
+
358
+ # ---------------------------------------------------------------------------
359
+ # Input capture
360
+ # ---------------------------------------------------------------------------
361
+
362
+ def _capture_inputs(
363
+ trace: ActionTrace,
364
+ func: Any,
365
+ args: tuple,
366
+ kwargs: Dict[str, Any],
367
+ capture_spec: Any,
368
+ ) -> None:
369
+ """
370
+ Capture function arguments and record them on the trace as inputs.
371
+
372
+ This is called before the function body runs, so inputs are always recorded
373
+ even if the function raises immediately.
374
+
375
+ Args:
376
+ trace: The ActionTrace to record inputs on.
377
+ func: The original (unwrapped) function.
378
+ args: Positional arguments passed to the function.
379
+ kwargs: Keyword arguments passed to the function.
380
+ capture_spec: False (no capture), True (all args), or a list of field names.
381
+
382
+ How it works:
383
+ 1. inspect.signature() gives us the function's parameter names.
384
+ 2. We map positional args to their names using that signature.
385
+ 3. We skip "self" and "cls" (the first param of methods).
386
+ 4. If capture_spec is a list, we filter to only those names.
387
+ 5. We apply redaction and size limits via the trace's config/budget.
388
+ 6. We call trace.input() with the sanitised dict.
389
+
390
+ Failures are swallowed (in non-strict mode) so that a problem with
391
+ input capture never breaks the function call.
392
+ """
393
+ try:
394
+ # Get the parameter list from the function signature.
395
+ sig = inspect.signature(func)
396
+ param_names = list(sig.parameters.keys())
397
+
398
+ # Remove "self" and "cls" — instance method receivers are not inputs.
399
+ if param_names and param_names[0] in ("self", "cls"):
400
+ param_names = param_names[1:]
401
+ args = args[1:]
402
+
403
+ # Build a dict mapping parameter names to their values.
404
+ # Positional args are mapped by position; kwargs are merged in.
405
+ bound: Dict[str, Any] = {}
406
+ for i, val in enumerate(args):
407
+ if i < len(param_names):
408
+ bound[param_names[i]] = val
409
+ bound.update(kwargs)
410
+
411
+ # If capture_spec is a list, keep only the explicitly requested fields.
412
+ # This is the safest form: the developer chose exactly what to record.
413
+ if isinstance(capture_spec, list):
414
+ bound = {k: v for k, v in bound.items() if k in capture_spec}
415
+
416
+ # If capture_spec is True, keep everything (already in bound).
417
+ # We do not need an elif here — the list branch has already filtered.
418
+
419
+ if not bound:
420
+ return
421
+
422
+ # Delegate to trace.input(), which applies redaction and size limits.
423
+ trace.input(bound)
424
+
425
+ except Exception as exc:
426
+ # Input capture should never crash the application.
427
+ if trace._effective_config.strict:
428
+ raise
429
+ # In non-strict mode, silently skip the capture.
430
+ _ = exc
traceact/helpers.py ADDED
@@ -0,0 +1,103 @@
1
+ # helpers.py
2
+ #
3
+ # Defines TraceHelpersMixin — a mixin class that adds convenience methods to
4
+ # ActionTrace for common event kinds.
5
+ #
6
+ # Why a mixin?
7
+ # The helpers (db, http, file, model) are just thin wrappers around
8
+ # trace.event(). Putting them in a separate mixin keeps trace.py focused on
9
+ # the core trace lifecycle and makes it easy to add more helpers later without
10
+ # touching the main ActionTrace class.
11
+ #
12
+ # Why not standalone functions like trace_db(...)?
13
+ # The methods live on the trace object (trace.db(...)) because the trace object
14
+ # is the natural scope for recording an event. A standalone function would need
15
+ # to look up the active trace from the ContextVar every time it was called,
16
+ # which is less readable and hides the relationship between the call and the
17
+ # trace it belongs to.
18
+ #
19
+ # All helpers use "target" as the resource field name. Aliases like "table",
20
+ # "url", or "path" are not accepted — the grammar stays consistent across all
21
+ # kinds so that traces are uniform regardless of the operation type.
22
+
23
+ from typing import Any
24
+
25
+
26
+ class TraceHelpersMixin:
27
+ """
28
+ Convenience methods for recording common event kinds on a trace.
29
+
30
+ These methods are mixed into ActionTrace. They are thin wrappers around
31
+ trace.event() and exist purely to reduce typing for the most frequent
32
+ operations. They add no new behaviour.
33
+
34
+ All methods accept arbitrary keyword arguments (**kwargs) that are passed
35
+ through to trace.event(). This means any field defined in the event schema
36
+ (rows, status, duration_ms, result, etc.) can be provided.
37
+ """
38
+
39
+ def db(self, operation: str, target: str, **kwargs: Any) -> None:
40
+ """
41
+ Record a database event.
42
+
43
+ Equivalent to: trace.event(kind="db", operation=operation, target=target, ...)
44
+
45
+ Args:
46
+ operation: The database operation. Standard values: select, insert,
47
+ update, delete, upsert, transaction, migration.
48
+ target: The table or database resource involved. Example: "notes".
49
+ **kwargs: Additional fields such as rows=1, database="sqlite",
50
+ safe_query="...", params_shape={...}, duration_ms=12.4.
51
+
52
+ Example:
53
+ trace.db(operation="insert", target="notes", rows=1)
54
+ """
55
+ self.event(kind="db", operation=operation, target=target, **kwargs)
56
+
57
+ def http(self, operation: str, target: str, **kwargs: Any) -> None:
58
+ """
59
+ Record an HTTP event.
60
+
61
+ Equivalent to: trace.event(kind="http", operation=operation, target=target, ...)
62
+
63
+ Args:
64
+ operation: The HTTP method or action. Examples: get, post, put, delete.
65
+ target: The endpoint or service name. Example: "payment-provider".
66
+ **kwargs: Additional fields such as status_code=200, duration_ms=120.
67
+
68
+ Example:
69
+ trace.http(operation="post", target="stripe", status_code=200)
70
+ """
71
+ self.event(kind="http", operation=operation, target=target, **kwargs)
72
+
73
+ def file(self, operation: str, target: str, **kwargs: Any) -> None:
74
+ """
75
+ Record a file operation event.
76
+
77
+ Equivalent to: trace.event(kind="file", operation=operation, target=target, ...)
78
+
79
+ Args:
80
+ operation: The file operation. Examples: read, write, delete, move.
81
+ target: The file path. Example: "data/notes.json".
82
+ **kwargs: Additional fields such as bytes_written=1024.
83
+
84
+ Example:
85
+ trace.file(operation="write", target="data/notes.json")
86
+ """
87
+ self.event(kind="file", operation=operation, target=target, **kwargs)
88
+
89
+ def model(self, operation: str, target: str, **kwargs: Any) -> None:
90
+ """
91
+ Record a model call event.
92
+
93
+ Equivalent to: trace.event(kind="model", operation=operation, target=target, ...)
94
+
95
+ Args:
96
+ operation: The model operation. Examples: completion, embedding, classification.
97
+ target: The model name or identifier. Example: "claude-sonnet-5".
98
+ **kwargs: Additional fields such as tokens_in=1200, tokens_out=300.
99
+
100
+ Example:
101
+ trace.model(operation="completion", target="claude-sonnet-5", tokens_in=800)
102
+ """
103
+ self.event(kind="model", operation=operation, target=target, **kwargs)
traceact/ids.py ADDED
@@ -0,0 +1,62 @@
1
+ # ids.py
2
+ #
3
+ # Generates prefixed, human-readable IDs for traces, events, steps, and
4
+ # correlation groups.
5
+ #
6
+ # Why prefixes?
7
+ # When you read a raw trace record — in a log file, a terminal, or a debugger
8
+ # — a bare UUID tells you nothing about what you are looking at. A prefixed ID
9
+ # like "trc_9f3a1c7b2d44" or "evt_184c90aa22af" is immediately identifiable.
10
+ # Developers know at a glance whether an ID belongs to a trace, an event, or a
11
+ # step without having to cross-reference the field name.
12
+ #
13
+ # Why 12 hex characters?
14
+ # 6 random bytes gives 2^48 (~281 trillion) possible values. Collision risk in
15
+ # a single session is negligible. The IDs are short enough to read but long
16
+ # enough to be unique across all traces in a single run.
17
+
18
+ import secrets
19
+
20
+
21
+ def _generate_id(prefix: str) -> str:
22
+ """
23
+ Build a prefixed ID from 6 random bytes encoded as lowercase hex.
24
+
25
+ The format is: {prefix}_{12_hex_chars}
26
+ Example: trc_9f3a1c7b2d44
27
+
28
+ Args:
29
+ prefix: A short string identifying the kind of record (e.g. "trc", "evt").
30
+
31
+ Returns:
32
+ A string ID that is unique with overwhelming probability.
33
+ """
34
+ return f"{prefix}_{secrets.token_hex(6)}"
35
+
36
+
37
+ def new_trace_id() -> str:
38
+ """Return a fresh trace ID. Example: trc_9f3a1c7b2d44"""
39
+ return _generate_id("trc")
40
+
41
+
42
+ def new_event_id() -> str:
43
+ """Return a fresh event ID. Example: evt_184c90aa22af"""
44
+ return _generate_id("evt")
45
+
46
+
47
+ def new_step_id() -> str:
48
+ """Return a fresh step ID. Example: stp_62e1aa49c103"""
49
+ return _generate_id("stp")
50
+
51
+
52
+ def new_correlation_id() -> str:
53
+ """
54
+ Return a fresh correlation ID for linking related traces.
55
+ Example: corr_71ac4e19aaf0
56
+
57
+ A correlation ID is passed externally (e.g. from a request header or a
58
+ job queue message) rather than generated internally most of the time.
59
+ This function exists for cases where the caller wants to start a new
60
+ correlation group and needs a fresh ID to assign.
61
+ """
62
+ return _generate_id("corr")