traceact 0.5.0__tar.gz → 0.6.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.5.0/traceact.egg-info → traceact-0.6.0}/PKG-INFO +2 -2
- {traceact-0.5.0 → traceact-0.6.0}/README.md +1 -1
- {traceact-0.5.0 → traceact-0.6.0}/USAGE.md +50 -6
- {traceact-0.5.0 → traceact-0.6.0}/pyproject.toml +1 -1
- {traceact-0.5.0 → traceact-0.6.0}/tests/test_tracelog.py +259 -0
- traceact-0.6.0/tests/test_viewer_query.py +436 -0
- {traceact-0.5.0 → traceact-0.6.0}/traceact/__init__.py +1 -1
- {traceact-0.5.0 → traceact-0.6.0}/traceact/log.py +157 -9
- {traceact-0.5.0 → traceact-0.6.0}/traceact/viewer/server.py +112 -1
- {traceact-0.5.0 → traceact-0.6.0}/traceact/viewer/static/app.js +90 -3
- {traceact-0.5.0 → traceact-0.6.0}/traceact/viewer/static/styles.css +13 -0
- {traceact-0.5.0 → traceact-0.6.0/traceact.egg-info}/PKG-INFO +2 -2
- {traceact-0.5.0 → traceact-0.6.0}/traceact.egg-info/SOURCES.txt +1 -0
- {traceact-0.5.0 → traceact-0.6.0}/LICENSE +0 -0
- {traceact-0.5.0 → traceact-0.6.0}/MANIFEST.in +0 -0
- {traceact-0.5.0 → traceact-0.6.0}/setup.cfg +0 -0
- {traceact-0.5.0 → traceact-0.6.0}/tests/test_async_sink.py +0 -0
- {traceact-0.5.0 → traceact-0.6.0}/tests/test_decorators.py +0 -0
- {traceact-0.5.0 → traceact-0.6.0}/tests/test_doctor.py +0 -0
- {traceact-0.5.0 → traceact-0.6.0}/tests/test_http_sink.py +0 -0
- {traceact-0.5.0 → traceact-0.6.0}/tests/test_otlp_sink.py +0 -0
- {traceact-0.5.0 → traceact-0.6.0}/tests/test_propagation.py +0 -0
- {traceact-0.5.0 → traceact-0.6.0}/tests/test_reader.py +0 -0
- {traceact-0.5.0 → traceact-0.6.0}/tests/test_redaction.py +0 -0
- {traceact-0.5.0 → traceact-0.6.0}/tests/test_sinks.py +0 -0
- {traceact-0.5.0 → traceact-0.6.0}/tests/test_sqlite_sink.py +0 -0
- {traceact-0.5.0 → traceact-0.6.0}/traceact/budget.py +0 -0
- {traceact-0.5.0 → traceact-0.6.0}/traceact/config.py +0 -0
- {traceact-0.5.0 → traceact-0.6.0}/traceact/context.py +0 -0
- {traceact-0.5.0 → traceact-0.6.0}/traceact/decorators.py +0 -0
- {traceact-0.5.0 → traceact-0.6.0}/traceact/helpers.py +0 -0
- {traceact-0.5.0 → traceact-0.6.0}/traceact/ids.py +0 -0
- {traceact-0.5.0 → traceact-0.6.0}/traceact/middleware.py +0 -0
- {traceact-0.5.0 → traceact-0.6.0}/traceact/propagation.py +0 -0
- {traceact-0.5.0 → traceact-0.6.0}/traceact/redaction.py +0 -0
- {traceact-0.5.0 → traceact-0.6.0}/traceact/sinks.py +0 -0
- {traceact-0.5.0 → traceact-0.6.0}/traceact/trace.py +0 -0
- {traceact-0.5.0 → traceact-0.6.0}/traceact/viewer/__init__.py +0 -0
- {traceact-0.5.0 → traceact-0.6.0}/traceact/viewer/cli.py +0 -0
- {traceact-0.5.0 → traceact-0.6.0}/traceact/viewer/doctor.py +0 -0
- {traceact-0.5.0 → traceact-0.6.0}/traceact/viewer/instance.py +0 -0
- {traceact-0.5.0 → traceact-0.6.0}/traceact/viewer/reader.py +0 -0
- {traceact-0.5.0 → traceact-0.6.0}/traceact/viewer/static/index.html +0 -0
- {traceact-0.5.0 → traceact-0.6.0}/traceact.egg-info/dependency_links.txt +0 -0
- {traceact-0.5.0 → traceact-0.6.0}/traceact.egg-info/entry_points.txt +0 -0
- {traceact-0.5.0 → traceact-0.6.0}/traceact.egg-info/requires.txt +0 -0
- {traceact-0.5.0 → traceact-0.6.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.6.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
|
|
@@ -128,7 +128,7 @@ Double-click `launch.command` in the repo root to open TraceAct from Finder with
|
|
|
128
128
|
|
|
129
129
|
The source modal (click the source name in the header) lets you:
|
|
130
130
|
|
|
131
|
-
- **Choose file / Choose folder** — opens a native macOS picker; returns the
|
|
131
|
+
- **Choose file / Choose folder** — opens a native macOS picker; returns the filesystem path for live tailing
|
|
132
132
|
- **Drag and drop** a `.jsonl` file — saved as a static snapshot in `~/.traceact/imports/`
|
|
133
133
|
- **Type a path** — collapsible fallback for pasting an absolute path
|
|
134
134
|
|
|
@@ -93,7 +93,7 @@ Double-click `launch.command` in the repo root to open TraceAct from Finder with
|
|
|
93
93
|
|
|
94
94
|
The source modal (click the source name in the header) lets you:
|
|
95
95
|
|
|
96
|
-
- **Choose file / Choose folder** — opens a native macOS picker; returns the
|
|
96
|
+
- **Choose file / Choose folder** — opens a native macOS picker; returns the filesystem path for live tailing
|
|
97
97
|
- **Drag and drop** a `.jsonl` file — saved as a static snapshot in `~/.traceact/imports/`
|
|
98
98
|
- **Type a path** — collapsible fallback for pasting an absolute path
|
|
99
99
|
|
|
@@ -626,7 +626,7 @@ configure(config=TraceConfig(redaction_presets=["filesystem_paths", "env_vars"])
|
|
|
626
626
|
|
|
627
627
|
An unknown preset name raises `ValueError` immediately at `TraceConfig(...)` construction, not later at trace time.
|
|
628
628
|
|
|
629
|
-
Also settable per-decorator, same as any other `TraceConfig` field — but note that a decorator-level `redaction_presets` **replaces** the package-level list rather than adding to it,
|
|
629
|
+
Also settable per-decorator, same as any other `TraceConfig` field — but note that a decorator-level `redaction_presets` **replaces** the package-level list rather than adding to it, the same way `capture_inputs` does:
|
|
630
630
|
|
|
631
631
|
```python
|
|
632
632
|
@traced_action(action="report.export", kind="app",
|
|
@@ -635,7 +635,7 @@ def export_report(...):
|
|
|
635
635
|
...
|
|
636
636
|
```
|
|
637
637
|
|
|
638
|
-
**These are field-name patterns, not content scanning.** A value is redacted because of what its *key* is called, not what it contains. `trace.input({"path": "/Users/mo/secret"})` is redacted by the `filesystem_paths` preset; `trace.input({"location": "/Users/mo/secret"})` is not, because `"location"` doesn't match any active pattern. This mirrors the baseline mechanism
|
|
638
|
+
**These are field-name patterns, not content scanning.** A value is redacted because of what its *key* is called, not what it contains. `trace.input({"path": "/Users/mo/secret"})` is redacted by the `filesystem_paths` preset; `trace.input({"location": "/Users/mo/secret"})` is not, because `"location"` doesn't match any active pattern. This mirrors the baseline mechanism (same substring, case-insensitive matching) — it's simple and has no false-positive risk from scanning arbitrary string content, at the cost of missing secrets stored under an unexpected field name.
|
|
639
639
|
|
|
640
640
|
**Nested redaction example:**
|
|
641
641
|
|
|
@@ -705,7 +705,7 @@ configure(sinks=[
|
|
|
705
705
|
])
|
|
706
706
|
```
|
|
707
707
|
|
|
708
|
-
**When to use it:** any time the inner sink is slow or remote — an HTTP collector, a database, or a high-latency filesystem. For local files on fast hardware,
|
|
708
|
+
**When to use it:** any time the inner sink is slow or remote — an HTTP collector, a database, or a high-latency filesystem. For local files on fast hardware, `JsonlSink` alone rarely needs the extra wrapping.
|
|
709
709
|
|
|
710
710
|
**Backpressure — and why drops are always observable:**
|
|
711
711
|
|
|
@@ -983,6 +983,32 @@ log.filter(status="failed").render_table(n=25) # cap rows shown
|
|
|
983
983
|
|
|
984
984
|
Each terminal call re-reads the JSONL file(s) — there is no caching, so you always get the current state of a live source.
|
|
985
985
|
|
|
986
|
+
`last()`/`first()` are memory-bounded: they hold at most `n` matching records per file at once rather than collecting every match before truncating. A broad filter (or no filter at all) over a large source costs memory proportional to `n`, not to how much of the source matches.
|
|
987
|
+
|
|
988
|
+
### query() — bounded result plus two completeness flags
|
|
989
|
+
|
|
990
|
+
```python
|
|
991
|
+
result = log.filter(status="failed").query(500)
|
|
992
|
+
result["traces"] # up to 500 matches, newest-first
|
|
993
|
+
result["scan_capped"] # True if max_lines_scanned stopped the scan early
|
|
994
|
+
result["limit_reached"] # True if more than 500 traces matched
|
|
995
|
+
```
|
|
996
|
+
|
|
997
|
+
Two separate reasons a result might not be every match that exists:
|
|
998
|
+
|
|
999
|
+
- `scan_capped` — the scan itself gave up early, per `max_lines_scanned`.
|
|
1000
|
+
- `limit_reached` — the scan finished (or found enough to stop), but `n` (or more) results matched — there may be more beyond what's returned. This is true of `last(n)` too; `last()` just has nowhere to report it, since it returns a plain list. Note that `len(result["traces"]) == n` alone can't tell you this — a bounded scan returns at most `n` regardless of whether `n` or a hundred thousand traces matched, so `limit_reached` is tracked from a count taken during the scan, not inferred from the result's length afterward.
|
|
1001
|
+
|
|
1002
|
+
Use `query()` instead of `last()` when the caller needs to distinguish those two situations — this is what the viewer's `/api/query` endpoint uses under the hood (see [Server-side search](#server-side-search-apiquery) above). `last()`/`first()` keep returning a plain list; `query()` is a separate method rather than a change to their return shape, so nothing about them breaks for existing callers.
|
|
1003
|
+
|
|
1004
|
+
### max_lines_scanned — bounding scan time
|
|
1005
|
+
|
|
1006
|
+
```python
|
|
1007
|
+
log = TraceLog("data/traces.jsonl", max_lines_scanned=200_000)
|
|
1008
|
+
```
|
|
1009
|
+
|
|
1010
|
+
Caps how many lines a scan reads (matched or not) before giving up and returning whatever was found, setting `scan_capped=True` on the next `query()` call. Defaults to `None` — unbounded, the same behaviour as before this existed. Set it when reading a source you don't control the size of, or when a single call needs a predictable worst-case cost (an HTTP handler, for instance — see below).
|
|
1011
|
+
|
|
986
1012
|
### Using TraceLog in tests
|
|
987
1013
|
|
|
988
1014
|
```python
|
|
@@ -1305,7 +1331,7 @@ Pass `--new` to bypass this and force a second instance — useful when you want
|
|
|
1305
1331
|
|
|
1306
1332
|
The "Add source" modal supports three ways to load a source:
|
|
1307
1333
|
|
|
1308
|
-
- **Choose file / Choose folder buttons** — opens a native macOS file or folder picker. The picker runs on the server side via AppleScript (`osascript`), so it returns the
|
|
1334
|
+
- **Choose file / Choose folder buttons** — opens a native macOS file or folder picker. The picker runs on the server side via AppleScript (`osascript`), so it returns the filesystem path and the viewer can tail it live. (Falls back to a `tkinter` dialog on non-macOS platforms.)
|
|
1309
1335
|
- **Drag and drop** — drop a `.jsonl` file onto the drop zone. The browser reads the file contents and posts them to the server, which saves a copy to `~/.traceact/imports/` and adds it as a source. Because the server holds a copy (not the original), this is a **static snapshot** — new writes to the original file won't appear. The viewer labels imported sources accordingly.
|
|
1310
1336
|
- **Type a path** — a collapsible text input for pasting or typing an absolute path. This gives live tailing just like the command-line argument.
|
|
1311
1337
|
|
|
@@ -1319,11 +1345,11 @@ The "Add source" modal supports three ways to load a source:
|
|
|
1319
1345
|
4. Installs or upgrades `traceact` inside that venv.
|
|
1320
1346
|
5. Runs `traceact view`, waits for the server to be ready, then opens the browser.
|
|
1321
1347
|
|
|
1322
|
-
The Terminal window stays open so Ctrl+C stops the viewer
|
|
1348
|
+
The Terminal window stays open so Ctrl+C stops the viewer. To pass a source file at launch from a script: `open launch.command path/to/traces.jsonl`.
|
|
1323
1349
|
|
|
1324
1350
|
### What the viewer shows
|
|
1325
1351
|
|
|
1326
|
-
- **Trace log** — a live, newest-first table of traces (time, action, status, duration, and touch/error/budget counts). A search box filters by action, kind, status, correlation ID, or touched target. The row count is capped (25 / 50 / 100 / 250, default 100) and paired with live tailing, so the newest traces are always in view.
|
|
1352
|
+
- **Trace log** — a live, newest-first table of traces (time, action, status, duration, and touch/error/budget counts). A search box filters by action, kind, status, correlation ID, or touched target, against the currently tailed rows. The row count is capped (25 / 50 / 100 / 250, default 100) and paired with live tailing, so the newest traces are always in view. A pre-filtered view opened via `TraceLog.view()` instead searches the full source on disk — see [Server-side search](#server-side-search-apiquery) below.
|
|
1327
1353
|
- **Trace inspector** — selecting a trace shows its own ID, its parent and root trace IDs (when it is a child trace), correlation ID (when present, shown in full), kind, duration, and touch/error counts. "Copy JSON" copies the full record.
|
|
1328
1354
|
- **Trace map** — a visual of one trace: the action as origin, its events and resources as connected nodes, with per-node status and a red marker on failures. Plays as a sequential step-through replay, with a speed slider (1×–10×, live, persisted) and pause/play.
|
|
1329
1355
|
- **Settings** — accent colour, display density, default trace view, row count, default replay speed, and a **Run diagnostics** button — all persisted to `localStorage` except diagnostics, which runs fresh each time.
|
|
@@ -1345,11 +1371,29 @@ These endpoints are available while a viewer is running. Apps and scripts can ca
|
|
|
1345
1371
|
| `GET` | `/api/pick?type=file\|folder` | — | `{"path":"...","cancelled":bool}` |
|
|
1346
1372
|
| `POST` | `/api/import` | `{"name":"file.jsonl","content":"..."}` | `{"name":"...","path":"...","imported":true}` |
|
|
1347
1373
|
| `GET` | `/api/stream?source=NAME&limit=N` | — | SSE stream: `snapshot` then `append` events |
|
|
1374
|
+
| `GET` | `/api/query?source=NAME&field[__op]=value&limit=N` | — | `{"traces":[...],"scan_capped":bool,"limit_reached":bool,"count":N}` |
|
|
1348
1375
|
|
|
1349
1376
|
`/api/doctor`'s `status` is `"pass"`, `"fail"`, or `"info"`; `hint` is present only on `"fail"` checks. `ok` is `true` only if every `"pass"`/`"fail"` check passed — `"info"` checks (traceact's version, whether a viewer is running) never affect it.
|
|
1350
1377
|
|
|
1351
1378
|
The SSE stream delivers one `snapshot` message (the last N traces as a JSON array) then `append` messages for each newly-written trace. A `": keepalive"` comment is sent every 0.5 s when nothing new arrives, to keep the connection alive through proxies.
|
|
1352
1379
|
|
|
1380
|
+
### Server-side search (`/api/query`)
|
|
1381
|
+
|
|
1382
|
+
The trace log's search box and the row-limit setting both operate on the live-tailed buffer — whatever the last N traces happen to be. That's fine for the search box (a quick, instant, client-side filter over what's currently in view), but a `TraceLog.view()` pre-filter is a precise, deliberately-specified query — it needs to find its match regardless of whether that match is still inside the tail window. `/api/query` answers filters against the whole source on disk, via `TraceLog`, and the viewer routes pre-filters through it automatically. You don't need to call it directly for that to happen.
|
|
1383
|
+
|
|
1384
|
+
```
|
|
1385
|
+
GET /api/query?source=traces&status=failed&action__contains=order&limit=200
|
|
1386
|
+
```
|
|
1387
|
+
|
|
1388
|
+
- Every query param except `source` and `limit` is a filter field, in the same `field` / `field__contains` / `field__startswith` / `field__endswith` form as `TraceLog.filter()`. Multiple params are ANDed, same as chaining `.filter()` calls.
|
|
1389
|
+
- `__re` is not accepted here — it's rejected with `400`. `TraceLog.filter(field__re=...)` only makes sense when the pattern comes from trusted code; over HTTP it's arbitrary caller-supplied input, and a catastrophic-backtracking pattern could hang the request. Use `__re` directly against `TraceLog` in Python instead.
|
|
1390
|
+
- **`limit` is hard-capped at 1000 server-side.** Requesting `limit=5000` against a source with 3000 matches returns only the newest 1000 — but not silently: `count` in the response is the count *returned*, not the count that matched, and `limit_reached` (below) tells you whether more may exist.
|
|
1391
|
+
- The response carries two separate completeness flags, both `false` when the result is everything that matched:
|
|
1392
|
+
- **`scan_capped`** — `true` if the scan hit its internal line-read ceiling before finishing reading the source.
|
|
1393
|
+
- **`limit_reached`** — `true` if more traces matched than `limit` allowed back (including when a too-large requested `limit` was clamped to 1000). `count == limit` is not itself a safe signal that nothing more exists — a bounded scan always returns at most `limit` regardless of whether `limit` or a hundred thousand traces matched, so this comes from a count taken during the scan, not from inspecting the returned list's length afterward.
|
|
1394
|
+
|
|
1395
|
+
Either flag `true` means the same thing to a caller: this may not be every match. The viewer shows "results may be incomplete" next to the pre-filter badges when either is set, rather than presenting a partial result as if it were exhaustive.
|
|
1396
|
+
|
|
1353
1397
|
### Notes
|
|
1354
1398
|
|
|
1355
1399
|
- The viewer is a **local, single-node development tool**. It reads files on the machine it runs on. Exposing one machine's traces to another over the network (`traceact serve`) is planned for a later version.
|
|
@@ -285,6 +285,265 @@ class TestCount:
|
|
|
285
285
|
assert TraceLog(str(f)).filter(status="failed").count() == 1
|
|
286
286
|
|
|
287
287
|
|
|
288
|
+
# ---------------------------------------------------------------------------
|
|
289
|
+
# Bounded reads: last()/first() memory-bounding correctness, query(), and
|
|
290
|
+
# max_lines_scanned. These are adversarial by design — each test is built to
|
|
291
|
+
# find the case where a bounded, per-file algorithm could plausibly diverge
|
|
292
|
+
# from the old "collect everything, then sort and slice" result, rather than
|
|
293
|
+
# just re-confirming the happy path already covered by TestLastFirst above.
|
|
294
|
+
# ---------------------------------------------------------------------------
|
|
295
|
+
|
|
296
|
+
class TestBoundedCorrectness:
|
|
297
|
+
"""
|
|
298
|
+
_read_bounded() collects each file's own top-n candidates rather than every
|
|
299
|
+
match, on the argument that the true global top-n must be a subset of that.
|
|
300
|
+
These tests are built specifically to break that argument if it's wrong:
|
|
301
|
+
matches skewed heavily into one file, ties across files, and n falling on
|
|
302
|
+
exact file/match boundaries.
|
|
303
|
+
"""
|
|
304
|
+
|
|
305
|
+
def test_matches_skewed_into_one_file_still_correct(self, tmp_path):
|
|
306
|
+
# File A holds the 5 newest matches; file B holds only old matches.
|
|
307
|
+
# A naive "per file, keep the first n seen" (rather than newest n)
|
|
308
|
+
# would get this wrong if it didn't account for scan order.
|
|
309
|
+
_write_jsonl(tmp_path / "a.jsonl", [
|
|
310
|
+
_trace(f"a{i}", started_at=f"2026-07-25T{10+i:02d}:00:00Z")
|
|
311
|
+
for i in range(5)
|
|
312
|
+
])
|
|
313
|
+
_write_jsonl(tmp_path / "b.jsonl", [
|
|
314
|
+
_trace(f"b{i}", started_at=f"2026-07-25T0{i}:00:00Z")
|
|
315
|
+
for i in range(5)
|
|
316
|
+
])
|
|
317
|
+
result = TraceLog(str(tmp_path)).last(3)
|
|
318
|
+
assert [t["action"] for t in result] == ["a4", "a3", "a2"]
|
|
319
|
+
|
|
320
|
+
def test_matches_interleaved_across_many_small_files(self, tmp_path):
|
|
321
|
+
# One match per file, timestamps interleaved out of filename order,
|
|
322
|
+
# so correctness can't come from accidentally reading files in a
|
|
323
|
+
# convenient order.
|
|
324
|
+
timestamps = ["03:00", "09:00", "01:00", "07:00", "05:00"]
|
|
325
|
+
for i, ts in enumerate(timestamps):
|
|
326
|
+
_write_jsonl(
|
|
327
|
+
tmp_path / f"shard{i}.jsonl",
|
|
328
|
+
[_trace(f"t{i}", started_at=f"2026-07-25T{ts}:00Z")]
|
|
329
|
+
)
|
|
330
|
+
result = TraceLog(str(tmp_path)).last(2)
|
|
331
|
+
assert [t["action"] for t in result] == ["t1", "t3"] # 09:00, 07:00
|
|
332
|
+
|
|
333
|
+
def test_exact_boundary_n_equals_total_matches(self, tmp_path):
|
|
334
|
+
f = tmp_path / "traces.jsonl"
|
|
335
|
+
_write_jsonl(f, [
|
|
336
|
+
_trace("a", started_at="2026-07-25T08:00:00Z"),
|
|
337
|
+
_trace("b", started_at="2026-07-25T09:00:00Z"),
|
|
338
|
+
])
|
|
339
|
+
result = TraceLog(str(f)).last(2)
|
|
340
|
+
assert len(result) == 2
|
|
341
|
+
|
|
342
|
+
def test_more_matches_per_file_than_n_still_bounded_correctly(self, tmp_path):
|
|
343
|
+
# 100 matches in one file, asking for the last 3 — exercises the
|
|
344
|
+
# maxlen-deque eviction path specifically, not just small inputs.
|
|
345
|
+
f = tmp_path / "traces.jsonl"
|
|
346
|
+
_write_jsonl(f, [
|
|
347
|
+
_trace(f"t{i}", started_at=f"2026-07-25T{i:02d}:00:00Z")
|
|
348
|
+
for i in range(23) # hours 00-22, valid HH range
|
|
349
|
+
])
|
|
350
|
+
result = TraceLog(str(f)).last(3)
|
|
351
|
+
assert [t["action"] for t in result] == ["t22", "t21", "t20"]
|
|
352
|
+
|
|
353
|
+
def test_first_stops_early_but_result_still_correct(self, tmp_path):
|
|
354
|
+
# first() early-exits per file once n matches are found; verify the
|
|
355
|
+
# early exit doesn't skip a legitimately-older match placed later in
|
|
356
|
+
# write order across multiple files.
|
|
357
|
+
_write_jsonl(tmp_path / "a.jsonl", [
|
|
358
|
+
_trace(f"a{i}", started_at=f"2026-07-25T{10+i:02d}:00:00Z")
|
|
359
|
+
for i in range(5)
|
|
360
|
+
])
|
|
361
|
+
_write_jsonl(tmp_path / "b.jsonl", [
|
|
362
|
+
_trace(f"b{i}", started_at=f"2026-07-25T0{i}:00:00Z")
|
|
363
|
+
for i in range(5)
|
|
364
|
+
])
|
|
365
|
+
result = TraceLog(str(tmp_path)).first(3)
|
|
366
|
+
assert [t["action"] for t in result] == ["b0", "b1", "b2"]
|
|
367
|
+
|
|
368
|
+
def test_filter_applied_before_bounding(self, tmp_path):
|
|
369
|
+
# The ring buffer must hold n *matching* records, not just the last n
|
|
370
|
+
# lines regardless of the filter — otherwise a filtered .last(n) could
|
|
371
|
+
# come back short even though n matches genuinely exist further back.
|
|
372
|
+
f = tmp_path / "traces.jsonl"
|
|
373
|
+
records = []
|
|
374
|
+
for i in range(20):
|
|
375
|
+
records.append(_trace(
|
|
376
|
+
f"n{i}", status="completed",
|
|
377
|
+
started_at=f"2026-07-25T{i:02d}:00:00Z",
|
|
378
|
+
))
|
|
379
|
+
# Only the last 2 lines are failures; everything else is noise after.
|
|
380
|
+
records.append(_trace("fail1", status="failed", started_at="2026-07-25T21:00:00Z"))
|
|
381
|
+
records.append(_trace("fail2", status="failed", started_at="2026-07-25T22:00:00Z"))
|
|
382
|
+
_write_jsonl(f, records)
|
|
383
|
+
result = TraceLog(str(f)).filter(status="failed").last(5)
|
|
384
|
+
assert [t["action"] for t in result] == ["fail2", "fail1"]
|
|
385
|
+
|
|
386
|
+
def test_zero_n_returns_empty(self, tmp_path):
|
|
387
|
+
f = tmp_path / "traces.jsonl"
|
|
388
|
+
_write_jsonl(f, [_trace("a")])
|
|
389
|
+
assert TraceLog(str(f)).last(0) == []
|
|
390
|
+
assert TraceLog(str(f)).first(0) == []
|
|
391
|
+
|
|
392
|
+
def test_negative_n_returns_empty(self, tmp_path):
|
|
393
|
+
f = tmp_path / "traces.jsonl"
|
|
394
|
+
_write_jsonl(f, [_trace("a")])
|
|
395
|
+
assert TraceLog(str(f)).last(-5) == []
|
|
396
|
+
assert TraceLog(str(f)).first(-5) == []
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
class TestQuery:
|
|
400
|
+
def test_query_returns_dict_shape(self, tmp_path):
|
|
401
|
+
f = tmp_path / "traces.jsonl"
|
|
402
|
+
_write_jsonl(f, [_trace("a")])
|
|
403
|
+
result = TraceLog(str(f)).query(10)
|
|
404
|
+
assert set(result.keys()) == {"traces", "scan_capped", "limit_reached"}
|
|
405
|
+
|
|
406
|
+
def test_query_uncapped_by_default(self, tmp_path):
|
|
407
|
+
f = tmp_path / "traces.jsonl"
|
|
408
|
+
_write_jsonl(f, [_trace("a"), _trace("b")])
|
|
409
|
+
result = TraceLog(str(f)).query(10)
|
|
410
|
+
assert result["scan_capped"] is False
|
|
411
|
+
assert len(result["traces"]) == 2
|
|
412
|
+
|
|
413
|
+
def test_limit_reached_false_when_fewer_matches_than_n(self, tmp_path):
|
|
414
|
+
f = tmp_path / "traces.jsonl"
|
|
415
|
+
_write_jsonl(f, [_trace("a"), _trace("b")])
|
|
416
|
+
result = TraceLog(str(f)).query(10)
|
|
417
|
+
assert result["limit_reached"] is False
|
|
418
|
+
|
|
419
|
+
def test_limit_reached_true_when_more_matches_than_n(self, tmp_path):
|
|
420
|
+
f = tmp_path / "traces.jsonl"
|
|
421
|
+
_write_jsonl(f, [
|
|
422
|
+
_trace(f"t{i}", started_at=f"2026-07-25T{i:02d}:00:00Z")
|
|
423
|
+
for i in range(15)
|
|
424
|
+
])
|
|
425
|
+
result = TraceLog(str(f)).query(10)
|
|
426
|
+
assert result["limit_reached"] is True
|
|
427
|
+
assert len(result["traces"]) == 10
|
|
428
|
+
|
|
429
|
+
def test_limit_reached_false_when_exactly_n_matches(self, tmp_path):
|
|
430
|
+
# Exactly n matches exist — every one was found, none were cut off.
|
|
431
|
+
# This is the case limit_reached must NOT flag as "may be more",
|
|
432
|
+
# since there genuinely isn't more.
|
|
433
|
+
f = tmp_path / "traces.jsonl"
|
|
434
|
+
_write_jsonl(f, [
|
|
435
|
+
_trace(f"t{i}", started_at=f"2026-07-25T{i:02d}:00:00Z")
|
|
436
|
+
for i in range(10)
|
|
437
|
+
])
|
|
438
|
+
result = TraceLog(str(f)).query(10)
|
|
439
|
+
assert result["limit_reached"] is False
|
|
440
|
+
assert len(result["traces"]) == 10
|
|
441
|
+
|
|
442
|
+
def test_limit_reached_false_for_zero_n(self, tmp_path):
|
|
443
|
+
# n<=0 is a degenerate "give me nothing" request, not "the limit was
|
|
444
|
+
# reached" — there is no limit to have reached.
|
|
445
|
+
f = tmp_path / "traces.jsonl"
|
|
446
|
+
_write_jsonl(f, [_trace("a")])
|
|
447
|
+
result = TraceLog(str(f)).query(0)
|
|
448
|
+
assert result["limit_reached"] is False
|
|
449
|
+
assert result["traces"] == []
|
|
450
|
+
|
|
451
|
+
def test_limit_reached_independent_of_scan_capped(self, tmp_path):
|
|
452
|
+
# Both flags can be true at once for different reasons: the scan gave
|
|
453
|
+
# up early (scan_capped) AND still happened to find >= n matches
|
|
454
|
+
# before that point (limit_reached). Neither implies the other.
|
|
455
|
+
f = tmp_path / "traces.jsonl"
|
|
456
|
+
_write_jsonl(f, [
|
|
457
|
+
_trace(f"t{i}", started_at=f"2026-07-25T{i:02d}:00:00Z")
|
|
458
|
+
for i in range(20)
|
|
459
|
+
])
|
|
460
|
+
result = TraceLog(str(f), max_lines_scanned=5).query(3)
|
|
461
|
+
assert result["scan_capped"] is True
|
|
462
|
+
assert result["limit_reached"] is True
|
|
463
|
+
|
|
464
|
+
def test_query_newest_first(self, tmp_path):
|
|
465
|
+
f = tmp_path / "traces.jsonl"
|
|
466
|
+
_write_jsonl(f, [
|
|
467
|
+
_trace("old", started_at="2026-07-25T08:00:00Z"),
|
|
468
|
+
_trace("new", started_at="2026-07-25T09:00:00Z"),
|
|
469
|
+
])
|
|
470
|
+
result = TraceLog(str(f)).query(10)
|
|
471
|
+
assert [t["action"] for t in result["traces"]] == ["new", "old"]
|
|
472
|
+
|
|
473
|
+
def test_query_respects_filters(self, tmp_path):
|
|
474
|
+
f = tmp_path / "traces.jsonl"
|
|
475
|
+
_write_jsonl(f, [
|
|
476
|
+
_trace("a", status="completed"),
|
|
477
|
+
_trace("b", status="failed"),
|
|
478
|
+
])
|
|
479
|
+
result = TraceLog(str(f)).filter(status="failed").query(10)
|
|
480
|
+
assert len(result["traces"]) == 1
|
|
481
|
+
assert result["traces"][0]["action"] == "b"
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
class TestMaxLinesScanned:
|
|
485
|
+
"""
|
|
486
|
+
max_lines_scanned bounds worst-case scan time — the counterpart to n
|
|
487
|
+
bounding memory. Every line counts against the cap, matched or not,
|
|
488
|
+
because the cost being bounded is reading and parsing, not matching.
|
|
489
|
+
"""
|
|
490
|
+
|
|
491
|
+
def test_default_is_uncapped(self, tmp_path):
|
|
492
|
+
f = tmp_path / "traces.jsonl"
|
|
493
|
+
_write_jsonl(f, [_trace(f"t{i}") for i in range(50)])
|
|
494
|
+
result = TraceLog(str(f)).query(10)
|
|
495
|
+
assert result["scan_capped"] is False
|
|
496
|
+
|
|
497
|
+
def test_cap_below_file_size_sets_capped_flag(self, tmp_path):
|
|
498
|
+
f = tmp_path / "traces.jsonl"
|
|
499
|
+
_write_jsonl(f, [
|
|
500
|
+
_trace(f"t{i}", started_at=f"2026-07-25T{i:02d}:00:00Z")
|
|
501
|
+
for i in range(20)
|
|
502
|
+
])
|
|
503
|
+
result = TraceLog(str(f), max_lines_scanned=5).query(10)
|
|
504
|
+
assert result["scan_capped"] is True
|
|
505
|
+
|
|
506
|
+
def test_cap_returns_partial_not_empty_results(self, tmp_path):
|
|
507
|
+
f = tmp_path / "traces.jsonl"
|
|
508
|
+
_write_jsonl(f, [
|
|
509
|
+
_trace(f"t{i}", started_at=f"2026-07-25T{i:02d}:00:00Z")
|
|
510
|
+
for i in range(20)
|
|
511
|
+
])
|
|
512
|
+
result = TraceLog(str(f), max_lines_scanned=5).query(10)
|
|
513
|
+
assert len(result["traces"]) > 0
|
|
514
|
+
|
|
515
|
+
def test_cap_at_exactly_line_count_not_capped(self, tmp_path):
|
|
516
|
+
f = tmp_path / "traces.jsonl"
|
|
517
|
+
_write_jsonl(f, [_trace(f"t{i}") for i in range(10)])
|
|
518
|
+
result = TraceLog(str(f), max_lines_scanned=10).query(10)
|
|
519
|
+
assert result["scan_capped"] is False
|
|
520
|
+
|
|
521
|
+
def test_malformed_lines_count_against_cap(self, tmp_path):
|
|
522
|
+
# The cost being bounded is reading/parsing, so garbage lines must
|
|
523
|
+
# count too — otherwise a file of mostly-noise could evade the cap
|
|
524
|
+
# entirely by never producing a "match" to count.
|
|
525
|
+
f = tmp_path / "traces.jsonl"
|
|
526
|
+
f.write_text("not json\n" * 10 + json.dumps(_trace("real")) + "\n")
|
|
527
|
+
result = TraceLog(str(f), max_lines_scanned=5).query(10)
|
|
528
|
+
assert result["scan_capped"] is True
|
|
529
|
+
assert result["traces"] == [] # the real trace is past the cap
|
|
530
|
+
|
|
531
|
+
def test_cap_stops_reading_further_files_in_folder_source(self, tmp_path):
|
|
532
|
+
_write_jsonl(tmp_path / "a.jsonl", [_trace(f"a{i}") for i in range(20)])
|
|
533
|
+
_write_jsonl(tmp_path / "b.jsonl", [_trace("b0")])
|
|
534
|
+
# Cap small enough to exhaust inside a.jsonl alone.
|
|
535
|
+
result = TraceLog(str(tmp_path), max_lines_scanned=3).query(10)
|
|
536
|
+
assert result["scan_capped"] is True
|
|
537
|
+
assert all(t["action"].startswith("a") for t in result["traces"])
|
|
538
|
+
|
|
539
|
+
def test_cap_propagates_through_filter_and_copy(self, tmp_path):
|
|
540
|
+
f = tmp_path / "traces.jsonl"
|
|
541
|
+
_write_jsonl(f, [_trace(f"t{i}") for i in range(20)])
|
|
542
|
+
log = TraceLog(str(f), max_lines_scanned=5).filter(kind="app")
|
|
543
|
+
result = log.query(10)
|
|
544
|
+
assert result["scan_capped"] is True
|
|
545
|
+
|
|
546
|
+
|
|
288
547
|
# ---------------------------------------------------------------------------
|
|
289
548
|
# render_table()
|
|
290
549
|
# ---------------------------------------------------------------------------
|