traceact 0.6.2__tar.gz → 0.8.1__tar.gz
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-0.6.2/traceact.egg-info → traceact-0.8.1}/PKG-INFO +2 -1
- {traceact-0.6.2 → traceact-0.8.1}/README.md +1 -0
- {traceact-0.6.2 → traceact-0.8.1}/USAGE.md +28 -2
- {traceact-0.6.2 → traceact-0.8.1}/pyproject.toml +1 -1
- traceact-0.8.1/tests/test_project_name.py +153 -0
- {traceact-0.6.2 → traceact-0.8.1}/tests/test_viewer_instance.py +206 -2
- {traceact-0.6.2 → traceact-0.8.1}/traceact/__init__.py +1 -1
- {traceact-0.6.2 → traceact-0.8.1}/traceact/config.py +29 -7
- {traceact-0.6.2 → traceact-0.8.1}/traceact/sinks.py +15 -0
- {traceact-0.6.2 → traceact-0.8.1}/traceact/trace.py +22 -1
- {traceact-0.6.2 → traceact-0.8.1}/traceact/viewer/instance.py +28 -5
- {traceact-0.6.2 → traceact-0.8.1}/traceact/viewer/server.py +122 -13
- {traceact-0.6.2 → traceact-0.8.1}/traceact/viewer/static/app.js +98 -3
- {traceact-0.6.2 → traceact-0.8.1}/traceact/viewer/static/index.html +6 -1
- {traceact-0.6.2 → traceact-0.8.1}/traceact/viewer/static/styles.css +69 -1
- {traceact-0.6.2 → traceact-0.8.1/traceact.egg-info}/PKG-INFO +2 -1
- {traceact-0.6.2 → traceact-0.8.1}/traceact.egg-info/SOURCES.txt +1 -0
- {traceact-0.6.2 → traceact-0.8.1}/LICENSE +0 -0
- {traceact-0.6.2 → traceact-0.8.1}/MANIFEST.in +0 -0
- {traceact-0.6.2 → traceact-0.8.1}/setup.cfg +0 -0
- {traceact-0.6.2 → traceact-0.8.1}/tests/test_async_sink.py +0 -0
- {traceact-0.6.2 → traceact-0.8.1}/tests/test_decorators.py +0 -0
- {traceact-0.6.2 → traceact-0.8.1}/tests/test_doctor.py +0 -0
- {traceact-0.6.2 → traceact-0.8.1}/tests/test_http_sink.py +0 -0
- {traceact-0.6.2 → traceact-0.8.1}/tests/test_otlp_sink.py +0 -0
- {traceact-0.6.2 → traceact-0.8.1}/tests/test_propagation.py +0 -0
- {traceact-0.6.2 → traceact-0.8.1}/tests/test_reader.py +0 -0
- {traceact-0.6.2 → traceact-0.8.1}/tests/test_redaction.py +0 -0
- {traceact-0.6.2 → traceact-0.8.1}/tests/test_sinks.py +0 -0
- {traceact-0.6.2 → traceact-0.8.1}/tests/test_sqlite_sink.py +0 -0
- {traceact-0.6.2 → traceact-0.8.1}/tests/test_trace_hardening.py +0 -0
- {traceact-0.6.2 → traceact-0.8.1}/tests/test_tracelog.py +0 -0
- {traceact-0.6.2 → traceact-0.8.1}/tests/test_viewer_query.py +0 -0
- {traceact-0.6.2 → traceact-0.8.1}/traceact/budget.py +0 -0
- {traceact-0.6.2 → traceact-0.8.1}/traceact/context.py +0 -0
- {traceact-0.6.2 → traceact-0.8.1}/traceact/decorators.py +0 -0
- {traceact-0.6.2 → traceact-0.8.1}/traceact/helpers.py +0 -0
- {traceact-0.6.2 → traceact-0.8.1}/traceact/ids.py +0 -0
- {traceact-0.6.2 → traceact-0.8.1}/traceact/log.py +0 -0
- {traceact-0.6.2 → traceact-0.8.1}/traceact/middleware.py +0 -0
- {traceact-0.6.2 → traceact-0.8.1}/traceact/propagation.py +0 -0
- {traceact-0.6.2 → traceact-0.8.1}/traceact/redaction.py +0 -0
- {traceact-0.6.2 → traceact-0.8.1}/traceact/viewer/__init__.py +0 -0
- {traceact-0.6.2 → traceact-0.8.1}/traceact/viewer/cli.py +0 -0
- {traceact-0.6.2 → traceact-0.8.1}/traceact/viewer/doctor.py +0 -0
- {traceact-0.6.2 → traceact-0.8.1}/traceact/viewer/reader.py +0 -0
- {traceact-0.6.2 → traceact-0.8.1}/traceact.egg-info/dependency_links.txt +0 -0
- {traceact-0.6.2 → traceact-0.8.1}/traceact.egg-info/entry_points.txt +0 -0
- {traceact-0.6.2 → traceact-0.8.1}/traceact.egg-info/requires.txt +0 -0
- {traceact-0.6.2 → traceact-0.8.1}/traceact.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: traceact
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.8.1
|
|
4
4
|
Summary: X-ray vision for your code. Lightweight action-level tracing for Python.
|
|
5
5
|
Author: Mohammed Shehu
|
|
6
6
|
License-Expression: MIT
|
|
@@ -55,6 +55,7 @@ pip install traceact
|
|
|
55
55
|
from traceact import traced_action, configure, TraceConfig, JsonlSink
|
|
56
56
|
|
|
57
57
|
configure(
|
|
58
|
+
project="my-app",
|
|
58
59
|
config=TraceConfig(sink_mode="blocking"),
|
|
59
60
|
sinks=[JsonlSink("data/traces.jsonl")],
|
|
60
61
|
)
|
|
@@ -94,6 +94,7 @@ Call `configure()` once at application startup before any traces run.
|
|
|
94
94
|
from traceact import configure, TraceConfig, TraceBudget, JsonlSink, ConsoleSink
|
|
95
95
|
|
|
96
96
|
configure(
|
|
97
|
+
project="my-app", # stamps every trace; viewer uses this as the source name
|
|
97
98
|
config=TraceConfig(
|
|
98
99
|
enabled=True,
|
|
99
100
|
sink_mode="blocking", # "blocking" | "buffered" | "disabled"
|
|
@@ -119,6 +120,8 @@ configure(
|
|
|
119
120
|
|
|
120
121
|
All fields are optional. Omitted fields use package defaults. `configure()` can be called multiple times; later calls replace earlier ones.
|
|
121
122
|
|
|
123
|
+
**`project`** is the recommended way to name your traces. It is stamped onto every trace produced by this process and used by the viewer to label the source. Traces written without a `project` emit a `UserWarning` at runtime. A per-trace `project=` argument on `@traced_action` or `ActionTrace.start()` overrides the package-level value for that trace only.
|
|
124
|
+
|
|
122
125
|
**Sink modes:**
|
|
123
126
|
- `"blocking"` — write immediately when a trace finishes. Best for development.
|
|
124
127
|
- `"buffered"` — hold traces in memory, flush on exit or on explicit `flush_buffer()`. Best for production.
|
|
@@ -1356,7 +1359,7 @@ The Terminal window stays open so Ctrl+C stops the viewer. To pass a source file
|
|
|
1356
1359
|
|
|
1357
1360
|
- **Trace log** — a live, newest-first table of traces (time, action, status, duration, and touch/error/budget counts). A search box filters by action, kind, status, correlation ID, or touched target, against the currently tailed rows. The row count is capped (25 / 50 / 100 / 250, default 100) and paired with live tailing, so the newest traces are always in view. A pre-filtered view opened via `TraceLog.view()` instead searches the full source on disk — see [Server-side search](#server-side-search-apiquery) below.
|
|
1358
1361
|
- **Trace inspector** — selecting a trace shows its own ID, its parent and root trace IDs (when it is a child trace), correlation ID (when present, shown in full), kind, duration, and touch/error counts. "Copy JSON" copies the full record.
|
|
1359
|
-
- **Trace map** — a visual of one trace: the action as origin, its events and resources as connected nodes, with per-node status and a red marker on failures. Plays as a sequential step-through replay, with a speed slider (1×–10×, live, persisted) and pause/play.
|
|
1362
|
+
- **Trace map** — a visual of one trace: the action as origin, its events and resources as connected nodes, with per-node status and a red marker on failures. Plays as a sequential step-through replay, with a speed slider (1×–10×, live, persisted) and pause/play. The map zooms and pans: the mouse wheel zooms about the cursor, left-drag pans, and the `+` / `−` / `⟲` buttons zoom about the centre and reset to 1×. Zoom is clamped to 0.2×–5× and resets when you select a different trace.
|
|
1360
1363
|
- **Settings** — accent colour, display density, default trace view, row count, default replay speed, and a **Run diagnostics** button — all persisted to `localStorage` except diagnostics, which runs fresh each time.
|
|
1361
1364
|
|
|
1362
1365
|
### Run diagnostics (Settings)
|
|
@@ -1447,9 +1450,32 @@ launch_or_connect(
|
|
|
1447
1450
|
port=8765, # port to try if starting a new viewer
|
|
1448
1451
|
open_browser=False,
|
|
1449
1452
|
timeout=3.0, # seconds to wait for a freshly started server to be ready
|
|
1450
|
-
|
|
1453
|
+
name=None, # label for this source in the picker; derived from the path if unset
|
|
1454
|
+
) -> str # returns the viewer URL, e.g. "http://127.0.0.1:8765/?source=agora"
|
|
1451
1455
|
```
|
|
1452
1456
|
|
|
1457
|
+
### How sources are named
|
|
1458
|
+
|
|
1459
|
+
The viewer labels each source in its picker. Left to itself it derives the name from the path, skipping components that describe storage rather than a project (`traces`, `data`, `logs`, `output`, `var`, `tmp`, and similar):
|
|
1460
|
+
|
|
1461
|
+
| Path | Name |
|
|
1462
|
+
|---|---|
|
|
1463
|
+
| `~/Dev/agora/data/traces/traces.jsonl` | `agora` |
|
|
1464
|
+
| `~/Dev/casewright/logs/traces.jsonl` | `casewright` |
|
|
1465
|
+
| `~/Dev/agora/agora_traces.jsonl` | `agora_traces` |
|
|
1466
|
+
| `~/Dev/agora/data/traces/worker.jsonl` | `worker` |
|
|
1467
|
+
| `~/Dev/agora/data/traces/traces.1234.jsonl` | `agora` |
|
|
1468
|
+
|
|
1469
|
+
A filename that already identifies the project is used as-is, so an app that writes `agora_traces.jsonl` keeps that name. Only a generic filename falls through to the directory walk. Per-process shards and rotated segments reduce to the same project name, so they don't fill the picker with near-identical entries.
|
|
1470
|
+
|
|
1471
|
+
Pass `name` when the app knows its own identity and shouldn't depend on where its files sit:
|
|
1472
|
+
|
|
1473
|
+
```python
|
|
1474
|
+
launch_or_connect(source="data/traces/traces.jsonl", name="agora")
|
|
1475
|
+
```
|
|
1476
|
+
|
|
1477
|
+
Sources are deduplicated by resolved path, so calling `launch_or_connect()` on every run reuses the existing entry rather than adding `agora-2`, `agora-3`, … Two different paths that derive the same name still get a numeric suffix to stay distinguishable.
|
|
1478
|
+
|
|
1453
1479
|
### Frontend button
|
|
1454
1480
|
|
|
1455
1481
|
The button should be a `<button>` (not an `<a href>`), so you can disable it and show a loading state while the backend starts the viewer:
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
# tests/test_project_name.py
|
|
2
|
+
#
|
|
3
|
+
# Tests for configure(project=...), project cascading to traces, and the
|
|
4
|
+
# warning emitted when a root trace is written without a project name.
|
|
5
|
+
|
|
6
|
+
import warnings
|
|
7
|
+
|
|
8
|
+
import pytest
|
|
9
|
+
|
|
10
|
+
import traceact
|
|
11
|
+
from traceact import ActionTrace, configure, reset_config
|
|
12
|
+
from traceact.config import get_package_project
|
|
13
|
+
from traceact.trace import _create_trace
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
# ---------------------------------------------------------------------------
|
|
17
|
+
# configure(project=...) — package-level state
|
|
18
|
+
# ---------------------------------------------------------------------------
|
|
19
|
+
|
|
20
|
+
class TestConfigureProject:
|
|
21
|
+
def test_project_stored_in_package_state(self):
|
|
22
|
+
configure(project="agora")
|
|
23
|
+
assert get_package_project() == "agora"
|
|
24
|
+
|
|
25
|
+
def test_reset_clears_project(self):
|
|
26
|
+
configure(project="agora")
|
|
27
|
+
reset_config()
|
|
28
|
+
assert get_package_project() is None
|
|
29
|
+
|
|
30
|
+
def test_configure_without_project_does_not_clear_existing(self):
|
|
31
|
+
configure(project="agora")
|
|
32
|
+
configure() # no project arg
|
|
33
|
+
assert get_package_project() == "agora"
|
|
34
|
+
|
|
35
|
+
def test_configure_project_can_be_updated(self):
|
|
36
|
+
configure(project="alpha")
|
|
37
|
+
configure(project="beta")
|
|
38
|
+
assert get_package_project() == "beta"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
# ---------------------------------------------------------------------------
|
|
42
|
+
# Project cascading — package → trace
|
|
43
|
+
# ---------------------------------------------------------------------------
|
|
44
|
+
|
|
45
|
+
class TestProjectCascade:
|
|
46
|
+
def test_package_project_stamped_onto_trace(self, tmp_path):
|
|
47
|
+
configure(project="agora")
|
|
48
|
+
with ActionTrace.start(action="test.run") as trace:
|
|
49
|
+
pass
|
|
50
|
+
assert trace.project == "agora"
|
|
51
|
+
|
|
52
|
+
def test_per_trace_project_overrides_package(self, tmp_path):
|
|
53
|
+
configure(project="agora")
|
|
54
|
+
with ActionTrace.start(action="test.run", project="override") as trace:
|
|
55
|
+
pass
|
|
56
|
+
assert trace.project == "override"
|
|
57
|
+
|
|
58
|
+
def test_child_inherits_package_project(self):
|
|
59
|
+
configure(project="agora")
|
|
60
|
+
with ActionTrace.start(action="parent") as parent:
|
|
61
|
+
with ActionTrace.start(action="child") as child:
|
|
62
|
+
pass
|
|
63
|
+
assert child.project == "agora"
|
|
64
|
+
|
|
65
|
+
def test_child_inherits_parent_project_when_package_unset(self):
|
|
66
|
+
with ActionTrace.start(action="parent", project="manual") as parent:
|
|
67
|
+
with ActionTrace.start(action="child") as child:
|
|
68
|
+
pass
|
|
69
|
+
assert child.project == "manual"
|
|
70
|
+
|
|
71
|
+
def test_package_project_wins_over_parent_project(self):
|
|
72
|
+
# Package is the top of the cascade — it overrides even an explicit
|
|
73
|
+
# parent project so that configure(project=...) is a single source of
|
|
74
|
+
# truth for the whole app.
|
|
75
|
+
configure(project="package-level")
|
|
76
|
+
with ActionTrace.start(action="parent", project="parent-level") as parent:
|
|
77
|
+
with ActionTrace.start(action="child") as child:
|
|
78
|
+
pass
|
|
79
|
+
assert child.project == "package-level"
|
|
80
|
+
|
|
81
|
+
def test_no_project_anywhere_leaves_none(self):
|
|
82
|
+
with ActionTrace.start(action="test.run") as trace:
|
|
83
|
+
pass
|
|
84
|
+
assert trace.project is None
|
|
85
|
+
|
|
86
|
+
def test_project_appears_in_serialised_record(self, tmp_path):
|
|
87
|
+
import json
|
|
88
|
+
f = tmp_path / "traces.jsonl"
|
|
89
|
+
traceact.configure(
|
|
90
|
+
project="agora",
|
|
91
|
+
config=traceact.TraceConfig(sink_mode="blocking"),
|
|
92
|
+
sinks=[traceact.JsonlSink(str(f))],
|
|
93
|
+
)
|
|
94
|
+
with ActionTrace.start(action="test.run"):
|
|
95
|
+
pass
|
|
96
|
+
records = [json.loads(l) for l in f.read_text().splitlines() if l.strip()]
|
|
97
|
+
assert records[-1]["project"] == "agora"
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
# ---------------------------------------------------------------------------
|
|
101
|
+
# Warning on unnamed root traces
|
|
102
|
+
# ---------------------------------------------------------------------------
|
|
103
|
+
|
|
104
|
+
class TestUnnamedTraceWarning:
|
|
105
|
+
def test_warns_when_no_project_set(self, tmp_path):
|
|
106
|
+
import traceact
|
|
107
|
+
f = tmp_path / "traces.jsonl"
|
|
108
|
+
traceact.configure(sinks=[traceact.JsonlSink(str(f))])
|
|
109
|
+
with warnings.catch_warnings(record=True) as caught:
|
|
110
|
+
warnings.simplefilter("always")
|
|
111
|
+
with ActionTrace.start(action="test.run"):
|
|
112
|
+
pass
|
|
113
|
+
msgs = [str(w.message) for w in caught if issubclass(w.category, UserWarning)]
|
|
114
|
+
assert any("project" in m for m in msgs)
|
|
115
|
+
|
|
116
|
+
def test_no_warning_when_project_set_via_configure(self, tmp_path):
|
|
117
|
+
import traceact
|
|
118
|
+
f = tmp_path / "traces.jsonl"
|
|
119
|
+
traceact.configure(project="agora", sinks=[traceact.JsonlSink(str(f))])
|
|
120
|
+
with warnings.catch_warnings(record=True) as caught:
|
|
121
|
+
warnings.simplefilter("always")
|
|
122
|
+
with ActionTrace.start(action="test.run"):
|
|
123
|
+
pass
|
|
124
|
+
msgs = [str(w.message) for w in caught if issubclass(w.category, UserWarning)]
|
|
125
|
+
assert not any("project" in m for m in msgs)
|
|
126
|
+
|
|
127
|
+
def test_no_warning_when_project_set_per_trace(self, tmp_path):
|
|
128
|
+
import traceact
|
|
129
|
+
f = tmp_path / "traces.jsonl"
|
|
130
|
+
traceact.configure(sinks=[traceact.JsonlSink(str(f))])
|
|
131
|
+
with warnings.catch_warnings(record=True) as caught:
|
|
132
|
+
warnings.simplefilter("always")
|
|
133
|
+
with ActionTrace.start(action="test.run", project="agora"):
|
|
134
|
+
pass
|
|
135
|
+
msgs = [str(w.message) for w in caught if issubclass(w.category, UserWarning)]
|
|
136
|
+
assert not any("project" in m for m in msgs)
|
|
137
|
+
|
|
138
|
+
def test_child_trace_does_not_warn(self, tmp_path):
|
|
139
|
+
# Only root traces warn — spamming for every child would be noise.
|
|
140
|
+
import traceact
|
|
141
|
+
f = tmp_path / "traces.jsonl"
|
|
142
|
+
traceact.configure(sinks=[traceact.JsonlSink(str(f))])
|
|
143
|
+
with warnings.catch_warnings(record=True) as caught:
|
|
144
|
+
warnings.simplefilter("always")
|
|
145
|
+
with ActionTrace.start(action="parent"):
|
|
146
|
+
with ActionTrace.start(action="child"):
|
|
147
|
+
pass
|
|
148
|
+
project_warnings = [
|
|
149
|
+
w for w in caught
|
|
150
|
+
if issubclass(w.category, UserWarning) and "project" in str(w.message)
|
|
151
|
+
]
|
|
152
|
+
# At most one warning (the root), never two.
|
|
153
|
+
assert len(project_warnings) <= 1
|
|
@@ -12,7 +12,72 @@ import unittest.mock as mock
|
|
|
12
12
|
|
|
13
13
|
import pytest
|
|
14
14
|
|
|
15
|
-
from traceact.viewer.server import ViewerState
|
|
15
|
+
from traceact.viewer.server import ViewerState, _derive_name
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
# ---------------------------------------------------------------------------
|
|
19
|
+
# Source naming
|
|
20
|
+
# ---------------------------------------------------------------------------
|
|
21
|
+
#
|
|
22
|
+
# Nearly every app writes to some variant of <project>/data/traces/traces.jsonl,
|
|
23
|
+
# so a name taken from the filename alone reads "traces" for every project on
|
|
24
|
+
# the machine. These assert the name identifies the project instead.
|
|
25
|
+
|
|
26
|
+
class TestDeriveName:
|
|
27
|
+
def test_skips_generic_dirs_to_project_name(self):
|
|
28
|
+
assert _derive_name("/Users/mo/Dev/agora/data/traces/traces.jsonl") == "agora"
|
|
29
|
+
|
|
30
|
+
def test_skips_logs_dir(self):
|
|
31
|
+
assert _derive_name("/Users/mo/Dev/casewright/logs/traces.jsonl") == "casewright"
|
|
32
|
+
|
|
33
|
+
def test_two_projects_get_different_names(self):
|
|
34
|
+
# The whole point: same filename, same layout, different projects.
|
|
35
|
+
a = _derive_name("/Users/mo/Dev/agora/data/traces/traces.jsonl")
|
|
36
|
+
b = _derive_name("/Users/mo/Dev/casewright/data/traces/traces.jsonl")
|
|
37
|
+
assert a != b
|
|
38
|
+
|
|
39
|
+
def test_specific_filename_is_honoured(self):
|
|
40
|
+
# An app that already names its file per project keeps that name —
|
|
41
|
+
# the directory walk is only a fallback for generic filenames.
|
|
42
|
+
assert _derive_name("/Users/mo/Dev/agora/agora_traces.jsonl") == "agora_traces"
|
|
43
|
+
|
|
44
|
+
def test_non_generic_filename_wins_over_dir(self):
|
|
45
|
+
assert _derive_name("/Users/mo/Dev/agora/data/traces/worker.jsonl") == "worker"
|
|
46
|
+
|
|
47
|
+
def test_hidden_project_dir_loses_leading_dot(self):
|
|
48
|
+
assert _derive_name("/Users/mo/.tree/traces/traces.jsonl") == "tree"
|
|
49
|
+
|
|
50
|
+
def test_pid_shard_derives_project_not_shard(self):
|
|
51
|
+
# All shards of one project must derive the same name, or a sharded
|
|
52
|
+
# app fills the picker with near-identical entries.
|
|
53
|
+
a = _derive_name("/Users/mo/Dev/agora/data/traces/traces.1234.jsonl")
|
|
54
|
+
b = _derive_name("/Users/mo/Dev/agora/data/traces/traces.5678.jsonl")
|
|
55
|
+
assert a == b == "agora"
|
|
56
|
+
|
|
57
|
+
def test_rotated_segment_derives_project(self):
|
|
58
|
+
path = "/Users/mo/Dev/agora/data/traces/traces.20260726T120000000000Z.jsonl"
|
|
59
|
+
assert _derive_name(path) == "agora"
|
|
60
|
+
|
|
61
|
+
def test_folder_source_skips_generic_components(self):
|
|
62
|
+
assert _derive_name("/Users/mo/Dev/agora/data/traces") == "agora"
|
|
63
|
+
|
|
64
|
+
def test_all_generic_falls_back(self):
|
|
65
|
+
assert _derive_name("/data/traces/traces.jsonl") == "source"
|
|
66
|
+
|
|
67
|
+
def test_generic_matching_is_case_insensitive(self):
|
|
68
|
+
assert _derive_name("/Users/mo/Dev/agora/Data/Traces/Traces.jsonl") == "agora"
|
|
69
|
+
|
|
70
|
+
def test_traceact_state_dir_is_generic(self):
|
|
71
|
+
assert _derive_name("/Users/mo/.traceact/imports/dropped.jsonl") == "dropped"
|
|
72
|
+
|
|
73
|
+
def test_bare_filename_does_not_crash(self):
|
|
74
|
+
assert _derive_name("traces.jsonl") == "source"
|
|
75
|
+
|
|
76
|
+
def test_explicit_name_overrides_derivation(self, tmp_path):
|
|
77
|
+
f = tmp_path / "traces.jsonl"
|
|
78
|
+
f.write_text("")
|
|
79
|
+
state = ViewerState()
|
|
80
|
+
assert state.add_source(str(f), name="agora") == "agora"
|
|
16
81
|
|
|
17
82
|
|
|
18
83
|
# ---------------------------------------------------------------------------
|
|
@@ -32,12 +97,15 @@ class TestAddSourceDedupe:
|
|
|
32
97
|
def test_repeated_adds_do_not_accumulate(self, tmp_path):
|
|
33
98
|
# The reported symptom: an app calling launch_or_connect() on every
|
|
34
99
|
# run produced traces-2, traces-3, traces-4 ... for one unchanging file.
|
|
100
|
+
# Asserts the dedupe property, not the derived name — the name comes
|
|
101
|
+
# from the tmp directory here and is covered by TestDeriveName.
|
|
35
102
|
f = tmp_path / "traces.jsonl"
|
|
36
103
|
f.write_text("")
|
|
37
104
|
state = ViewerState()
|
|
38
105
|
for _ in range(5):
|
|
39
106
|
state.add_source(str(f))
|
|
40
|
-
assert
|
|
107
|
+
assert len(state.sources) == 1
|
|
108
|
+
assert not any(n.endswith("-2") for n in state.sources)
|
|
41
109
|
|
|
42
110
|
def test_relative_and_absolute_path_dedupe(self, tmp_path, monkeypatch):
|
|
43
111
|
f = tmp_path / "traces.jsonl"
|
|
@@ -129,6 +197,58 @@ class TestLaunchOrConnectSourcePin:
|
|
|
129
197
|
url = instance.launch_or_connect()
|
|
130
198
|
assert url == "http://127.0.0.1:8765/"
|
|
131
199
|
|
|
200
|
+
def test_explicit_name_forwarded_to_add_source(self):
|
|
201
|
+
from traceact.viewer import instance
|
|
202
|
+
|
|
203
|
+
with mock.patch.object(
|
|
204
|
+
instance, "find_running",
|
|
205
|
+
return_value={"host": "127.0.0.1", "port": 8765},
|
|
206
|
+
), mock.patch.object(
|
|
207
|
+
instance, "add_source_to",
|
|
208
|
+
return_value={"name": "agora", "path": "/x/traces.jsonl"},
|
|
209
|
+
) as add:
|
|
210
|
+
url = instance.launch_or_connect(source="/x/traces.jsonl", name="agora")
|
|
211
|
+
assert add.call_args.kwargs["name"] == "agora"
|
|
212
|
+
assert url == "http://127.0.0.1:8765/?source=agora"
|
|
213
|
+
|
|
214
|
+
def test_add_source_to_omits_name_when_not_given(self):
|
|
215
|
+
# The payload must not carry name=None; the server treats a missing
|
|
216
|
+
# name as "derive one" and an explicit null would be a different case.
|
|
217
|
+
from traceact.viewer import instance
|
|
218
|
+
|
|
219
|
+
captured = {}
|
|
220
|
+
|
|
221
|
+
class FakeResp:
|
|
222
|
+
def __enter__(self): return self
|
|
223
|
+
def __exit__(self, *a): return False
|
|
224
|
+
def read(self): return b'{"name":"x","path":"/x"}'
|
|
225
|
+
|
|
226
|
+
def fake_urlopen(req, timeout=None):
|
|
227
|
+
captured["body"] = json.loads(req.data.decode())
|
|
228
|
+
return FakeResp()
|
|
229
|
+
|
|
230
|
+
with mock.patch("urllib.request.urlopen", side_effect=fake_urlopen):
|
|
231
|
+
instance.add_source_to("127.0.0.1", 8765, "/x/traces.jsonl")
|
|
232
|
+
assert captured["body"] == {"path": "/x/traces.jsonl"}
|
|
233
|
+
|
|
234
|
+
def test_add_source_to_includes_name_when_given(self):
|
|
235
|
+
from traceact.viewer import instance
|
|
236
|
+
|
|
237
|
+
captured = {}
|
|
238
|
+
|
|
239
|
+
class FakeResp:
|
|
240
|
+
def __enter__(self): return self
|
|
241
|
+
def __exit__(self, *a): return False
|
|
242
|
+
def read(self): return b'{"name":"agora","path":"/x"}'
|
|
243
|
+
|
|
244
|
+
def fake_urlopen(req, timeout=None):
|
|
245
|
+
captured["body"] = json.loads(req.data.decode())
|
|
246
|
+
return FakeResp()
|
|
247
|
+
|
|
248
|
+
with mock.patch("urllib.request.urlopen", side_effect=fake_urlopen):
|
|
249
|
+
instance.add_source_to("127.0.0.1", 8765, "/x/traces.jsonl", name="agora")
|
|
250
|
+
assert captured["body"]["name"] == "agora"
|
|
251
|
+
|
|
132
252
|
def test_failed_add_falls_back_to_bare_url(self):
|
|
133
253
|
# add_source_to returns None when the running viewer can't be reached;
|
|
134
254
|
# a URL pinning a source that was never registered would be worse than
|
|
@@ -255,3 +375,87 @@ class TestPrivateInstanceStateGating:
|
|
|
255
375
|
rc, _, clear_state = self._run_view(["view", "--no-browser"], monkeypatch)
|
|
256
376
|
assert rc == 0
|
|
257
377
|
assert clear_state.called
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
# ---------------------------------------------------------------------------
|
|
381
|
+
# Project-field-based source naming
|
|
382
|
+
# ---------------------------------------------------------------------------
|
|
383
|
+
#
|
|
384
|
+
# When a source contains trace records with a non-null project field, the
|
|
385
|
+
# viewer uses that value as the source name rather than deriving it from the
|
|
386
|
+
# file path. This keeps the name stable even if the file is moved, and works
|
|
387
|
+
# identically for JSONL, SQLite, and future HTTP sources.
|
|
388
|
+
|
|
389
|
+
import json as _json # already imported above, but named alias avoids shadowing
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
class TestProjectFieldNaming:
|
|
393
|
+
def _write_traces(self, path, records):
|
|
394
|
+
with open(path, "w", encoding="utf-8") as fh:
|
|
395
|
+
for r in records:
|
|
396
|
+
fh.write(_json.dumps(r) + "\n")
|
|
397
|
+
|
|
398
|
+
def test_project_field_used_as_source_name(self, tmp_path):
|
|
399
|
+
f = tmp_path / "traces.jsonl"
|
|
400
|
+
self._write_traces(f, [{"project": "agora", "action": "test"}])
|
|
401
|
+
state = ViewerState()
|
|
402
|
+
name = state.add_source(str(f))
|
|
403
|
+
assert name == "agora"
|
|
404
|
+
|
|
405
|
+
def test_project_name_marked_as_named(self, tmp_path):
|
|
406
|
+
f = tmp_path / "traces.jsonl"
|
|
407
|
+
self._write_traces(f, [{"project": "agora", "action": "test"}])
|
|
408
|
+
state = ViewerState()
|
|
409
|
+
name = state.add_source(str(f))
|
|
410
|
+
assert state.source_named[name] is True
|
|
411
|
+
|
|
412
|
+
def test_path_derived_name_marked_as_unnamed(self, tmp_path):
|
|
413
|
+
f = tmp_path / "traces.jsonl"
|
|
414
|
+
f.write_text("")
|
|
415
|
+
state = ViewerState()
|
|
416
|
+
name = state.add_source(str(f))
|
|
417
|
+
assert state.source_named[name] is False
|
|
418
|
+
|
|
419
|
+
def test_empty_source_falls_back_to_path(self, tmp_path):
|
|
420
|
+
f = tmp_path / "traces.jsonl"
|
|
421
|
+
f.write_text("")
|
|
422
|
+
# Empty file has no project — must not crash and must produce a name.
|
|
423
|
+
state = ViewerState()
|
|
424
|
+
name = state.add_source(str(f))
|
|
425
|
+
assert name # non-empty
|
|
426
|
+
|
|
427
|
+
def test_null_project_falls_back_to_path(self, tmp_path):
|
|
428
|
+
f = tmp_path / "traces.jsonl"
|
|
429
|
+
self._write_traces(f, [{"project": None, "action": "test"}])
|
|
430
|
+
state = ViewerState()
|
|
431
|
+
name = state.add_source(str(f))
|
|
432
|
+
# null project is treated the same as absent — path derivation runs.
|
|
433
|
+
assert state.source_named[name] is False
|
|
434
|
+
|
|
435
|
+
def test_explicit_name_param_beats_project_field(self, tmp_path):
|
|
436
|
+
f = tmp_path / "traces.jsonl"
|
|
437
|
+
self._write_traces(f, [{"project": "agora", "action": "test"}])
|
|
438
|
+
state = ViewerState()
|
|
439
|
+
name = state.add_source(str(f), name="override")
|
|
440
|
+
assert name == "override"
|
|
441
|
+
assert state.source_named[name] is True
|
|
442
|
+
|
|
443
|
+
def test_directory_source_reads_project_from_first_file(self, tmp_path):
|
|
444
|
+
(tmp_path / "a.jsonl").write_text(
|
|
445
|
+
_json.dumps({"project": "myapp", "action": "x"}) + "\n"
|
|
446
|
+
)
|
|
447
|
+
state = ViewerState()
|
|
448
|
+
name = state.add_source(str(tmp_path))
|
|
449
|
+
assert name == "myapp"
|
|
450
|
+
assert state.source_named[name] is True
|
|
451
|
+
|
|
452
|
+
def test_collision_suffix_applied_to_project_name(self, tmp_path):
|
|
453
|
+
f1 = tmp_path / "a.jsonl"
|
|
454
|
+
f2 = tmp_path / "b.jsonl"
|
|
455
|
+
self._write_traces(f1, [{"project": "agora"}])
|
|
456
|
+
self._write_traces(f2, [{"project": "agora"}])
|
|
457
|
+
state = ViewerState()
|
|
458
|
+
n1 = state.add_source(str(f1))
|
|
459
|
+
n2 = state.add_source(str(f2))
|
|
460
|
+
assert n1 == "agora"
|
|
461
|
+
assert n2 == "agora-2"
|
|
@@ -70,7 +70,7 @@
|
|
|
70
70
|
# TraceActASGIMiddleware — ASGI middleware (FastAPI, Starlette). Same as
|
|
71
71
|
# above for async frameworks.
|
|
72
72
|
|
|
73
|
-
__version__ = "0.
|
|
73
|
+
__version__ = "0.8.1"
|
|
74
74
|
|
|
75
75
|
from traceact.config import configure, reset_config, TraceConfig
|
|
76
76
|
from traceact.budget import TraceBudget
|
|
@@ -149,12 +149,14 @@ class TraceConfig:
|
|
|
149
149
|
_package_config: Optional[TraceConfig] = None
|
|
150
150
|
_package_budget: Any = None # Optional[TraceBudget] — typed as Any to avoid circular import
|
|
151
151
|
_package_sinks: List[Any] = [] # List[Sink]
|
|
152
|
+
_package_project: Optional[str] = None # set via configure(project=...); stamped onto every trace
|
|
152
153
|
|
|
153
154
|
|
|
154
155
|
def configure(
|
|
155
156
|
config: Optional[TraceConfig] = None,
|
|
156
157
|
budget: Any = None,
|
|
157
158
|
sinks: Optional[List[Any]] = None,
|
|
159
|
+
project: Optional[str] = None,
|
|
158
160
|
) -> None:
|
|
159
161
|
"""
|
|
160
162
|
Set package-wide defaults for all future traces.
|
|
@@ -163,20 +165,25 @@ def configure(
|
|
|
163
165
|
baseline for every trace unless a specific trace or decorator overrides them.
|
|
164
166
|
|
|
165
167
|
Args:
|
|
166
|
-
config:
|
|
167
|
-
|
|
168
|
-
budget:
|
|
169
|
-
sinks:
|
|
170
|
-
|
|
168
|
+
config: A TraceConfig instance. Only non-None fields are applied; the
|
|
169
|
+
rest keep their current values (or package defaults).
|
|
170
|
+
budget: A TraceBudget instance. Only non-None fields are applied.
|
|
171
|
+
sinks: A list of sink objects (JsonlSink, ConsoleSink, etc.). Replaces
|
|
172
|
+
the current sink list entirely.
|
|
173
|
+
project: The application or project name. Stamped onto every trace so
|
|
174
|
+
the viewer can label sources by their project rather than by
|
|
175
|
+
the sink's file path. Required in practice — traces written
|
|
176
|
+
without a project name emit a warning.
|
|
171
177
|
|
|
172
178
|
Example:
|
|
173
179
|
configure(
|
|
180
|
+
project="my-app",
|
|
174
181
|
config=TraceConfig(enabled=True, sink_mode="buffered"),
|
|
175
182
|
budget=TraceBudget(max_events=200),
|
|
176
183
|
sinks=[JsonlSink("traces.jsonl")],
|
|
177
184
|
)
|
|
178
185
|
"""
|
|
179
|
-
global _package_config, _package_budget, _package_sinks
|
|
186
|
+
global _package_config, _package_budget, _package_sinks, _package_project
|
|
180
187
|
|
|
181
188
|
if config is not None:
|
|
182
189
|
_package_config = config
|
|
@@ -187,6 +194,9 @@ def configure(
|
|
|
187
194
|
if sinks is not None:
|
|
188
195
|
_package_sinks = list(sinks)
|
|
189
196
|
|
|
197
|
+
if project is not None:
|
|
198
|
+
_package_project = project
|
|
199
|
+
|
|
190
200
|
|
|
191
201
|
def reset_config() -> None:
|
|
192
202
|
"""
|
|
@@ -207,11 +217,18 @@ def reset_config() -> None:
|
|
|
207
217
|
- Traces that have already been written to a sink. Those are gone.
|
|
208
218
|
- Files on disk. JsonlSink output is not deleted.
|
|
209
219
|
"""
|
|
210
|
-
global _package_config, _package_budget, _package_sinks
|
|
220
|
+
global _package_config, _package_budget, _package_sinks, _package_project
|
|
211
221
|
|
|
212
222
|
_package_config = None
|
|
213
223
|
_package_budget = None
|
|
214
224
|
_package_sinks = []
|
|
225
|
+
_package_project = None
|
|
226
|
+
|
|
227
|
+
# Discard any buffered records so one test's buffered traces cannot leak
|
|
228
|
+
# into the next test's buffer state. Imported here to avoid a circular
|
|
229
|
+
# import at module load time (sinks.py does not import config.py).
|
|
230
|
+
from traceact.sinks import reset_buffer
|
|
231
|
+
reset_buffer()
|
|
215
232
|
|
|
216
233
|
# Clear the active trace context so tests start with a clean slate.
|
|
217
234
|
# The deferred import avoids a circular dependency at module load time
|
|
@@ -244,3 +261,8 @@ def get_package_budget() -> Any:
|
|
|
244
261
|
def get_package_sinks() -> List[Any]:
|
|
245
262
|
"""Return the current list of configured sinks."""
|
|
246
263
|
return _package_sinks
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def get_package_project() -> Optional[str]:
|
|
267
|
+
"""Return the package-level project name, or None if not set."""
|
|
268
|
+
return _package_project
|
|
@@ -76,6 +76,21 @@ def _ensure_flush_registered() -> None:
|
|
|
76
76
|
_flush_registered = True
|
|
77
77
|
|
|
78
78
|
|
|
79
|
+
def reset_buffer() -> None:
|
|
80
|
+
"""
|
|
81
|
+
Discard all buffered records and reset the atexit flush registration.
|
|
82
|
+
|
|
83
|
+
Call this in test teardown (via reset_config()) to prevent one test's
|
|
84
|
+
buffered traces from leaking into the next test's buffer state. Not
|
|
85
|
+
intended for production use — buffered records not yet flushed to a sink
|
|
86
|
+
are silently discarded.
|
|
87
|
+
"""
|
|
88
|
+
global _flush_registered
|
|
89
|
+
with _buffer_lock:
|
|
90
|
+
_buffer.clear()
|
|
91
|
+
_flush_registered = False
|
|
92
|
+
|
|
93
|
+
|
|
79
94
|
def buffer_record(record: Dict[str, Any]) -> None:
|
|
80
95
|
"""
|
|
81
96
|
Add a finished trace record to the in-memory buffer.
|
|
@@ -40,10 +40,13 @@ from datetime import datetime, timezone
|
|
|
40
40
|
from typing import Any, Dict, List, Optional, Union
|
|
41
41
|
|
|
42
42
|
from traceact.budget import BUDGET_DEFAULTS, TraceBudget
|
|
43
|
+
import warnings
|
|
44
|
+
|
|
43
45
|
from traceact.config import (
|
|
44
46
|
TraceConfig,
|
|
45
47
|
get_package_budget,
|
|
46
48
|
get_package_config,
|
|
49
|
+
get_package_project,
|
|
47
50
|
get_package_sinks,
|
|
48
51
|
)
|
|
49
52
|
from traceact.context import SKIP, get_active_trace, is_skip, pop_trace, push_trace
|
|
@@ -1047,6 +1050,17 @@ class ActionTrace(TraceHelpersMixin):
|
|
|
1047
1050
|
if mode == "disabled":
|
|
1048
1051
|
return
|
|
1049
1052
|
|
|
1053
|
+
# Warn once when a root trace (no parent) is about to be written with no
|
|
1054
|
+
# project set. Child traces are silent — the root already told them.
|
|
1055
|
+
if self.project is None and self.parent_trace_id is None:
|
|
1056
|
+
warnings.warn(
|
|
1057
|
+
"TraceAct: trace written without a project name. "
|
|
1058
|
+
"Add project='your-app' to your configure() call so the viewer "
|
|
1059
|
+
"can label this source correctly.",
|
|
1060
|
+
UserWarning,
|
|
1061
|
+
stacklevel=2,
|
|
1062
|
+
)
|
|
1063
|
+
|
|
1050
1064
|
sinks = get_package_sinks()
|
|
1051
1065
|
|
|
1052
1066
|
# If no sinks have been configured, fall back to ConsoleSink so that
|
|
@@ -1231,11 +1245,18 @@ def _create_trace(
|
|
|
1231
1245
|
return _NoOpTrace()
|
|
1232
1246
|
|
|
1233
1247
|
# --- Create the real trace ---
|
|
1248
|
+
# If the caller didn't specify a project, inherit from the package config,
|
|
1249
|
+
# then from the parent trace. Package wins over parent so that a single
|
|
1250
|
+
# configure(project=...) stamps every trace regardless of nesting depth.
|
|
1251
|
+
resolved_project = project or get_package_project() or (
|
|
1252
|
+
parent.project if parent is not None else None
|
|
1253
|
+
)
|
|
1254
|
+
|
|
1234
1255
|
trace = ActionTrace(
|
|
1235
1256
|
action=action,
|
|
1236
1257
|
kind=kind,
|
|
1237
1258
|
actor=actor,
|
|
1238
|
-
project=
|
|
1259
|
+
project=resolved_project,
|
|
1239
1260
|
parent=parent,
|
|
1240
1261
|
correlation_id=correlation_id,
|
|
1241
1262
|
upstream_trace_id=upstream_trace_id,
|