traceact 0.6.1__tar.gz → 0.6.2__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.1/traceact.egg-info → traceact-0.6.2}/PKG-INFO +1 -1
- {traceact-0.6.1 → traceact-0.6.2}/pyproject.toml +1 -1
- traceact-0.6.2/tests/test_viewer_instance.py +257 -0
- {traceact-0.6.1 → traceact-0.6.2}/traceact/__init__.py +1 -1
- {traceact-0.6.1 → traceact-0.6.2}/traceact/log.py +11 -2
- {traceact-0.6.1 → traceact-0.6.2}/traceact/viewer/cli.py +13 -2
- {traceact-0.6.1 → traceact-0.6.2}/traceact/viewer/instance.py +32 -1
- {traceact-0.6.1 → traceact-0.6.2}/traceact/viewer/server.py +8 -0
- {traceact-0.6.1 → traceact-0.6.2}/traceact/viewer/static/app.js +37 -16
- {traceact-0.6.1 → traceact-0.6.2}/traceact/viewer/static/index.html +5 -5
- {traceact-0.6.1 → traceact-0.6.2}/traceact/viewer/static/styles.css +18 -2
- {traceact-0.6.1 → traceact-0.6.2/traceact.egg-info}/PKG-INFO +1 -1
- {traceact-0.6.1 → traceact-0.6.2}/traceact.egg-info/SOURCES.txt +1 -0
- {traceact-0.6.1 → traceact-0.6.2}/LICENSE +0 -0
- {traceact-0.6.1 → traceact-0.6.2}/MANIFEST.in +0 -0
- {traceact-0.6.1 → traceact-0.6.2}/README.md +0 -0
- {traceact-0.6.1 → traceact-0.6.2}/USAGE.md +0 -0
- {traceact-0.6.1 → traceact-0.6.2}/setup.cfg +0 -0
- {traceact-0.6.1 → traceact-0.6.2}/tests/test_async_sink.py +0 -0
- {traceact-0.6.1 → traceact-0.6.2}/tests/test_decorators.py +0 -0
- {traceact-0.6.1 → traceact-0.6.2}/tests/test_doctor.py +0 -0
- {traceact-0.6.1 → traceact-0.6.2}/tests/test_http_sink.py +0 -0
- {traceact-0.6.1 → traceact-0.6.2}/tests/test_otlp_sink.py +0 -0
- {traceact-0.6.1 → traceact-0.6.2}/tests/test_propagation.py +0 -0
- {traceact-0.6.1 → traceact-0.6.2}/tests/test_reader.py +0 -0
- {traceact-0.6.1 → traceact-0.6.2}/tests/test_redaction.py +0 -0
- {traceact-0.6.1 → traceact-0.6.2}/tests/test_sinks.py +0 -0
- {traceact-0.6.1 → traceact-0.6.2}/tests/test_sqlite_sink.py +0 -0
- {traceact-0.6.1 → traceact-0.6.2}/tests/test_trace_hardening.py +0 -0
- {traceact-0.6.1 → traceact-0.6.2}/tests/test_tracelog.py +0 -0
- {traceact-0.6.1 → traceact-0.6.2}/tests/test_viewer_query.py +0 -0
- {traceact-0.6.1 → traceact-0.6.2}/traceact/budget.py +0 -0
- {traceact-0.6.1 → traceact-0.6.2}/traceact/config.py +0 -0
- {traceact-0.6.1 → traceact-0.6.2}/traceact/context.py +0 -0
- {traceact-0.6.1 → traceact-0.6.2}/traceact/decorators.py +0 -0
- {traceact-0.6.1 → traceact-0.6.2}/traceact/helpers.py +0 -0
- {traceact-0.6.1 → traceact-0.6.2}/traceact/ids.py +0 -0
- {traceact-0.6.1 → traceact-0.6.2}/traceact/middleware.py +0 -0
- {traceact-0.6.1 → traceact-0.6.2}/traceact/propagation.py +0 -0
- {traceact-0.6.1 → traceact-0.6.2}/traceact/redaction.py +0 -0
- {traceact-0.6.1 → traceact-0.6.2}/traceact/sinks.py +0 -0
- {traceact-0.6.1 → traceact-0.6.2}/traceact/trace.py +0 -0
- {traceact-0.6.1 → traceact-0.6.2}/traceact/viewer/__init__.py +0 -0
- {traceact-0.6.1 → traceact-0.6.2}/traceact/viewer/doctor.py +0 -0
- {traceact-0.6.1 → traceact-0.6.2}/traceact/viewer/reader.py +0 -0
- {traceact-0.6.1 → traceact-0.6.2}/traceact.egg-info/dependency_links.txt +0 -0
- {traceact-0.6.1 → traceact-0.6.2}/traceact.egg-info/entry_points.txt +0 -0
- {traceact-0.6.1 → traceact-0.6.2}/traceact.egg-info/requires.txt +0 -0
- {traceact-0.6.1 → traceact-0.6.2}/traceact.egg-info/top_level.txt +0 -0
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
# tests/test_viewer_instance.py
|
|
2
|
+
#
|
|
3
|
+
# Tests for viewer instance coordination: source deduplication, the
|
|
4
|
+
# ?source=NAME pin that makes an app open on its own source, and the
|
|
5
|
+
# state-file gating that keeps a deliberately-separate viewer private.
|
|
6
|
+
#
|
|
7
|
+
# These three behaviours are interlocking — the failure they prevent is one
|
|
8
|
+
# app's viewer showing another app's traces — so they are tested together.
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import unittest.mock as mock
|
|
12
|
+
|
|
13
|
+
import pytest
|
|
14
|
+
|
|
15
|
+
from traceact.viewer.server import ViewerState
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
# ---------------------------------------------------------------------------
|
|
19
|
+
# add_source deduplication
|
|
20
|
+
# ---------------------------------------------------------------------------
|
|
21
|
+
|
|
22
|
+
class TestAddSourceDedupe:
|
|
23
|
+
def test_same_path_twice_returns_same_name(self, tmp_path):
|
|
24
|
+
f = tmp_path / "traces.jsonl"
|
|
25
|
+
f.write_text("")
|
|
26
|
+
state = ViewerState()
|
|
27
|
+
first = state.add_source(str(f))
|
|
28
|
+
second = state.add_source(str(f))
|
|
29
|
+
assert first == second
|
|
30
|
+
assert len(state.sources) == 1
|
|
31
|
+
|
|
32
|
+
def test_repeated_adds_do_not_accumulate(self, tmp_path):
|
|
33
|
+
# The reported symptom: an app calling launch_or_connect() on every
|
|
34
|
+
# run produced traces-2, traces-3, traces-4 ... for one unchanging file.
|
|
35
|
+
f = tmp_path / "traces.jsonl"
|
|
36
|
+
f.write_text("")
|
|
37
|
+
state = ViewerState()
|
|
38
|
+
for _ in range(5):
|
|
39
|
+
state.add_source(str(f))
|
|
40
|
+
assert list(state.sources) == ["traces"]
|
|
41
|
+
|
|
42
|
+
def test_relative_and_absolute_path_dedupe(self, tmp_path, monkeypatch):
|
|
43
|
+
f = tmp_path / "traces.jsonl"
|
|
44
|
+
f.write_text("")
|
|
45
|
+
monkeypatch.chdir(tmp_path)
|
|
46
|
+
state = ViewerState()
|
|
47
|
+
a = state.add_source("traces.jsonl")
|
|
48
|
+
b = state.add_source(str(f))
|
|
49
|
+
assert a == b
|
|
50
|
+
assert len(state.sources) == 1
|
|
51
|
+
|
|
52
|
+
def test_path_with_dotdot_dedupes(self, tmp_path):
|
|
53
|
+
sub = tmp_path / "sub"
|
|
54
|
+
sub.mkdir()
|
|
55
|
+
f = sub / "traces.jsonl"
|
|
56
|
+
f.write_text("")
|
|
57
|
+
state = ViewerState()
|
|
58
|
+
a = state.add_source(str(f))
|
|
59
|
+
b = state.add_source(str(sub / ".." / "sub" / "traces.jsonl"))
|
|
60
|
+
assert a == b
|
|
61
|
+
assert len(state.sources) == 1
|
|
62
|
+
|
|
63
|
+
def test_distinct_paths_still_get_distinct_names(self, tmp_path):
|
|
64
|
+
# Dedupe must not collapse genuinely different files that happen to
|
|
65
|
+
# share a basename.
|
|
66
|
+
a_dir = tmp_path / "a"
|
|
67
|
+
b_dir = tmp_path / "b"
|
|
68
|
+
a_dir.mkdir()
|
|
69
|
+
b_dir.mkdir()
|
|
70
|
+
(a_dir / "traces.jsonl").write_text("")
|
|
71
|
+
(b_dir / "traces.jsonl").write_text("")
|
|
72
|
+
state = ViewerState()
|
|
73
|
+
n1 = state.add_source(str(a_dir / "traces.jsonl"))
|
|
74
|
+
n2 = state.add_source(str(b_dir / "traces.jsonl"))
|
|
75
|
+
assert n1 != n2
|
|
76
|
+
assert len(state.sources) == 2
|
|
77
|
+
|
|
78
|
+
def test_explicit_name_still_dedupes_by_path(self, tmp_path):
|
|
79
|
+
# The path is the identity; a different label for the same file must
|
|
80
|
+
# not create a second entry pointing at the same data.
|
|
81
|
+
f = tmp_path / "traces.jsonl"
|
|
82
|
+
f.write_text("")
|
|
83
|
+
state = ViewerState()
|
|
84
|
+
first = state.add_source(str(f), name="alpha")
|
|
85
|
+
second = state.add_source(str(f), name="beta")
|
|
86
|
+
assert first == second == "alpha"
|
|
87
|
+
assert len(state.sources) == 1
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
# ---------------------------------------------------------------------------
|
|
91
|
+
# launch_or_connect returns a ?source= pin
|
|
92
|
+
# ---------------------------------------------------------------------------
|
|
93
|
+
|
|
94
|
+
class TestLaunchOrConnectSourcePin:
|
|
95
|
+
def test_existing_viewer_url_pins_added_source(self):
|
|
96
|
+
from traceact.viewer import instance
|
|
97
|
+
|
|
98
|
+
with mock.patch.object(
|
|
99
|
+
instance, "find_running",
|
|
100
|
+
return_value={"host": "127.0.0.1", "port": 8765},
|
|
101
|
+
), mock.patch.object(
|
|
102
|
+
instance, "add_source_to",
|
|
103
|
+
return_value={"name": "mylog", "path": "/x/mylog.jsonl"},
|
|
104
|
+
):
|
|
105
|
+
url = instance.launch_or_connect(source="/x/mylog.jsonl")
|
|
106
|
+
assert url == "http://127.0.0.1:8765/?source=mylog"
|
|
107
|
+
|
|
108
|
+
def test_name_is_url_quoted(self):
|
|
109
|
+
from traceact.viewer import instance
|
|
110
|
+
|
|
111
|
+
with mock.patch.object(
|
|
112
|
+
instance, "find_running",
|
|
113
|
+
return_value={"host": "127.0.0.1", "port": 8765},
|
|
114
|
+
), mock.patch.object(
|
|
115
|
+
instance, "add_source_to",
|
|
116
|
+
return_value={"name": "my log/1", "path": "/x"},
|
|
117
|
+
):
|
|
118
|
+
url = instance.launch_or_connect(source="/x")
|
|
119
|
+
assert " " not in url
|
|
120
|
+
assert "my%20log%2F1" in url
|
|
121
|
+
|
|
122
|
+
def test_no_source_returns_bare_url(self):
|
|
123
|
+
from traceact.viewer import instance
|
|
124
|
+
|
|
125
|
+
with mock.patch.object(
|
|
126
|
+
instance, "find_running",
|
|
127
|
+
return_value={"host": "127.0.0.1", "port": 8765},
|
|
128
|
+
):
|
|
129
|
+
url = instance.launch_or_connect()
|
|
130
|
+
assert url == "http://127.0.0.1:8765/"
|
|
131
|
+
|
|
132
|
+
def test_failed_add_falls_back_to_bare_url(self):
|
|
133
|
+
# add_source_to returns None when the running viewer can't be reached;
|
|
134
|
+
# a URL pinning a source that was never registered would be worse than
|
|
135
|
+
# no pin at all.
|
|
136
|
+
from traceact.viewer import instance
|
|
137
|
+
|
|
138
|
+
with mock.patch.object(
|
|
139
|
+
instance, "find_running",
|
|
140
|
+
return_value={"host": "127.0.0.1", "port": 8765},
|
|
141
|
+
), mock.patch.object(instance, "add_source_to", return_value=None):
|
|
142
|
+
url = instance.launch_or_connect(source="/x/mylog.jsonl")
|
|
143
|
+
assert url == "http://127.0.0.1:8765/"
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
# ---------------------------------------------------------------------------
|
|
147
|
+
# TraceLog.view() preserves the ?source= pin
|
|
148
|
+
# ---------------------------------------------------------------------------
|
|
149
|
+
|
|
150
|
+
class TestViewPreservesSourcePin:
|
|
151
|
+
def test_pin_survives_with_no_filters(self, tmp_path):
|
|
152
|
+
from traceact import TraceLog
|
|
153
|
+
|
|
154
|
+
f = tmp_path / "traces.jsonl"
|
|
155
|
+
f.write_text("")
|
|
156
|
+
with mock.patch(
|
|
157
|
+
"traceact.viewer.instance.launch_or_connect",
|
|
158
|
+
return_value="http://127.0.0.1:8765/?source=mylog",
|
|
159
|
+
), mock.patch("webbrowser.open"):
|
|
160
|
+
url = TraceLog(str(f)).view(open_browser=False)
|
|
161
|
+
assert "source=mylog" in url
|
|
162
|
+
|
|
163
|
+
def test_pin_and_prefilters_both_present(self, tmp_path):
|
|
164
|
+
from traceact import TraceLog
|
|
165
|
+
|
|
166
|
+
f = tmp_path / "traces.jsonl"
|
|
167
|
+
f.write_text("")
|
|
168
|
+
with mock.patch(
|
|
169
|
+
"traceact.viewer.instance.launch_or_connect",
|
|
170
|
+
return_value="http://127.0.0.1:8765/?source=mylog",
|
|
171
|
+
), mock.patch("webbrowser.open"):
|
|
172
|
+
url = TraceLog(str(f)).filter(status="failed").view(open_browser=False)
|
|
173
|
+
assert "source=mylog" in url
|
|
174
|
+
assert "pf_status=failed" in url
|
|
175
|
+
|
|
176
|
+
def test_bare_base_url_still_works(self, tmp_path):
|
|
177
|
+
# Backward compatibility: a launch_or_connect that returns no query
|
|
178
|
+
# (no source given) must still produce a valid pre-filter URL.
|
|
179
|
+
from traceact import TraceLog
|
|
180
|
+
|
|
181
|
+
f = tmp_path / "traces.jsonl"
|
|
182
|
+
f.write_text("")
|
|
183
|
+
with mock.patch(
|
|
184
|
+
"traceact.viewer.instance.launch_or_connect",
|
|
185
|
+
return_value="http://127.0.0.1:8765/",
|
|
186
|
+
), mock.patch("webbrowser.open"):
|
|
187
|
+
url = TraceLog(str(f)).filter(status="failed").view(open_browser=False)
|
|
188
|
+
assert url.startswith("http://127.0.0.1:8765/?")
|
|
189
|
+
assert "pf_status=failed" in url
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
# ---------------------------------------------------------------------------
|
|
193
|
+
# State-file gating: a private instance must not claim the shared slot
|
|
194
|
+
# ---------------------------------------------------------------------------
|
|
195
|
+
|
|
196
|
+
class TestPrivateInstanceStateGating:
|
|
197
|
+
"""
|
|
198
|
+
`traceact view --port N` / `--new` starts a deliberately separate viewer.
|
|
199
|
+
Writing the shared state file from one would point the next
|
|
200
|
+
launch_or_connect() caller — a different app entirely — at this instance.
|
|
201
|
+
"""
|
|
202
|
+
|
|
203
|
+
def _run_view(self, argv, monkeypatch):
|
|
204
|
+
"""Run cli._run_view with the server loop stubbed out; return mocks."""
|
|
205
|
+
from traceact.viewer import cli
|
|
206
|
+
|
|
207
|
+
write_state = mock.MagicMock()
|
|
208
|
+
clear_state = mock.MagicMock()
|
|
209
|
+
fake_server = mock.MagicMock()
|
|
210
|
+
# serve_forever returns immediately so _run_view falls through to the
|
|
211
|
+
# finally block without blocking the test.
|
|
212
|
+
fake_server.serve_forever.side_effect = lambda: None
|
|
213
|
+
|
|
214
|
+
monkeypatch.setattr(cli._instance, "write_state", write_state)
|
|
215
|
+
monkeypatch.setattr(cli._instance, "clear_state", clear_state)
|
|
216
|
+
monkeypatch.setattr(cli, "_start_server", lambda h, p, s: (fake_server, p))
|
|
217
|
+
monkeypatch.setattr(
|
|
218
|
+
cli._instance, "find_running", lambda: None
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
parser = cli._build_parser()
|
|
222
|
+
args = parser.parse_args(argv)
|
|
223
|
+
rc = cli._run_view(args)
|
|
224
|
+
return rc, write_state, clear_state
|
|
225
|
+
|
|
226
|
+
def test_default_port_writes_state(self, monkeypatch):
|
|
227
|
+
rc, write_state, _ = self._run_view(["view", "--no-browser"], monkeypatch)
|
|
228
|
+
assert rc == 0
|
|
229
|
+
assert write_state.called
|
|
230
|
+
|
|
231
|
+
def test_explicit_port_does_not_write_state(self, monkeypatch):
|
|
232
|
+
rc, write_state, _ = self._run_view(
|
|
233
|
+
["view", "--no-browser", "--port", "8951"], monkeypatch
|
|
234
|
+
)
|
|
235
|
+
assert rc == 0
|
|
236
|
+
assert not write_state.called
|
|
237
|
+
|
|
238
|
+
def test_new_flag_does_not_write_state(self, monkeypatch):
|
|
239
|
+
rc, write_state, _ = self._run_view(
|
|
240
|
+
["view", "--no-browser", "--new"], monkeypatch
|
|
241
|
+
)
|
|
242
|
+
assert rc == 0
|
|
243
|
+
assert not write_state.called
|
|
244
|
+
|
|
245
|
+
def test_private_instance_does_not_clear_shared_state_on_exit(self, monkeypatch):
|
|
246
|
+
# A private instance clearing the state file on shutdown would evict a
|
|
247
|
+
# still-running shared viewer's entry.
|
|
248
|
+
rc, _, clear_state = self._run_view(
|
|
249
|
+
["view", "--no-browser", "--port", "8951"], monkeypatch
|
|
250
|
+
)
|
|
251
|
+
assert rc == 0
|
|
252
|
+
assert not clear_state.called
|
|
253
|
+
|
|
254
|
+
def test_shared_instance_clears_state_on_exit(self, monkeypatch):
|
|
255
|
+
rc, _, clear_state = self._run_view(["view", "--no-browser"], monkeypatch)
|
|
256
|
+
assert rc == 0
|
|
257
|
+
assert clear_state.called
|
|
@@ -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.
|
|
73
|
+
__version__ = "0.6.2"
|
|
74
74
|
|
|
75
75
|
from traceact.config import configure, reset_config, TraceConfig
|
|
76
76
|
from traceact.budget import TraceBudget
|
|
@@ -292,8 +292,17 @@ class TraceLog:
|
|
|
292
292
|
key = f"pf_{field}" if op == "eq" else f"pf_{field}__{op}"
|
|
293
293
|
params.append((key, str(value)))
|
|
294
294
|
|
|
295
|
-
|
|
296
|
-
|
|
295
|
+
# launch_or_connect returns a URL that may already carry ?source=NAME
|
|
296
|
+
# pinning the viewer to this log's source. Merge the pre-filters onto
|
|
297
|
+
# it rather than rebuilding from the origin, which would drop that pin
|
|
298
|
+
# and let the viewer open on some other app's source.
|
|
299
|
+
from urllib.parse import urlsplit, urlunsplit, parse_qsl
|
|
300
|
+
parts = urlsplit(base_url)
|
|
301
|
+
merged = parse_qsl(parts.query) + params
|
|
302
|
+
path = parts.path if parts.path else "/"
|
|
303
|
+
url = urlunsplit(
|
|
304
|
+
(parts.scheme, parts.netloc, path, urlencode(merged), "")
|
|
305
|
+
)
|
|
297
306
|
|
|
298
307
|
if open_browser:
|
|
299
308
|
import threading
|
|
@@ -167,7 +167,14 @@ def _run_view(args: argparse.Namespace) -> int:
|
|
|
167
167
|
print(f"TraceAct viewer running at {url}")
|
|
168
168
|
print("Press Ctrl+C to stop.")
|
|
169
169
|
|
|
170
|
-
|
|
170
|
+
# Only a default-port instance advertises itself as the shared viewer.
|
|
171
|
+
# An instance the user deliberately separated — via --new or an explicit
|
|
172
|
+
# --port — stays private: claiming the state file would point the next
|
|
173
|
+
# launch_or_connect() caller (another app entirely) at this instance, so
|
|
174
|
+
# that app's traces would be POSTed here and it would open a viewer
|
|
175
|
+
# showing this one's source instead of its own.
|
|
176
|
+
if not args.new and not user_chose_port:
|
|
177
|
+
_instance.write_state(args.host, port)
|
|
171
178
|
|
|
172
179
|
if not args.no_browser:
|
|
173
180
|
threading.Timer(0.4, lambda: webbrowser.open(url)).start()
|
|
@@ -179,7 +186,11 @@ def _run_view(args: argparse.Namespace) -> int:
|
|
|
179
186
|
finally:
|
|
180
187
|
server.shutdown()
|
|
181
188
|
server.server_close()
|
|
182
|
-
|
|
189
|
+
# Only clear the state file if this instance wrote it. A private
|
|
190
|
+
# instance clearing it would evict a still-running shared viewer's
|
|
191
|
+
# entry, making the next caller spawn a duplicate.
|
|
192
|
+
if not args.new and not user_chose_port:
|
|
193
|
+
_instance.clear_state()
|
|
183
194
|
return 0
|
|
184
195
|
|
|
185
196
|
|
|
@@ -121,16 +121,24 @@ def launch_or_connect(
|
|
|
121
121
|
URL is returned immediately — no new process is started. If nothing is
|
|
122
122
|
running a background subprocess is spawned, waited on for `timeout` seconds,
|
|
123
123
|
and then the URL is returned (the server is usually ready in < 0.5 s).
|
|
124
|
+
|
|
125
|
+
The returned URL carries ``?source=<name>`` when a source was given, so the
|
|
126
|
+
viewer opens on *that* source. Without it, the front-end selects whichever
|
|
127
|
+
source is first in the list — so an app adding its source to a viewer
|
|
128
|
+
another app had already started would open showing the other app's traces.
|
|
124
129
|
"""
|
|
125
130
|
import subprocess
|
|
126
131
|
import sys
|
|
127
132
|
import time
|
|
133
|
+
from urllib.parse import quote
|
|
128
134
|
|
|
129
135
|
existing = find_running()
|
|
130
136
|
if existing is not None:
|
|
131
137
|
h, p = existing["host"], existing["port"]
|
|
132
138
|
if source is not None:
|
|
133
|
-
add_source_to(h, p, source)
|
|
139
|
+
added = add_source_to(h, p, source)
|
|
140
|
+
if added and added.get("name"):
|
|
141
|
+
return f"http://{h}:{p}/?source={quote(added['name'], safe='')}"
|
|
134
142
|
return f"http://{h}:{p}/"
|
|
135
143
|
|
|
136
144
|
# Not running — start one in the background.
|
|
@@ -147,6 +155,14 @@ def launch_or_connect(
|
|
|
147
155
|
break
|
|
148
156
|
time.sleep(0.1)
|
|
149
157
|
|
|
158
|
+
# A freshly spawned viewer was seeded with this source on the command
|
|
159
|
+
# line, so it's the only one registered — but name it explicitly anyway,
|
|
160
|
+
# so the URL stays correct if another app adds a source before the tab
|
|
161
|
+
# is opened.
|
|
162
|
+
if source is not None:
|
|
163
|
+
names = list_source_names(host, port)
|
|
164
|
+
if names:
|
|
165
|
+
return f"http://{host}:{port}/?source={quote(names[0], safe='')}"
|
|
150
166
|
return f"http://{host}:{port}/"
|
|
151
167
|
|
|
152
168
|
|
|
@@ -167,3 +183,18 @@ def add_source_to(host: str, port: int, path: str,
|
|
|
167
183
|
return json.loads(resp.read().decode("utf-8"))
|
|
168
184
|
except Exception:
|
|
169
185
|
return None
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def list_source_names(host: str, port: int,
|
|
189
|
+
timeout: float = 1.0) -> list:
|
|
190
|
+
"""
|
|
191
|
+
Return the names of the sources registered with a running viewer, in the
|
|
192
|
+
order the viewer reports them. Empty list on any failure.
|
|
193
|
+
"""
|
|
194
|
+
url = f"http://{host}:{port}/api/sources"
|
|
195
|
+
try:
|
|
196
|
+
with urllib.request.urlopen(url, timeout=timeout) as resp:
|
|
197
|
+
sources = json.loads(resp.read().decode("utf-8"))
|
|
198
|
+
return [s["name"] for s in sources if isinstance(s, dict) and "name" in s]
|
|
199
|
+
except Exception:
|
|
200
|
+
return []
|
|
@@ -120,8 +120,16 @@ class ViewerState:
|
|
|
120
120
|
If no name is given, one is derived from the path: the folder name for a
|
|
121
121
|
directory, or the file's stem for a file. Collisions get a numeric
|
|
122
122
|
suffix so two files with the same name stay distinct.
|
|
123
|
+
|
|
124
|
+
Adding a path that is already registered returns the existing name
|
|
125
|
+
rather than registering it again — an app that calls
|
|
126
|
+
launch_or_connect() on every run would otherwise accumulate
|
|
127
|
+
traces-2, traces-3, ... entries for one unchanging file.
|
|
123
128
|
"""
|
|
124
129
|
path = os.path.abspath(os.path.expanduser(path))
|
|
130
|
+
for existing_name, existing_path in self.sources.items():
|
|
131
|
+
if existing_path == path:
|
|
132
|
+
return existing_name
|
|
125
133
|
if name is None:
|
|
126
134
|
name = _derive_name(path)
|
|
127
135
|
name = self._unique_name(name)
|
|
@@ -58,12 +58,19 @@ function init() {
|
|
|
58
58
|
initPreFilters(); // reads ?pf_* URL params seeded by TraceLog.view()
|
|
59
59
|
|
|
60
60
|
refreshSources().then(() => {
|
|
61
|
-
|
|
62
|
-
if (state.sources.length > 0) {
|
|
63
|
-
selectSource(state.sources[0].name);
|
|
64
|
-
} else {
|
|
61
|
+
if (state.sources.length === 0) {
|
|
65
62
|
renderLog();
|
|
63
|
+
return;
|
|
66
64
|
}
|
|
65
|
+
// ?source=NAME selects a specific source (set by launch_or_connect so an
|
|
66
|
+
// app opens on its OWN source, not whichever happens to be first in a
|
|
67
|
+
// viewer another app already started). Falls back to the first source
|
|
68
|
+
// when the param is absent, or names one that isn't registered here.
|
|
69
|
+
const requested = new URLSearchParams(window.location.search).get("source");
|
|
70
|
+
const match = requested
|
|
71
|
+
? state.sources.find((s) => s.name === requested)
|
|
72
|
+
: null;
|
|
73
|
+
selectSource(match ? match.name : state.sources[0].name);
|
|
67
74
|
});
|
|
68
75
|
}
|
|
69
76
|
|
|
@@ -289,42 +296,56 @@ function inspectorSummary(t) {
|
|
|
289
296
|
const touches = (t.touches || []).length;
|
|
290
297
|
const errors = (t.errors || []).length;
|
|
291
298
|
|
|
299
|
+
// Two-column [label, value] pairs rather than pre-padded strings. A plain
|
|
300
|
+
// padded-text block (the previous approach) has no column boundary of its
|
|
301
|
+
// own, so a long value — a full correlation ID, shown untruncated so it
|
|
302
|
+
// stays copyable — either overflows the card or, once wrapped, spills its
|
|
303
|
+
// continuation under the label column instead of staying in the value
|
|
304
|
+
// column. Rendering as a CSS grid (see .insp-summary) gives the value its
|
|
305
|
+
// own column to wrap within, independent of the label column's width.
|
|
306
|
+
//
|
|
292
307
|
// Always show this trace's own id. Parent and root are only shown when they
|
|
293
308
|
// add information: a root trace has no parent and is its own root, so those
|
|
294
|
-
//
|
|
295
|
-
const
|
|
309
|
+
// rows would be noise. A child trace shows both, so the chain is visible.
|
|
310
|
+
const rows = [["Trace:", shortId(t.trace_id)]];
|
|
296
311
|
if (t.parent_trace_id) {
|
|
297
|
-
|
|
312
|
+
rows.push(["Parent:", shortId(t.parent_trace_id)]);
|
|
298
313
|
}
|
|
299
314
|
if (t.root_trace_id && t.root_trace_id !== t.trace_id) {
|
|
300
|
-
|
|
315
|
+
rows.push(["Root:", shortId(t.root_trace_id)]);
|
|
301
316
|
}
|
|
302
317
|
if (t.upstream_trace_id) {
|
|
303
318
|
// The trace in another service that triggered this one. Shown in full for
|
|
304
319
|
// the same reason as correlation id: it is meant to be copied and searched
|
|
305
320
|
// against that other service's traces.
|
|
306
|
-
|
|
321
|
+
rows.push(["Upstream:", t.upstream_trace_id]);
|
|
307
322
|
}
|
|
308
323
|
if (t.correlation_id) {
|
|
309
324
|
// Shown in full (not shortId-truncated): the point of a correlation id is
|
|
310
325
|
// to copy/search it outside the app (search box, jq, grep), so a 6-char
|
|
311
326
|
// truncation would defeat the purpose.
|
|
312
|
-
|
|
327
|
+
rows.push(["Corr:", t.correlation_id]);
|
|
313
328
|
}
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
329
|
+
rows.push(["Kind:", t.kind || ""]);
|
|
330
|
+
rows.push(["Duration:", fmtDurLong(t.duration_ms)]);
|
|
331
|
+
rows.push(["Touches:", String(touches)]);
|
|
332
|
+
rows.push(["Errors:", String(errors)]);
|
|
318
333
|
if (t.sampled_out) {
|
|
319
334
|
// A failure recorded from a sampled-out trace: steps/events are empty
|
|
320
335
|
// because nothing was recording while the action ran.
|
|
321
|
-
|
|
336
|
+
rows.push(["Sampled:", "error-only record (detail was not recorded)"]);
|
|
322
337
|
}
|
|
323
338
|
|
|
339
|
+
const summaryHtml = rows
|
|
340
|
+
.map(([label, value]) =>
|
|
341
|
+
`<span class="insp-label">${esc(label)}</span><span class="insp-value">${esc(value)}</span>`
|
|
342
|
+
)
|
|
343
|
+
.join("");
|
|
344
|
+
|
|
324
345
|
return `
|
|
325
346
|
<div class="insp-title">${esc(t.action)}</div>
|
|
326
347
|
<div class="status status-${t.status}">● ${esc(t.status)}</div>
|
|
327
|
-
<div class="insp-summary">${
|
|
348
|
+
<div class="insp-summary">${summaryHtml}</div>
|
|
328
349
|
<div class="insp-actions">
|
|
329
350
|
<button class="btn btn-primary" id="btn-view-map">View trace</button>
|
|
330
351
|
<button class="btn" id="btn-copy">Copy JSON</button>
|
|
@@ -73,11 +73,11 @@
|
|
|
73
73
|
<table class="log-table">
|
|
74
74
|
<thead>
|
|
75
75
|
<tr>
|
|
76
|
-
<th>TIME</th>
|
|
77
|
-
<th>ACTION</th>
|
|
78
|
-
<th>STATUS</th>
|
|
79
|
-
<th>DUR.</th>
|
|
80
|
-
<th>T·E·B</th>
|
|
76
|
+
<th title="When the trace started">TIME</th>
|
|
77
|
+
<th title="The traced action's name">ACTION</th>
|
|
78
|
+
<th title="completed, failed, running, or cancelled">STATUS</th>
|
|
79
|
+
<th title="How long the action took">DUR.</th>
|
|
80
|
+
<th title="Touches · Errors · Budget — resources touched, errors recorded, and whether a recording limit was hit (Y) or not (—)">T·E·B</th>
|
|
81
81
|
</tr>
|
|
82
82
|
</thead>
|
|
83
83
|
<tbody id="log-body"></tbody>
|
|
@@ -413,10 +413,26 @@ body {
|
|
|
413
413
|
border: 1px solid var(--border);
|
|
414
414
|
border-radius: var(--radius);
|
|
415
415
|
padding: 16px;
|
|
416
|
-
white-space: pre;
|
|
417
416
|
color: var(--text-dim);
|
|
418
417
|
font-size: 12.5px;
|
|
419
|
-
|
|
418
|
+
/* A real two-column grid, not padded text: the label column sizes to its
|
|
419
|
+
widest label and the value column takes the rest. A long value (a full
|
|
420
|
+
correlation UUID, shown untruncated so it stays copyable) wraps within
|
|
421
|
+
its own column instead of either overflowing the card or, once wrapped,
|
|
422
|
+
spilling its continuation under the label column. */
|
|
423
|
+
display: grid;
|
|
424
|
+
grid-template-columns: auto 1fr;
|
|
425
|
+
column-gap: 14px;
|
|
426
|
+
row-gap: 9px;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
.insp-label {
|
|
430
|
+
color: var(--text-faint);
|
|
431
|
+
white-space: nowrap;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
.insp-value {
|
|
435
|
+
overflow-wrap: anywhere;
|
|
420
436
|
}
|
|
421
437
|
|
|
422
438
|
.insp-actions { display: flex; gap: 10px; margin-top: 16px; }
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|