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.
Files changed (49) hide show
  1. {traceact-0.6.1/traceact.egg-info → traceact-0.6.2}/PKG-INFO +1 -1
  2. {traceact-0.6.1 → traceact-0.6.2}/pyproject.toml +1 -1
  3. traceact-0.6.2/tests/test_viewer_instance.py +257 -0
  4. {traceact-0.6.1 → traceact-0.6.2}/traceact/__init__.py +1 -1
  5. {traceact-0.6.1 → traceact-0.6.2}/traceact/log.py +11 -2
  6. {traceact-0.6.1 → traceact-0.6.2}/traceact/viewer/cli.py +13 -2
  7. {traceact-0.6.1 → traceact-0.6.2}/traceact/viewer/instance.py +32 -1
  8. {traceact-0.6.1 → traceact-0.6.2}/traceact/viewer/server.py +8 -0
  9. {traceact-0.6.1 → traceact-0.6.2}/traceact/viewer/static/app.js +37 -16
  10. {traceact-0.6.1 → traceact-0.6.2}/traceact/viewer/static/index.html +5 -5
  11. {traceact-0.6.1 → traceact-0.6.2}/traceact/viewer/static/styles.css +18 -2
  12. {traceact-0.6.1 → traceact-0.6.2/traceact.egg-info}/PKG-INFO +1 -1
  13. {traceact-0.6.1 → traceact-0.6.2}/traceact.egg-info/SOURCES.txt +1 -0
  14. {traceact-0.6.1 → traceact-0.6.2}/LICENSE +0 -0
  15. {traceact-0.6.1 → traceact-0.6.2}/MANIFEST.in +0 -0
  16. {traceact-0.6.1 → traceact-0.6.2}/README.md +0 -0
  17. {traceact-0.6.1 → traceact-0.6.2}/USAGE.md +0 -0
  18. {traceact-0.6.1 → traceact-0.6.2}/setup.cfg +0 -0
  19. {traceact-0.6.1 → traceact-0.6.2}/tests/test_async_sink.py +0 -0
  20. {traceact-0.6.1 → traceact-0.6.2}/tests/test_decorators.py +0 -0
  21. {traceact-0.6.1 → traceact-0.6.2}/tests/test_doctor.py +0 -0
  22. {traceact-0.6.1 → traceact-0.6.2}/tests/test_http_sink.py +0 -0
  23. {traceact-0.6.1 → traceact-0.6.2}/tests/test_otlp_sink.py +0 -0
  24. {traceact-0.6.1 → traceact-0.6.2}/tests/test_propagation.py +0 -0
  25. {traceact-0.6.1 → traceact-0.6.2}/tests/test_reader.py +0 -0
  26. {traceact-0.6.1 → traceact-0.6.2}/tests/test_redaction.py +0 -0
  27. {traceact-0.6.1 → traceact-0.6.2}/tests/test_sinks.py +0 -0
  28. {traceact-0.6.1 → traceact-0.6.2}/tests/test_sqlite_sink.py +0 -0
  29. {traceact-0.6.1 → traceact-0.6.2}/tests/test_trace_hardening.py +0 -0
  30. {traceact-0.6.1 → traceact-0.6.2}/tests/test_tracelog.py +0 -0
  31. {traceact-0.6.1 → traceact-0.6.2}/tests/test_viewer_query.py +0 -0
  32. {traceact-0.6.1 → traceact-0.6.2}/traceact/budget.py +0 -0
  33. {traceact-0.6.1 → traceact-0.6.2}/traceact/config.py +0 -0
  34. {traceact-0.6.1 → traceact-0.6.2}/traceact/context.py +0 -0
  35. {traceact-0.6.1 → traceact-0.6.2}/traceact/decorators.py +0 -0
  36. {traceact-0.6.1 → traceact-0.6.2}/traceact/helpers.py +0 -0
  37. {traceact-0.6.1 → traceact-0.6.2}/traceact/ids.py +0 -0
  38. {traceact-0.6.1 → traceact-0.6.2}/traceact/middleware.py +0 -0
  39. {traceact-0.6.1 → traceact-0.6.2}/traceact/propagation.py +0 -0
  40. {traceact-0.6.1 → traceact-0.6.2}/traceact/redaction.py +0 -0
  41. {traceact-0.6.1 → traceact-0.6.2}/traceact/sinks.py +0 -0
  42. {traceact-0.6.1 → traceact-0.6.2}/traceact/trace.py +0 -0
  43. {traceact-0.6.1 → traceact-0.6.2}/traceact/viewer/__init__.py +0 -0
  44. {traceact-0.6.1 → traceact-0.6.2}/traceact/viewer/doctor.py +0 -0
  45. {traceact-0.6.1 → traceact-0.6.2}/traceact/viewer/reader.py +0 -0
  46. {traceact-0.6.1 → traceact-0.6.2}/traceact.egg-info/dependency_links.txt +0 -0
  47. {traceact-0.6.1 → traceact-0.6.2}/traceact.egg-info/entry_points.txt +0 -0
  48. {traceact-0.6.1 → traceact-0.6.2}/traceact.egg-info/requires.txt +0 -0
  49. {traceact-0.6.1 → traceact-0.6.2}/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.1
3
+ Version: 0.6.2
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
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "traceact"
7
- version = "0.6.1"
7
+ version = "0.6.2"
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,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.1"
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
- base = base_url if base_url.endswith("/") else base_url + "/"
296
- url = base + ("?" + urlencode(params) if params else "")
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
- _instance.write_state(args.host, port)
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
- _instance.clear_state()
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
- // If a source was seeded on the command line, stream the first one.
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
- // lines would be noise. A child trace shows both, so the chain is visible.
295
- const lines = [`Trace: ${shortId(t.trace_id)}`];
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
- lines.push(`Parent: ${shortId(t.parent_trace_id)}`);
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
- lines.push(`Root: ${shortId(t.root_trace_id)}`);
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
- lines.push(`Upstream: ${t.upstream_trace_id}`);
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
- lines.push(`Corr: ${t.correlation_id}`);
327
+ rows.push(["Corr:", t.correlation_id]);
313
328
  }
314
- lines.push(`Kind: ${t.kind || ""}`);
315
- lines.push(`Duration: ${fmtDurLong(t.duration_ms)}`);
316
- lines.push(`Touches: ${touches}`);
317
- lines.push(`Errors: ${errors}`);
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
- lines.push(`Sampled: error-only record (detail was not recorded)`);
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">${esc(lines.join("\n"))}</div>
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
- line-height: 1.9;
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; }
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: traceact
3
- Version: 0.6.1
3
+ Version: 0.6.2
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
@@ -15,6 +15,7 @@ tests/test_sinks.py
15
15
  tests/test_sqlite_sink.py
16
16
  tests/test_trace_hardening.py
17
17
  tests/test_tracelog.py
18
+ tests/test_viewer_instance.py
18
19
  tests/test_viewer_query.py
19
20
  traceact/__init__.py
20
21
  traceact/budget.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
File without changes
File without changes
File without changes
File without changes