traceact 0.6.2__tar.gz → 0.8.0__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.0}/PKG-INFO +2 -1
  2. {traceact-0.6.2 → traceact-0.8.0}/README.md +1 -0
  3. {traceact-0.6.2 → traceact-0.8.0}/USAGE.md +27 -1
  4. {traceact-0.6.2 → traceact-0.8.0}/pyproject.toml +1 -1
  5. traceact-0.8.0/tests/test_project_name.py +153 -0
  6. {traceact-0.6.2 → traceact-0.8.0}/tests/test_viewer_instance.py +206 -2
  7. {traceact-0.6.2 → traceact-0.8.0}/traceact/__init__.py +1 -1
  8. {traceact-0.6.2 → traceact-0.8.0}/traceact/config.py +29 -7
  9. {traceact-0.6.2 → traceact-0.8.0}/traceact/sinks.py +15 -0
  10. {traceact-0.6.2 → traceact-0.8.0}/traceact/trace.py +22 -1
  11. {traceact-0.6.2 → traceact-0.8.0}/traceact/viewer/instance.py +28 -5
  12. {traceact-0.6.2 → traceact-0.8.0}/traceact/viewer/server.py +119 -13
  13. {traceact-0.6.2 → traceact-0.8.0}/traceact/viewer/static/app.js +14 -2
  14. {traceact-0.6.2 → traceact-0.8.0}/traceact/viewer/static/index.html +1 -1
  15. {traceact-0.6.2 → traceact-0.8.0}/traceact/viewer/static/styles.css +41 -0
  16. {traceact-0.6.2 → traceact-0.8.0/traceact.egg-info}/PKG-INFO +2 -1
  17. {traceact-0.6.2 → traceact-0.8.0}/traceact.egg-info/SOURCES.txt +1 -0
  18. {traceact-0.6.2 → traceact-0.8.0}/LICENSE +0 -0
  19. {traceact-0.6.2 → traceact-0.8.0}/MANIFEST.in +0 -0
  20. {traceact-0.6.2 → traceact-0.8.0}/setup.cfg +0 -0
  21. {traceact-0.6.2 → traceact-0.8.0}/tests/test_async_sink.py +0 -0
  22. {traceact-0.6.2 → traceact-0.8.0}/tests/test_decorators.py +0 -0
  23. {traceact-0.6.2 → traceact-0.8.0}/tests/test_doctor.py +0 -0
  24. {traceact-0.6.2 → traceact-0.8.0}/tests/test_http_sink.py +0 -0
  25. {traceact-0.6.2 → traceact-0.8.0}/tests/test_otlp_sink.py +0 -0
  26. {traceact-0.6.2 → traceact-0.8.0}/tests/test_propagation.py +0 -0
  27. {traceact-0.6.2 → traceact-0.8.0}/tests/test_reader.py +0 -0
  28. {traceact-0.6.2 → traceact-0.8.0}/tests/test_redaction.py +0 -0
  29. {traceact-0.6.2 → traceact-0.8.0}/tests/test_sinks.py +0 -0
  30. {traceact-0.6.2 → traceact-0.8.0}/tests/test_sqlite_sink.py +0 -0
  31. {traceact-0.6.2 → traceact-0.8.0}/tests/test_trace_hardening.py +0 -0
  32. {traceact-0.6.2 → traceact-0.8.0}/tests/test_tracelog.py +0 -0
  33. {traceact-0.6.2 → traceact-0.8.0}/tests/test_viewer_query.py +0 -0
  34. {traceact-0.6.2 → traceact-0.8.0}/traceact/budget.py +0 -0
  35. {traceact-0.6.2 → traceact-0.8.0}/traceact/context.py +0 -0
  36. {traceact-0.6.2 → traceact-0.8.0}/traceact/decorators.py +0 -0
  37. {traceact-0.6.2 → traceact-0.8.0}/traceact/helpers.py +0 -0
  38. {traceact-0.6.2 → traceact-0.8.0}/traceact/ids.py +0 -0
  39. {traceact-0.6.2 → traceact-0.8.0}/traceact/log.py +0 -0
  40. {traceact-0.6.2 → traceact-0.8.0}/traceact/middleware.py +0 -0
  41. {traceact-0.6.2 → traceact-0.8.0}/traceact/propagation.py +0 -0
  42. {traceact-0.6.2 → traceact-0.8.0}/traceact/redaction.py +0 -0
  43. {traceact-0.6.2 → traceact-0.8.0}/traceact/viewer/__init__.py +0 -0
  44. {traceact-0.6.2 → traceact-0.8.0}/traceact/viewer/cli.py +0 -0
  45. {traceact-0.6.2 → traceact-0.8.0}/traceact/viewer/doctor.py +0 -0
  46. {traceact-0.6.2 → traceact-0.8.0}/traceact/viewer/reader.py +0 -0
  47. {traceact-0.6.2 → traceact-0.8.0}/traceact.egg-info/dependency_links.txt +0 -0
  48. {traceact-0.6.2 → traceact-0.8.0}/traceact.egg-info/entry_points.txt +0 -0
  49. {traceact-0.6.2 → traceact-0.8.0}/traceact.egg-info/requires.txt +0 -0
  50. {traceact-0.6.2 → traceact-0.8.0}/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.0
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.
@@ -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.0"
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.0"
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,
@@ -96,6 +96,7 @@ def launch_or_connect(
96
96
  port: int = 8765,
97
97
  open_browser: bool = False,
98
98
  timeout: float = 3.0,
99
+ name: Optional[str] = None,
99
100
  ) -> str:
100
101
  """
101
102
  Ensure a viewer is running and return its URL. Designed to be called from
@@ -126,6 +127,14 @@ def launch_or_connect(
126
127
  viewer opens on *that* source. Without it, the front-end selects whichever
127
128
  source is first in the list — so an app adding its source to a viewer
128
129
  another app had already started would open showing the other app's traces.
130
+
131
+ ``name`` labels this source in the viewer's picker. Leave it unset and the
132
+ viewer derives one from the path, skipping generic components so
133
+ ``~/Dev/agora/data/traces/traces.jsonl`` reads as "agora". Pass it when the
134
+ app knows its own name and shouldn't depend on where its files happen to
135
+ sit::
136
+
137
+ launch_or_connect(source="data/traces/traces.jsonl", name="agora")
129
138
  """
130
139
  import subprocess
131
140
  import sys
@@ -136,15 +145,17 @@ def launch_or_connect(
136
145
  if existing is not None:
137
146
  h, p = existing["host"], existing["port"]
138
147
  if source is not None:
139
- added = add_source_to(h, p, source)
148
+ added = add_source_to(h, p, source, name=name)
140
149
  if added and added.get("name"):
141
150
  return f"http://{h}:{p}/?source={quote(added['name'], safe='')}"
142
151
  return f"http://{h}:{p}/"
143
152
 
144
- # Not running — start one in the background.
153
+ # Not running — start one in the background. A source is seeded on the
154
+ # command line only when no explicit name was given: the CLI path derives
155
+ # its own name, so a named source is added over HTTP once the server is up.
145
156
  cmd = [sys.executable, "-m", "traceact.viewer.cli", "view", "--no-browser",
146
157
  "--host", host, "--port", str(port)]
147
- if source is not None:
158
+ if source is not None and name is None:
148
159
  cmd.append(source)
149
160
  subprocess.Popen(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
150
161
 
@@ -160,6 +171,10 @@ def launch_or_connect(
160
171
  # so the URL stays correct if another app adds a source before the tab
161
172
  # is opened.
162
173
  if source is not None:
174
+ if name is not None:
175
+ added = add_source_to(host, port, source, name=name)
176
+ if added and added.get("name"):
177
+ return f"http://{host}:{port}/?source={quote(added['name'], safe='')}"
163
178
  names = list_source_names(host, port)
164
179
  if names:
165
180
  return f"http://{host}:{port}/?source={quote(names[0], safe='')}"
@@ -167,13 +182,21 @@ def launch_or_connect(
167
182
 
168
183
 
169
184
  def add_source_to(host: str, port: int, path: str,
170
- timeout: float = 1.0) -> Optional[dict]:
185
+ timeout: float = 1.0,
186
+ name: Optional[str] = None) -> Optional[dict]:
171
187
  """
172
188
  Ask an already-running viewer to add a source. Returns the created source
173
189
  ({name, path}) or None on failure.
190
+
191
+ ``name`` labels the source explicitly; without it the viewer derives one
192
+ from the path. Note the viewer dedupes by path, so a path it already knows
193
+ keeps the name it was first registered under.
174
194
  """
175
195
  url = f"http://{host}:{port}/api/sources"
176
- data = json.dumps({"path": path}).encode("utf-8")
196
+ payload = {"path": path}
197
+ if name is not None:
198
+ payload["name"] = name
199
+ data = json.dumps(payload).encode("utf-8")
177
200
  req = urllib.request.Request(
178
201
  url, data=data,
179
202
  headers={"Content-Type": "application/json"}, method="POST",
@@ -112,14 +112,19 @@ class ViewerState:
112
112
 
113
113
  def __init__(self) -> None:
114
114
  self.sources: Dict[str, str] = {}
115
+ # True when the name came from the project field in the trace data,
116
+ # False when it was derived from the file path. Used by the viewer to
117
+ # flag unnamed sources so developers know to set configure(project=...).
118
+ self.source_named: Dict[str, bool] = {}
115
119
 
116
120
  def add_source(self, path: str, name: Optional[str] = None) -> str:
117
121
  """
118
122
  Register a source and return the name it was stored under.
119
123
 
120
- If no name is given, one is derived from the path: the folder name for a
121
- directory, or the file's stem for a file. Collisions get a numeric
122
- suffix so two files with the same name stay distinct.
124
+ If no name is given, the project field from the first trace record is
125
+ used. Falls back to _derive_name() (path-based) when no project is
126
+ set. Collisions still get a numeric suffix so two distinct paths stay
127
+ distinguishable.
123
128
 
124
129
  Adding a path that is already registered returns the existing name
125
130
  rather than registering it again — an app that calls
@@ -130,10 +135,20 @@ class ViewerState:
130
135
  for existing_name, existing_path in self.sources.items():
131
136
  if existing_path == path:
132
137
  return existing_name
138
+ named = False
133
139
  if name is None:
134
- name = _derive_name(path)
140
+ project = _read_project_from_source(path)
141
+ if project:
142
+ name = project
143
+ named = True
144
+ else:
145
+ name = _derive_name(path)
146
+ else:
147
+ # Caller supplied an explicit name (e.g. launch_or_connect(name=...)).
148
+ named = True
135
149
  name = self._unique_name(name)
136
150
  self.sources[name] = path
151
+ self.source_named[name] = named
137
152
  return name
138
153
 
139
154
  def _unique_name(self, name: str) -> str:
@@ -284,9 +299,14 @@ class _Handler(BaseHTTPRequestHandler):
284
299
  # -- sources API -------------------------------------------------------
285
300
 
286
301
  def _serve_sources(self) -> None:
302
+ state = self.server.state # type: ignore[attr-defined]
287
303
  payload = [
288
- {"name": name, "path": path}
289
- for name, path in self.server.state.sources.items() # type: ignore[attr-defined]
304
+ {
305
+ "name": name,
306
+ "path": path,
307
+ "named": state.source_named.get(name, False),
308
+ }
309
+ for name, path in state.sources.items()
290
310
  ]
291
311
  self._send_json(200, payload)
292
312
 
@@ -298,9 +318,12 @@ class _Handler(BaseHTTPRequestHandler):
298
318
  name = self.server.state.add_source( # type: ignore[attr-defined]
299
319
  body["path"], body.get("name")
300
320
  )
301
- self._send_json(
302
- 200, {"name": name, "path": self.server.state.sources[name]} # type: ignore[attr-defined]
303
- )
321
+ state = self.server.state # type: ignore[attr-defined]
322
+ self._send_json(200, {
323
+ "name": name,
324
+ "path": state.sources[name],
325
+ "named": state.source_named.get(name, False),
326
+ })
304
327
 
305
328
  # -- SSE stream --------------------------------------------------------
306
329
 
@@ -495,12 +518,95 @@ def _pick_via_tkinter(pick_type: str) -> Optional[str]:
495
518
  return None
496
519
 
497
520
 
521
+ # Path components that say where traces live, not which project they belong
522
+ # to. Almost every app writes to some variation of <project>/data/traces/
523
+ # traces.jsonl, so naming a source after any of these makes every project's
524
+ # source read the same — "traces", "traces-2", "traces-3" — which is the one
525
+ # thing the name has to distinguish.
526
+ _GENERIC_PATH_NAMES = frozenset({
527
+ "traces", "trace", "tracing", "traceact",
528
+ "logs", "log", "logging",
529
+ "data", "output", "outputs", "out", "results",
530
+ "var", "tmp", "temp", "cache",
531
+ "run", "runs", "instrumentation", "telemetry",
532
+ })
533
+
534
+
535
+ def _is_generic_component(component: str) -> bool:
536
+ """True if a path component describes storage rather than a project."""
537
+ return component.lower().lstrip(".") in _GENERIC_PATH_NAMES
538
+
539
+
540
+ def _read_project_from_source(path: str) -> Optional[str]:
541
+ """
542
+ Return the ``project`` field from the first trace record in a source, or
543
+ None if the source is empty, unreadable, or the field is absent/null.
544
+
545
+ For a directory source, the first lexicographically-sorted .jsonl file is
546
+ checked. This keeps the lookup O(1) in the common case — we only ever read
547
+ one line from one file.
548
+ """
549
+ try:
550
+ if os.path.isdir(path):
551
+ files = sorted(
552
+ f for f in os.listdir(path) if f.endswith(".jsonl")
553
+ )
554
+ if not files:
555
+ return None
556
+ target = os.path.join(path, files[0])
557
+ else:
558
+ target = path
559
+
560
+ with open(target, encoding="utf-8") as fh:
561
+ line = fh.readline()
562
+ if not line.strip():
563
+ return None
564
+ record = json.loads(line)
565
+ return record.get("project") or None
566
+ except Exception:
567
+ return None
568
+
569
+
498
570
  def _derive_name(path: str) -> str:
499
- """Human-friendly source name from a path: folder name, or file stem."""
571
+ """
572
+ Derive a source name that identifies the project, not the storage layout.
573
+
574
+ Two rules, in order:
575
+
576
+ 1. If the file's own name is specific (``agora_traces.jsonl``), use it.
577
+ An app that already names its trace files per project gets that name
578
+ honoured as-is.
579
+
580
+ 2. Otherwise walk up the directory chain and use the first component that
581
+ isn't generic, so ``~/Dev/agora/data/traces/traces.jsonl`` becomes
582
+ "agora" rather than "traces". Without this every project on a machine
583
+ lands on the same name and the source picker can't tell them apart.
584
+
585
+ A shard or rotation suffix (``traces.1234.jsonl``,
586
+ ``traces.20260726T120000000000Z.jsonl``) reduces to its first segment, so
587
+ every shard of one project derives the same project name.
588
+
589
+ Falls back to "source" when every component is generic. Callers that know
590
+ better can always pass an explicit name (see ViewerState.add_source and
591
+ launch_or_connect(name=...)).
592
+ """
593
+ path = os.path.normpath(path)
594
+ parts = [p for p in path.split(os.sep) if p]
595
+
500
596
  if os.path.isdir(path):
501
- return os.path.basename(os.path.normpath(path)) or "source"
502
- stem = os.path.splitext(os.path.basename(path))[0]
503
- return stem or "source"
597
+ candidates = parts
598
+ else:
599
+ stem = os.path.splitext(parts[-1])[0] if parts else ""
600
+ # "traces.1234" / "traces.<timestamp>" → "traces"
601
+ base = stem.split(".")[0]
602
+ if base and not _is_generic_component(base):
603
+ return base.lstrip(".")
604
+ candidates = parts[:-1]
605
+
606
+ for component in reversed(candidates):
607
+ if not _is_generic_component(component):
608
+ return component.lstrip(".") or "source"
609
+ return "source"
504
610
 
505
611
 
506
612
  def _parse_query_filters(query: Dict[str, list]) -> Dict[str, Any]:
@@ -17,7 +17,7 @@
17
17
  * never grows unbounded no matter how busy the source is. */
18
18
 
19
19
  const state = {
20
- sources: [], // [{name, path}]
20
+ sources: [], // [{name, path, named}] — named=false means project field was absent
21
21
  currentSource: null, // source name currently streamed
22
22
  traces: [], // newest-first, capped at settings.limit
23
23
  selected: null, // the trace object shown in the inspector
@@ -1129,13 +1129,25 @@ function closeModal() {
1129
1129
  function renderSourceList() {
1130
1130
  const list = document.getElementById("source-list");
1131
1131
  if (!list) return;
1132
+
1133
+ // The list scrolls once it outgrows its panel, so the count is the only
1134
+ // cue to how many sources are off-screen.
1135
+ const label = document.getElementById("source-list-label");
1136
+ if (label) {
1137
+ label.textContent = state.sources.length > 0
1138
+ ? `SOURCES (${state.sources.length})`
1139
+ : "SOURCES";
1140
+ }
1141
+
1132
1142
  if (state.sources.length === 0) {
1133
1143
  list.innerHTML = `<div class="muted" style="padding:6px 0">No sources yet.</div>`;
1134
1144
  return;
1135
1145
  }
1136
1146
  list.innerHTML = state.sources.map((s) => `
1137
1147
  <div class="source-option" data-name="${esc(s.name)}">
1138
- <div class="name">${esc(s.name)}</div>
1148
+ <div class="name">
1149
+ ${esc(s.name)}${s.named ? "" : ' <span class="unnamed-badge" title="No project name set — add configure(project=&quot;your-app&quot;) to your traceact setup">unnamed</span>'}
1150
+ </div>
1139
1151
  <span class="path-chip">
1140
1152
  <div class="path">${esc(shortenPath(s.path))}</div>
1141
1153
  <button class="path-copy-btn" data-full="${esc(s.path)}"
@@ -189,7 +189,7 @@
189
189
  <!-- Add-source modal -->
190
190
  <div class="modal-backdrop" id="source-modal" hidden>
191
191
  <div class="modal">
192
- <div class="modal-section-label">SOURCES</div>
192
+ <div class="modal-section-label" id="source-list-label">SOURCES</div>
193
193
  <div id="source-list"></div>
194
194
 
195
195
  <div class="modal-section-label">ADD SOURCE</div>
@@ -654,7 +654,35 @@ body {
654
654
  border: 1px solid var(--border-strong);
655
655
  border-radius: 10px;
656
656
  padding: 16px;
657
+ /* Cap the whole modal so it can't outgrow the viewport on a short window;
658
+ the source list below is the part that scrolls. */
659
+ max-height: 82vh;
660
+ overflow-y: auto;
661
+ }
662
+
663
+ /* The source list scrolls inside its own area rather than stretching the
664
+ modal: a dev machine can accumulate a source per project, and an unbounded
665
+ list would push the "add source" controls off-screen. ~5 rows stay visible;
666
+ the rest are one scroll away.
667
+
668
+ The inset panel treatment matters as much as the max-height — without a
669
+ visible boundary, a row clipped mid-text at the scroll edge reads as a
670
+ rendering fault rather than "there is more below". */
671
+ #source-list {
672
+ max-height: 244px;
673
+ overflow-y: auto;
674
+ border: 1px solid var(--border);
675
+ border-radius: var(--radius);
676
+ background: var(--bg-panel);
677
+ padding: 4px;
657
678
  }
679
+ #source-list::-webkit-scrollbar { width: 8px; }
680
+ #source-list::-webkit-scrollbar-track { background: transparent; }
681
+ #source-list::-webkit-scrollbar-thumb {
682
+ background: var(--border-strong);
683
+ border-radius: 4px;
684
+ }
685
+ #source-list::-webkit-scrollbar-thumb:hover { background: var(--text-faint); }
658
686
  .modal-section-label {
659
687
  color: var(--text-faint);
660
688
  font-size: 10.5px;
@@ -676,6 +704,19 @@ body {
676
704
  /* .path-chip defaults to inline-flex for the header's inline usage; here it
677
705
  needs to sit on its own line under .name, so it takes the block it replaced. */
678
706
  .source-option .path-chip { display: flex; align-items: center; }
707
+ .unnamed-badge {
708
+ display: inline-block;
709
+ font-size: 10px;
710
+ font-weight: 600;
711
+ letter-spacing: 0.04em;
712
+ padding: 1px 5px;
713
+ border-radius: 3px;
714
+ background: var(--bg-hover);
715
+ color: var(--text-faint);
716
+ border: 1px solid var(--border);
717
+ vertical-align: middle;
718
+ margin-left: 5px;
719
+ }
679
720
 
680
721
  /* Drop zone */
681
722
  .drop-zone {
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: traceact
3
- Version: 0.6.2
3
+ Version: 0.8.0
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
  )
@@ -8,6 +8,7 @@ tests/test_decorators.py
8
8
  tests/test_doctor.py
9
9
  tests/test_http_sink.py
10
10
  tests/test_otlp_sink.py
11
+ tests/test_project_name.py
11
12
  tests/test_propagation.py
12
13
  tests/test_reader.py
13
14
  tests/test_redaction.py
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes