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.
- {traceact-0.6.2/traceact.egg-info → traceact-0.8.0}/PKG-INFO +2 -1
- {traceact-0.6.2 → traceact-0.8.0}/README.md +1 -0
- {traceact-0.6.2 → traceact-0.8.0}/USAGE.md +27 -1
- {traceact-0.6.2 → traceact-0.8.0}/pyproject.toml +1 -1
- traceact-0.8.0/tests/test_project_name.py +153 -0
- {traceact-0.6.2 → traceact-0.8.0}/tests/test_viewer_instance.py +206 -2
- {traceact-0.6.2 → traceact-0.8.0}/traceact/__init__.py +1 -1
- {traceact-0.6.2 → traceact-0.8.0}/traceact/config.py +29 -7
- {traceact-0.6.2 → traceact-0.8.0}/traceact/sinks.py +15 -0
- {traceact-0.6.2 → traceact-0.8.0}/traceact/trace.py +22 -1
- {traceact-0.6.2 → traceact-0.8.0}/traceact/viewer/instance.py +28 -5
- {traceact-0.6.2 → traceact-0.8.0}/traceact/viewer/server.py +119 -13
- {traceact-0.6.2 → traceact-0.8.0}/traceact/viewer/static/app.js +14 -2
- {traceact-0.6.2 → traceact-0.8.0}/traceact/viewer/static/index.html +1 -1
- {traceact-0.6.2 → traceact-0.8.0}/traceact/viewer/static/styles.css +41 -0
- {traceact-0.6.2 → traceact-0.8.0/traceact.egg-info}/PKG-INFO +2 -1
- {traceact-0.6.2 → traceact-0.8.0}/traceact.egg-info/SOURCES.txt +1 -0
- {traceact-0.6.2 → traceact-0.8.0}/LICENSE +0 -0
- {traceact-0.6.2 → traceact-0.8.0}/MANIFEST.in +0 -0
- {traceact-0.6.2 → traceact-0.8.0}/setup.cfg +0 -0
- {traceact-0.6.2 → traceact-0.8.0}/tests/test_async_sink.py +0 -0
- {traceact-0.6.2 → traceact-0.8.0}/tests/test_decorators.py +0 -0
- {traceact-0.6.2 → traceact-0.8.0}/tests/test_doctor.py +0 -0
- {traceact-0.6.2 → traceact-0.8.0}/tests/test_http_sink.py +0 -0
- {traceact-0.6.2 → traceact-0.8.0}/tests/test_otlp_sink.py +0 -0
- {traceact-0.6.2 → traceact-0.8.0}/tests/test_propagation.py +0 -0
- {traceact-0.6.2 → traceact-0.8.0}/tests/test_reader.py +0 -0
- {traceact-0.6.2 → traceact-0.8.0}/tests/test_redaction.py +0 -0
- {traceact-0.6.2 → traceact-0.8.0}/tests/test_sinks.py +0 -0
- {traceact-0.6.2 → traceact-0.8.0}/tests/test_sqlite_sink.py +0 -0
- {traceact-0.6.2 → traceact-0.8.0}/tests/test_trace_hardening.py +0 -0
- {traceact-0.6.2 → traceact-0.8.0}/tests/test_tracelog.py +0 -0
- {traceact-0.6.2 → traceact-0.8.0}/tests/test_viewer_query.py +0 -0
- {traceact-0.6.2 → traceact-0.8.0}/traceact/budget.py +0 -0
- {traceact-0.6.2 → traceact-0.8.0}/traceact/context.py +0 -0
- {traceact-0.6.2 → traceact-0.8.0}/traceact/decorators.py +0 -0
- {traceact-0.6.2 → traceact-0.8.0}/traceact/helpers.py +0 -0
- {traceact-0.6.2 → traceact-0.8.0}/traceact/ids.py +0 -0
- {traceact-0.6.2 → traceact-0.8.0}/traceact/log.py +0 -0
- {traceact-0.6.2 → traceact-0.8.0}/traceact/middleware.py +0 -0
- {traceact-0.6.2 → traceact-0.8.0}/traceact/propagation.py +0 -0
- {traceact-0.6.2 → traceact-0.8.0}/traceact/redaction.py +0 -0
- {traceact-0.6.2 → traceact-0.8.0}/traceact/viewer/__init__.py +0 -0
- {traceact-0.6.2 → traceact-0.8.0}/traceact/viewer/cli.py +0 -0
- {traceact-0.6.2 → traceact-0.8.0}/traceact/viewer/doctor.py +0 -0
- {traceact-0.6.2 → traceact-0.8.0}/traceact/viewer/reader.py +0 -0
- {traceact-0.6.2 → traceact-0.8.0}/traceact.egg-info/dependency_links.txt +0 -0
- {traceact-0.6.2 → traceact-0.8.0}/traceact.egg-info/entry_points.txt +0 -0
- {traceact-0.6.2 → traceact-0.8.0}/traceact.egg-info/requires.txt +0 -0
- {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.
|
|
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
|
)
|
|
@@ -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
|
-
|
|
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.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:
|
|
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,
|
|
@@ -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
|
|
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
|
-
|
|
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,
|
|
121
|
-
|
|
122
|
-
|
|
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
|
-
|
|
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
|
-
{
|
|
289
|
-
|
|
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.
|
|
302
|
-
|
|
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
|
-
"""
|
|
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
|
-
|
|
502
|
-
|
|
503
|
-
|
|
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"
|
|
1148
|
+
<div class="name">
|
|
1149
|
+
${esc(s.name)}${s.named ? "" : ' <span class="unnamed-badge" title="No project name set — add configure(project="your-app") 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.
|
|
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
|
)
|
|
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
|
|
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
|
|
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
|