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.
Files changed (47) hide show
  1. {traceact-0.5.0/traceact.egg-info → traceact-0.6.0}/PKG-INFO +2 -2
  2. {traceact-0.5.0 → traceact-0.6.0}/README.md +1 -1
  3. {traceact-0.5.0 → traceact-0.6.0}/USAGE.md +50 -6
  4. {traceact-0.5.0 → traceact-0.6.0}/pyproject.toml +1 -1
  5. {traceact-0.5.0 → traceact-0.6.0}/tests/test_tracelog.py +259 -0
  6. traceact-0.6.0/tests/test_viewer_query.py +436 -0
  7. {traceact-0.5.0 → traceact-0.6.0}/traceact/__init__.py +1 -1
  8. {traceact-0.5.0 → traceact-0.6.0}/traceact/log.py +157 -9
  9. {traceact-0.5.0 → traceact-0.6.0}/traceact/viewer/server.py +112 -1
  10. {traceact-0.5.0 → traceact-0.6.0}/traceact/viewer/static/app.js +90 -3
  11. {traceact-0.5.0 → traceact-0.6.0}/traceact/viewer/static/styles.css +13 -0
  12. {traceact-0.5.0 → traceact-0.6.0/traceact.egg-info}/PKG-INFO +2 -2
  13. {traceact-0.5.0 → traceact-0.6.0}/traceact.egg-info/SOURCES.txt +1 -0
  14. {traceact-0.5.0 → traceact-0.6.0}/LICENSE +0 -0
  15. {traceact-0.5.0 → traceact-0.6.0}/MANIFEST.in +0 -0
  16. {traceact-0.5.0 → traceact-0.6.0}/setup.cfg +0 -0
  17. {traceact-0.5.0 → traceact-0.6.0}/tests/test_async_sink.py +0 -0
  18. {traceact-0.5.0 → traceact-0.6.0}/tests/test_decorators.py +0 -0
  19. {traceact-0.5.0 → traceact-0.6.0}/tests/test_doctor.py +0 -0
  20. {traceact-0.5.0 → traceact-0.6.0}/tests/test_http_sink.py +0 -0
  21. {traceact-0.5.0 → traceact-0.6.0}/tests/test_otlp_sink.py +0 -0
  22. {traceact-0.5.0 → traceact-0.6.0}/tests/test_propagation.py +0 -0
  23. {traceact-0.5.0 → traceact-0.6.0}/tests/test_reader.py +0 -0
  24. {traceact-0.5.0 → traceact-0.6.0}/tests/test_redaction.py +0 -0
  25. {traceact-0.5.0 → traceact-0.6.0}/tests/test_sinks.py +0 -0
  26. {traceact-0.5.0 → traceact-0.6.0}/tests/test_sqlite_sink.py +0 -0
  27. {traceact-0.5.0 → traceact-0.6.0}/traceact/budget.py +0 -0
  28. {traceact-0.5.0 → traceact-0.6.0}/traceact/config.py +0 -0
  29. {traceact-0.5.0 → traceact-0.6.0}/traceact/context.py +0 -0
  30. {traceact-0.5.0 → traceact-0.6.0}/traceact/decorators.py +0 -0
  31. {traceact-0.5.0 → traceact-0.6.0}/traceact/helpers.py +0 -0
  32. {traceact-0.5.0 → traceact-0.6.0}/traceact/ids.py +0 -0
  33. {traceact-0.5.0 → traceact-0.6.0}/traceact/middleware.py +0 -0
  34. {traceact-0.5.0 → traceact-0.6.0}/traceact/propagation.py +0 -0
  35. {traceact-0.5.0 → traceact-0.6.0}/traceact/redaction.py +0 -0
  36. {traceact-0.5.0 → traceact-0.6.0}/traceact/sinks.py +0 -0
  37. {traceact-0.5.0 → traceact-0.6.0}/traceact/trace.py +0 -0
  38. {traceact-0.5.0 → traceact-0.6.0}/traceact/viewer/__init__.py +0 -0
  39. {traceact-0.5.0 → traceact-0.6.0}/traceact/viewer/cli.py +0 -0
  40. {traceact-0.5.0 → traceact-0.6.0}/traceact/viewer/doctor.py +0 -0
  41. {traceact-0.5.0 → traceact-0.6.0}/traceact/viewer/instance.py +0 -0
  42. {traceact-0.5.0 → traceact-0.6.0}/traceact/viewer/reader.py +0 -0
  43. {traceact-0.5.0 → traceact-0.6.0}/traceact/viewer/static/index.html +0 -0
  44. {traceact-0.5.0 → traceact-0.6.0}/traceact.egg-info/dependency_links.txt +0 -0
  45. {traceact-0.5.0 → traceact-0.6.0}/traceact.egg-info/entry_points.txt +0 -0
  46. {traceact-0.5.0 → traceact-0.6.0}/traceact.egg-info/requires.txt +0 -0
  47. {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.5.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 real filesystem path for live tailing
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 real filesystem path for live tailing
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, exactly like `capture_inputs` does:
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 exactly (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.
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, the overhead of `JsonlSink` alone is rarely worth wrapping.
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 real filesystem path and the viewer can tail it live. (Falls back to a `tkinter` dialog on non-macOS platforms.)
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 cleanly. To pass a source file at launch from a script: `open launch.command path/to/traces.jsonl`.
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.
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "traceact"
7
- version = "0.5.0"
7
+ version = "0.6.0"
8
8
  description = "X-ray vision for your code. Lightweight action-level tracing for Python."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
@@ -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
  # ---------------------------------------------------------------------------