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.
Files changed (50) hide show
  1. {traceact-0.6.2/traceact.egg-info → traceact-0.8.1}/PKG-INFO +2 -1
  2. {traceact-0.6.2 → traceact-0.8.1}/README.md +1 -0
  3. {traceact-0.6.2 → traceact-0.8.1}/USAGE.md +28 -2
  4. {traceact-0.6.2 → traceact-0.8.1}/pyproject.toml +1 -1
  5. traceact-0.8.1/tests/test_project_name.py +153 -0
  6. {traceact-0.6.2 → traceact-0.8.1}/tests/test_viewer_instance.py +206 -2
  7. {traceact-0.6.2 → traceact-0.8.1}/traceact/__init__.py +1 -1
  8. {traceact-0.6.2 → traceact-0.8.1}/traceact/config.py +29 -7
  9. {traceact-0.6.2 → traceact-0.8.1}/traceact/sinks.py +15 -0
  10. {traceact-0.6.2 → traceact-0.8.1}/traceact/trace.py +22 -1
  11. {traceact-0.6.2 → traceact-0.8.1}/traceact/viewer/instance.py +28 -5
  12. {traceact-0.6.2 → traceact-0.8.1}/traceact/viewer/server.py +122 -13
  13. {traceact-0.6.2 → traceact-0.8.1}/traceact/viewer/static/app.js +98 -3
  14. {traceact-0.6.2 → traceact-0.8.1}/traceact/viewer/static/index.html +6 -1
  15. {traceact-0.6.2 → traceact-0.8.1}/traceact/viewer/static/styles.css +69 -1
  16. {traceact-0.6.2 → traceact-0.8.1/traceact.egg-info}/PKG-INFO +2 -1
  17. {traceact-0.6.2 → traceact-0.8.1}/traceact.egg-info/SOURCES.txt +1 -0
  18. {traceact-0.6.2 → traceact-0.8.1}/LICENSE +0 -0
  19. {traceact-0.6.2 → traceact-0.8.1}/MANIFEST.in +0 -0
  20. {traceact-0.6.2 → traceact-0.8.1}/setup.cfg +0 -0
  21. {traceact-0.6.2 → traceact-0.8.1}/tests/test_async_sink.py +0 -0
  22. {traceact-0.6.2 → traceact-0.8.1}/tests/test_decorators.py +0 -0
  23. {traceact-0.6.2 → traceact-0.8.1}/tests/test_doctor.py +0 -0
  24. {traceact-0.6.2 → traceact-0.8.1}/tests/test_http_sink.py +0 -0
  25. {traceact-0.6.2 → traceact-0.8.1}/tests/test_otlp_sink.py +0 -0
  26. {traceact-0.6.2 → traceact-0.8.1}/tests/test_propagation.py +0 -0
  27. {traceact-0.6.2 → traceact-0.8.1}/tests/test_reader.py +0 -0
  28. {traceact-0.6.2 → traceact-0.8.1}/tests/test_redaction.py +0 -0
  29. {traceact-0.6.2 → traceact-0.8.1}/tests/test_sinks.py +0 -0
  30. {traceact-0.6.2 → traceact-0.8.1}/tests/test_sqlite_sink.py +0 -0
  31. {traceact-0.6.2 → traceact-0.8.1}/tests/test_trace_hardening.py +0 -0
  32. {traceact-0.6.2 → traceact-0.8.1}/tests/test_tracelog.py +0 -0
  33. {traceact-0.6.2 → traceact-0.8.1}/tests/test_viewer_query.py +0 -0
  34. {traceact-0.6.2 → traceact-0.8.1}/traceact/budget.py +0 -0
  35. {traceact-0.6.2 → traceact-0.8.1}/traceact/context.py +0 -0
  36. {traceact-0.6.2 → traceact-0.8.1}/traceact/decorators.py +0 -0
  37. {traceact-0.6.2 → traceact-0.8.1}/traceact/helpers.py +0 -0
  38. {traceact-0.6.2 → traceact-0.8.1}/traceact/ids.py +0 -0
  39. {traceact-0.6.2 → traceact-0.8.1}/traceact/log.py +0 -0
  40. {traceact-0.6.2 → traceact-0.8.1}/traceact/middleware.py +0 -0
  41. {traceact-0.6.2 → traceact-0.8.1}/traceact/propagation.py +0 -0
  42. {traceact-0.6.2 → traceact-0.8.1}/traceact/redaction.py +0 -0
  43. {traceact-0.6.2 → traceact-0.8.1}/traceact/viewer/__init__.py +0 -0
  44. {traceact-0.6.2 → traceact-0.8.1}/traceact/viewer/cli.py +0 -0
  45. {traceact-0.6.2 → traceact-0.8.1}/traceact/viewer/doctor.py +0 -0
  46. {traceact-0.6.2 → traceact-0.8.1}/traceact/viewer/reader.py +0 -0
  47. {traceact-0.6.2 → traceact-0.8.1}/traceact.egg-info/dependency_links.txt +0 -0
  48. {traceact-0.6.2 → traceact-0.8.1}/traceact.egg-info/entry_points.txt +0 -0
  49. {traceact-0.6.2 → traceact-0.8.1}/traceact.egg-info/requires.txt +0 -0
  50. {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.6.2
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
  )
@@ -20,6 +20,7 @@ pip install traceact
20
20
  from traceact import traced_action, configure, TraceConfig, JsonlSink
21
21
 
22
22
  configure(
23
+ project="my-app",
23
24
  config=TraceConfig(sink_mode="blocking"),
24
25
  sinks=[JsonlSink("data/traces.jsonl")],
25
26
  )
@@ -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
- ) -> str # returns the viewer URL, e.g. "http://127.0.0.1:8765/"
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:
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "traceact"
7
- version = "0.6.2"
7
+ version = "0.8.1"
8
8
  description = "X-ray vision for your code. Lightweight action-level tracing for Python."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
@@ -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 list(state.sources) == ["traces"]
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.6.2"
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: A TraceConfig instance. Only non-None fields are applied; the
167
- rest keep their current values (or package defaults).
168
- budget: A TraceBudget instance. Only non-None fields are applied.
169
- sinks: A list of sink objects (JsonlSink, ConsoleSink, etc.). Replaces
170
- the current sink list entirely.
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=project,
1259
+ project=resolved_project,
1239
1260
  parent=parent,
1240
1261
  correlation_id=correlation_id,
1241
1262
  upstream_trace_id=upstream_trace_id,