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 ADDED
@@ -0,0 +1,43 @@
1
+ # __init__.py
2
+ #
3
+ # Public API for the traceact package.
4
+ #
5
+ # Everything a developer needs to use TraceAct is exported from here. Importing
6
+ # from the submodules directly (e.g. from traceact.trace import ActionTrace) is
7
+ # supported but not the intended pattern — this file is the stable public surface.
8
+ #
9
+ # Public API (v1):
10
+ #
11
+ # ActionTrace — the live trace object. Use with ActionTrace.start() for manual
12
+ # tracing inside a with-block.
13
+ #
14
+ # TraceConfig — settings object. Pass to configure() or to @traced_action.
15
+ #
16
+ # TraceBudget — limits object. Pass to configure() or to @traced_action.
17
+ #
18
+ # configure() — set package-wide defaults for all future traces.
19
+ #
20
+ # reset_config()— restore package defaults (use in test teardown).
21
+ #
22
+ # traced_action — decorator. The primary way to add tracing to a function.
23
+ #
24
+ # JsonlSink — writes traces to a .jsonl file.
25
+ #
26
+ # ConsoleSink — prints traces to stdout.
27
+
28
+ from traceact.config import configure, reset_config, TraceConfig
29
+ from traceact.budget import TraceBudget
30
+ from traceact.trace import ActionTrace
31
+ from traceact.decorators import traced_action
32
+ from traceact.sinks import JsonlSink, ConsoleSink
33
+
34
+ __all__ = [
35
+ "ActionTrace",
36
+ "TraceConfig",
37
+ "TraceBudget",
38
+ "configure",
39
+ "reset_config",
40
+ "traced_action",
41
+ "JsonlSink",
42
+ "ConsoleSink",
43
+ ]
traceact/budget.py ADDED
@@ -0,0 +1,103 @@
1
+ # budget.py
2
+ #
3
+ # Defines TraceBudget — the limits object that controls how much TraceAct
4
+ # records before stopping and flagging budget_hit on the trace.
5
+ #
6
+ # Why a separate budget from config?
7
+ # Config controls *behaviour* (is tracing on? what mode? strict or not?).
8
+ # Budget controls *volume* (how many events? how deep? what size payloads?).
9
+ # Keeping them separate makes it easy to tune one without touching the other,
10
+ # and makes the inherited-vs-overridden distinction clear at each trace boundary.
11
+ #
12
+ # How budget inheritance works:
13
+ # Like TraceConfig, TraceBudget uses None to mean "not specified." When a child
14
+ # trace specifies TraceBudget(max_events=300), only max_events is overridden —
15
+ # all other fields are inherited from the parent trace or the package default.
16
+ #
17
+ # This is key because a specific trace (say, an agent loop) might need more
18
+ # events than the default, but still wants the global max_depth and
19
+ # always_trace_errors settings to apply.
20
+
21
+ from typing import Optional
22
+
23
+
24
+ class TraceBudget:
25
+ """
26
+ Controls the recording limits for a trace.
27
+
28
+ All fields default to None, which means "not specified — inherit from the
29
+ parent trace or use the package default." See _resolve_budget() in trace.py
30
+ for how inheritance is applied.
31
+
32
+ Fields:
33
+ max_events:
34
+ Maximum number of events to record in a single trace. Once this
35
+ limit is reached, further calls to trace.event() are silently
36
+ dropped and budget_hit is set to True on the trace. The wrapped
37
+ function continues to run normally.
38
+
39
+ max_steps:
40
+ Maximum number of steps to record. Same behaviour as max_events
41
+ once the limit is hit.
42
+
43
+ max_depth:
44
+ Maximum nesting depth for child traces. If a @traced_action
45
+ decorator would create a trace at depth > max_depth, the decorator
46
+ behaves as if tracing is disabled for that call. The function still
47
+ runs normally; it just produces no trace record.
48
+
49
+ max_payload_bytes:
50
+ Maximum size (in bytes, after JSON serialisation) of any single
51
+ captured value — an input field, an event result field, or an
52
+ output field. Values that exceed this are replaced with a
53
+ "[truncated: N chars]" summary.
54
+
55
+ sample_rate:
56
+ The fraction of successful traces to record. 1.0 means record
57
+ everything. 0.1 means record approximately 10% of traces. Failed
58
+ traces (status="failed") are always recorded when always_trace_errors
59
+ is True, regardless of sample_rate.
60
+
61
+ Sampling happens before a trace object is created. If a trace is
62
+ sampled out, the ContextVar is set to a skip sentinel so that any
63
+ nested @traced_action calls are also skipped. Nothing is written.
64
+
65
+ always_trace_errors:
66
+ When True (default), failed traces are always recorded even if
67
+ sample_rate would otherwise drop them. This ensures that errors are
68
+ never silently lost because of sampling.
69
+ """
70
+
71
+ def __init__(
72
+ self,
73
+ max_events: Optional[int] = None,
74
+ max_steps: Optional[int] = None,
75
+ max_depth: Optional[int] = None,
76
+ max_payload_bytes: Optional[int] = None,
77
+ sample_rate: Optional[float] = None,
78
+ always_trace_errors: Optional[bool] = None,
79
+ ) -> None:
80
+ self.max_events = max_events
81
+ self.max_steps = max_steps
82
+ self.max_depth = max_depth
83
+ self.max_payload_bytes = max_payload_bytes
84
+ self.sample_rate = sample_rate
85
+ self.always_trace_errors = always_trace_errors
86
+
87
+
88
+ # ---------------------------------------------------------------------------
89
+ # Package-level defaults
90
+ # ---------------------------------------------------------------------------
91
+ #
92
+ # These are the values used when no package config and no trace-level override
93
+ # specifies a budget field. They are intentionally generous so that TraceAct
94
+ # works well in small local tools without any configuration at all.
95
+
96
+ BUDGET_DEFAULTS = {
97
+ "max_events": 100,
98
+ "max_steps": 50,
99
+ "max_depth": 10,
100
+ "max_payload_bytes": 8192,
101
+ "sample_rate": 1.0,
102
+ "always_trace_errors": True,
103
+ }
traceact/config.py ADDED
@@ -0,0 +1,198 @@
1
+ # config.py
2
+ #
3
+ # Defines TraceConfig — the settings object that controls how TraceAct behaves —
4
+ # and the package-level configure() / reset_config() functions.
5
+ #
6
+ # Why a separate config module?
7
+ # Configuration is shared across the whole package. Every trace, decorator, and
8
+ # sink needs to read the same settings. Centralising them here means there is one
9
+ # place to look when a setting needs to change, and one place to reset when a
10
+ # test has left state behind.
11
+ #
12
+ # How config inheritance works:
13
+ # TraceConfig uses None to mean "not specified". A None field means "inherit from
14
+ # the parent trace's config, or fall back to the package default." This allows a
15
+ # single decorator to override only the fields it cares about, rather than
16
+ # replacing the entire config object.
17
+ #
18
+ # The resolution order (lowest to highest priority) is:
19
+ # Package defaults → package-level configure() → parent trace config → decorator override
20
+ #
21
+ # See trace.py for the _resolve_config() function that applies this ordering.
22
+
23
+ from typing import Any, List, Optional
24
+
25
+
26
+ class TraceConfig:
27
+ """
28
+ Controls the behaviour of TraceAct tracing.
29
+
30
+ All fields default to None, which means "not specified — use the package
31
+ default or inherit from the parent trace." Only fields you explicitly set
32
+ will override the inherited value.
33
+
34
+ Fields:
35
+ enabled:
36
+ Whether tracing is active. When False, @traced_action decorators
37
+ run their wrapped function with almost no overhead — no trace is
38
+ created, no context is touched, no sink is called. This is the
39
+ global kill switch.
40
+
41
+ sink_mode:
42
+ Controls when traces are written to sinks.
43
+ "blocking" — write immediately when a trace finishes. Best for
44
+ local development and tests.
45
+ "buffered" — accumulate finished traces in memory and flush them
46
+ later (or on program exit). Best default for small apps.
47
+ "disabled" — never write anything, regardless of sink configuration.
48
+ Use as a performance kill switch that still leaves
49
+ decorators in place.
50
+
51
+ strict:
52
+ When True, any exception raised inside TraceAct's own tracing logic
53
+ (not inside the function being traced) will propagate into the
54
+ application. When False (default), tracing failures are silently
55
+ swallowed so the app is never broken by its own observability layer.
56
+
57
+ redact_by_default:
58
+ When True, field names that match known sensitive patterns (such as
59
+ "password", "token", "secret", "api_key") are replaced with the
60
+ string "[redacted]" before being stored in inputs, events, or outputs.
61
+ This applies to both automatic input capture and manual trace.event()
62
+ calls.
63
+
64
+ capture_inputs:
65
+ Controls the automatic input-capture behaviour of @traced_action.
66
+
67
+ None / not set — use the package default, which is False (no capture).
68
+ False — disable automatic capture globally. When set at the
69
+ package level via configure(), individual decorators
70
+ cannot re-enable automatic capture. Manual
71
+ trace.input() calls still work.
72
+ True — capture all named function arguments automatically,
73
+ applying redaction and payload limits.
74
+ list of strings — capture only the named arguments in the list.
75
+ This is the safest opt-in form: you choose exactly
76
+ which fields are recorded.
77
+
78
+ capture_outputs:
79
+ When True (default), calls to trace.output() are recorded on the
80
+ trace. When False, trace.output() is a no-op.
81
+ """
82
+
83
+ def __init__(
84
+ self,
85
+ enabled: Optional[bool] = None,
86
+ sink_mode: Optional[str] = None,
87
+ strict: Optional[bool] = None,
88
+ redact_by_default: Optional[bool] = None,
89
+ capture_inputs: Any = None,
90
+ capture_outputs: Optional[bool] = None,
91
+ ) -> None:
92
+ self.enabled = enabled
93
+ self.sink_mode = sink_mode
94
+ self.strict = strict
95
+ self.redact_by_default = redact_by_default
96
+ self.capture_inputs = capture_inputs
97
+ self.capture_outputs = capture_outputs
98
+
99
+
100
+ # ---------------------------------------------------------------------------
101
+ # Package-level state
102
+ # ---------------------------------------------------------------------------
103
+ #
104
+ # These three variables hold the current package-wide configuration, budget,
105
+ # and list of sinks. They start as None / empty, which means "use defaults."
106
+ # configure() sets them. reset_config() clears them back to None / empty.
107
+ #
108
+ # Why module-level variables instead of a class?
109
+ # A module is itself a singleton in Python — imported once and cached. Module-
110
+ # level variables are the simplest, most readable way to hold package state
111
+ # without introducing a global object that callers must instantiate.
112
+
113
+ _package_config: Optional[TraceConfig] = None
114
+ _package_budget: Any = None # Optional[TraceBudget] — typed as Any to avoid circular import
115
+ _package_sinks: List[Any] = [] # List[Sink]
116
+
117
+
118
+ def configure(
119
+ config: Optional[TraceConfig] = None,
120
+ budget: Any = None,
121
+ sinks: Optional[List[Any]] = None,
122
+ ) -> None:
123
+ """
124
+ Set package-wide defaults for all future traces.
125
+
126
+ Call this once at application startup. Settings applied here become the
127
+ baseline for every trace unless a specific trace or decorator overrides them.
128
+
129
+ Args:
130
+ config: A TraceConfig instance. Only non-None fields are applied; the
131
+ rest keep their current values (or package defaults).
132
+ budget: A TraceBudget instance. Only non-None fields are applied.
133
+ sinks: A list of sink objects (JsonlSink, ConsoleSink, etc.). Replaces
134
+ the current sink list entirely.
135
+
136
+ Example:
137
+ configure(
138
+ config=TraceConfig(enabled=True, sink_mode="buffered"),
139
+ budget=TraceBudget(max_events=200),
140
+ sinks=[JsonlSink("traces.jsonl")],
141
+ )
142
+ """
143
+ global _package_config, _package_budget, _package_sinks
144
+
145
+ if config is not None:
146
+ _package_config = config
147
+
148
+ if budget is not None:
149
+ _package_budget = budget
150
+
151
+ if sinks is not None:
152
+ _package_sinks = list(sinks)
153
+
154
+
155
+ def reset_config() -> None:
156
+ """
157
+ Restore all package-level state to its initial defaults.
158
+
159
+ Use this in test teardown to prevent one test's configure() call from
160
+ leaking into the next test. reset_config() also clears the active trace
161
+ ContextVar so no orphaned trace context carries over between tests.
162
+
163
+ What it resets:
164
+ - TraceConfig → package defaults (enabled, buffered, strict=False, etc.)
165
+ - TraceBudget → package defaults
166
+ - Sink list → empty (no sinks)
167
+ - Active trace ContextVar → None (no active trace)
168
+
169
+ What it does NOT reset:
170
+ - Traces that have already been written to a sink. Those are gone.
171
+ - Files on disk. JsonlSink output is not deleted.
172
+ """
173
+ global _package_config, _package_budget, _package_sinks
174
+
175
+ _package_config = None
176
+ _package_budget = None
177
+ _package_sinks = []
178
+
179
+ # Clear the active trace context so tests start with a clean slate.
180
+ # The deferred import avoids a circular dependency at module load time
181
+ # (context.py does not import config.py).
182
+ from traceact.context import _active_trace
183
+ _active_trace.set(None)
184
+
185
+
186
+ def get_package_config() -> Optional[TraceConfig]:
187
+ """Return the package-level TraceConfig, or None if not set."""
188
+ return _package_config
189
+
190
+
191
+ def get_package_budget() -> Any:
192
+ """Return the package-level TraceBudget, or None if not set."""
193
+ return _package_budget
194
+
195
+
196
+ def get_package_sinks() -> List[Any]:
197
+ """Return the current list of configured sinks."""
198
+ return _package_sinks
traceact/context.py ADDED
@@ -0,0 +1,130 @@
1
+ # context.py
2
+ #
3
+ # Manages the active trace using Python's contextvars.ContextVar.
4
+ #
5
+ # Why ContextVar?
6
+ # Python's contextvars module (introduced in 3.7) provides context-local
7
+ # storage that is automatically isolated across:
8
+ # - Threads (each thread gets its own copy)
9
+ # - asyncio Tasks (each task gets its own copy, inherited from its parent)
10
+ # - Nested function calls (the ContextVar token mechanism allows exact restore)
11
+ #
12
+ # This makes it the right tool for tracking "which trace is currently running"
13
+ # without requiring the developer to pass a trace object through every function
14
+ # call manually.
15
+ #
16
+ # The public API never exposes the ContextVar directly. Developers interact with
17
+ # traces through @traced_action and ActionTrace.start(), not through this module.
18
+ #
19
+ # How the skip sentinel works:
20
+ # When a trace is sampled out (sample_rate < 1.0 decides to skip it), we still
21
+ # set the ContextVar — but we set it to the SKIP sentinel rather than to a real
22
+ # ActionTrace. Any nested @traced_action call checks the ContextVar first: if it
23
+ # sees SKIP, it also skips. This ensures that a sampled-out parent silently
24
+ # suppresses all its children without requiring any coordination between traces.
25
+ #
26
+ # Without the skip sentinel, a sampled-out parent would leave the ContextVar
27
+ # empty, and a nested @traced_action would see "no active parent" and create a
28
+ # new root trace — which would then appear in the sink without its parent, making
29
+ # the output confusing and incomplete.
30
+
31
+ from contextvars import ContextVar
32
+ from typing import Any, Optional
33
+
34
+
35
+ # ---------------------------------------------------------------------------
36
+ # Skip sentinel
37
+ # ---------------------------------------------------------------------------
38
+ #
39
+ # A sentinel is a unique object used as a special signal value. We use a class
40
+ # rather than a string like "SKIP" so that nothing can accidentally match it.
41
+
42
+ class _SkipSentinel:
43
+ """
44
+ Marker placed in the ContextVar when a trace has been sampled out.
45
+
46
+ Any code that reads the ContextVar and finds this object should treat the
47
+ current execution context as "tracing suppressed" and do nothing.
48
+ """
49
+ __slots__ = ()
50
+
51
+ def __repr__(self) -> str:
52
+ return "<TraceAct: sampled out>"
53
+
54
+
55
+ # The single instance of the skip sentinel. This is what gets stored in the
56
+ # ContextVar when a trace is sampled out.
57
+ SKIP: _SkipSentinel = _SkipSentinel()
58
+
59
+
60
+ def is_skip(value: Any) -> bool:
61
+ """Return True if the given value is the skip sentinel."""
62
+ return isinstance(value, _SkipSentinel)
63
+
64
+
65
+ # ---------------------------------------------------------------------------
66
+ # The ContextVar
67
+ # ---------------------------------------------------------------------------
68
+ #
69
+ # This variable holds one of three things at any point in time:
70
+ # None — no active trace (we are outside any traced function)
71
+ # ActionTrace — a live trace that @traced_action or ActionTrace.start() created
72
+ # SKIP — we are inside a sampled-out trace; everything is suppressed
73
+ #
74
+ # The variable is module-level so it is truly global (one per interpreter), but
75
+ # ContextVar ensures each asyncio Task and each thread sees its own independent
76
+ # value. Two concurrent requests will never interfere with each other's active
77
+ # trace.
78
+
79
+ _active_trace: ContextVar[Optional[Any]] = ContextVar(
80
+ "traceact_active_trace",
81
+ default=None,
82
+ )
83
+
84
+
85
+ def get_active_trace() -> Optional[Any]:
86
+ """
87
+ Return whatever is currently stored in the active trace ContextVar.
88
+
89
+ Returns:
90
+ None — no active trace
91
+ ActionTrace — the currently running trace
92
+ SKIP sentinel — we are inside a sampled-out parent
93
+ """
94
+ return _active_trace.get()
95
+
96
+
97
+ def push_trace(trace_or_skip: Any) -> Any:
98
+ """
99
+ Set a new value as the active trace and return a token for restoring the
100
+ previous value later.
101
+
102
+ Args:
103
+ trace_or_skip: Either an ActionTrace or the SKIP sentinel.
104
+
105
+ Returns:
106
+ A ContextVar Token. Pass this to pop_trace() when the trace finishes
107
+ to restore whatever was active before.
108
+
109
+ How the token mechanism works:
110
+ Python's ContextVar.set() returns a Token object that remembers the
111
+ previous value. Calling ContextVar.reset(token) restores that exact
112
+ previous value — even if something else has changed the ContextVar in
113
+ between. This is what makes nested traces work correctly: each level
114
+ saves its own token and restores independently.
115
+ """
116
+ return _active_trace.set(trace_or_skip)
117
+
118
+
119
+ def pop_trace(token: Any) -> None:
120
+ """
121
+ Restore the active trace to whatever it was before the matching push_trace()
122
+ call.
123
+
124
+ Args:
125
+ token: The Token returned by the corresponding push_trace() call.
126
+
127
+ This should always be called in a finally block so that the context is
128
+ restored even if the traced function raises an exception.
129
+ """
130
+ _active_trace.reset(token)