traceact 0.6.0__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 (50) hide show
  1. {traceact-0.6.0/traceact.egg-info → traceact-0.6.2}/PKG-INFO +3 -3
  2. {traceact-0.6.0 → traceact-0.6.2}/README.md +2 -2
  3. {traceact-0.6.0 → traceact-0.6.2}/USAGE.md +10 -5
  4. {traceact-0.6.0 → traceact-0.6.2}/pyproject.toml +1 -1
  5. {traceact-0.6.0 → traceact-0.6.2}/tests/test_async_sink.py +37 -0
  6. {traceact-0.6.0 → traceact-0.6.2}/tests/test_otlp_sink.py +12 -0
  7. {traceact-0.6.0 → traceact-0.6.2}/tests/test_propagation.py +30 -0
  8. traceact-0.6.2/tests/test_sinks.py +165 -0
  9. traceact-0.6.2/tests/test_trace_hardening.py +301 -0
  10. traceact-0.6.2/tests/test_viewer_instance.py +257 -0
  11. {traceact-0.6.0 → traceact-0.6.2}/traceact/__init__.py +1 -1
  12. {traceact-0.6.0 → traceact-0.6.2}/traceact/budget.py +22 -8
  13. {traceact-0.6.0 → traceact-0.6.2}/traceact/decorators.py +14 -3
  14. {traceact-0.6.0 → traceact-0.6.2}/traceact/log.py +19 -5
  15. {traceact-0.6.0 → traceact-0.6.2}/traceact/middleware.py +23 -0
  16. {traceact-0.6.0 → traceact-0.6.2}/traceact/sinks.py +60 -20
  17. {traceact-0.6.0 → traceact-0.6.2}/traceact/trace.py +113 -21
  18. {traceact-0.6.0 → traceact-0.6.2}/traceact/viewer/cli.py +13 -2
  19. {traceact-0.6.0 → traceact-0.6.2}/traceact/viewer/instance.py +32 -1
  20. {traceact-0.6.0 → traceact-0.6.2}/traceact/viewer/reader.py +7 -2
  21. {traceact-0.6.0 → traceact-0.6.2}/traceact/viewer/server.py +8 -0
  22. {traceact-0.6.0 → traceact-0.6.2}/traceact/viewer/static/app.js +80 -22
  23. {traceact-0.6.0 → traceact-0.6.2}/traceact/viewer/static/index.html +5 -5
  24. {traceact-0.6.0 → traceact-0.6.2}/traceact/viewer/static/styles.css +18 -2
  25. {traceact-0.6.0 → traceact-0.6.2/traceact.egg-info}/PKG-INFO +3 -3
  26. {traceact-0.6.0 → traceact-0.6.2}/traceact.egg-info/SOURCES.txt +2 -0
  27. traceact-0.6.0/tests/test_sinks.py +0 -69
  28. {traceact-0.6.0 → traceact-0.6.2}/LICENSE +0 -0
  29. {traceact-0.6.0 → traceact-0.6.2}/MANIFEST.in +0 -0
  30. {traceact-0.6.0 → traceact-0.6.2}/setup.cfg +0 -0
  31. {traceact-0.6.0 → traceact-0.6.2}/tests/test_decorators.py +0 -0
  32. {traceact-0.6.0 → traceact-0.6.2}/tests/test_doctor.py +0 -0
  33. {traceact-0.6.0 → traceact-0.6.2}/tests/test_http_sink.py +0 -0
  34. {traceact-0.6.0 → traceact-0.6.2}/tests/test_reader.py +0 -0
  35. {traceact-0.6.0 → traceact-0.6.2}/tests/test_redaction.py +0 -0
  36. {traceact-0.6.0 → traceact-0.6.2}/tests/test_sqlite_sink.py +0 -0
  37. {traceact-0.6.0 → traceact-0.6.2}/tests/test_tracelog.py +0 -0
  38. {traceact-0.6.0 → traceact-0.6.2}/tests/test_viewer_query.py +0 -0
  39. {traceact-0.6.0 → traceact-0.6.2}/traceact/config.py +0 -0
  40. {traceact-0.6.0 → traceact-0.6.2}/traceact/context.py +0 -0
  41. {traceact-0.6.0 → traceact-0.6.2}/traceact/helpers.py +0 -0
  42. {traceact-0.6.0 → traceact-0.6.2}/traceact/ids.py +0 -0
  43. {traceact-0.6.0 → traceact-0.6.2}/traceact/propagation.py +0 -0
  44. {traceact-0.6.0 → traceact-0.6.2}/traceact/redaction.py +0 -0
  45. {traceact-0.6.0 → traceact-0.6.2}/traceact/viewer/__init__.py +0 -0
  46. {traceact-0.6.0 → traceact-0.6.2}/traceact/viewer/doctor.py +0 -0
  47. {traceact-0.6.0 → traceact-0.6.2}/traceact.egg-info/dependency_links.txt +0 -0
  48. {traceact-0.6.0 → traceact-0.6.2}/traceact.egg-info/entry_points.txt +0 -0
  49. {traceact-0.6.0 → traceact-0.6.2}/traceact.egg-info/requires.txt +0 -0
  50. {traceact-0.6.0 → 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.0
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
@@ -41,7 +41,7 @@ Dynamic: license-file
41
41
 
42
42
  X-ray vision for Python code.
43
43
 
44
- TraceAct is a lightweight Python package for action-level tracing. It records the full story of what happens when a function runs — every step taken, resource touched, event recorded, and failure encountered — so you or your agent can understand what actually happened.
44
+ TraceAct is a lightweight Python package for action-level tracing. It records the full story of what happens when a function runs — every step taken, resource touched, event recorded, and failure encountered — so you or your agent can understand what happened.
45
45
 
46
46
  ## Install
47
47
 
@@ -154,7 +154,7 @@ Checks Python version, that `~/.traceact` is writable, whether a viewer is alrea
154
154
 
155
155
  TraceAct exists to give you X-ray vision for your code. That means nothing TraceAct does itself should take that vision away.
156
156
 
157
- Wherever TraceAct might skip, drop, or truncate data a trace sampled out by `sample_rate`, events truncated by a budget limit, records dropped by `AsyncSink` under backpressure there is always an observable signal. The `budget_hit` flag marks truncated traces. The `AsyncSink.dropped` counter counts every dropped record. Sampling decisions are made before a trace object is created, so nothing is half-recorded.
157
+ Wherever TraceAct might skip, drop, or truncate data, there is a signal for it. Events truncated by a budget limit set the `budget_hit` flag. Records dropped by `AsyncSink` under backpressure increment `AsyncSink.dropped`. A failure inside a sampled-out trace is recorded anyway (with `always_trace_errors`, on by default) and marked `sampled_out` so you know its detail was not captured. The one deliberate silence is a sampled-out *success* that's what sampling is for — and it's opt-in through `sample_rate`.
158
158
 
159
159
  The design choice is always: **silent by default, observable by choice**. You decide whether to log, alert on, or ignore those signals. TraceAct never makes that decision for you.
160
160
 
@@ -6,7 +6,7 @@
6
6
 
7
7
  X-ray vision for Python code.
8
8
 
9
- TraceAct is a lightweight Python package for action-level tracing. It records the full story of what happens when a function runs — every step taken, resource touched, event recorded, and failure encountered — so you or your agent can understand what actually happened.
9
+ TraceAct is a lightweight Python package for action-level tracing. It records the full story of what happens when a function runs — every step taken, resource touched, event recorded, and failure encountered — so you or your agent can understand what happened.
10
10
 
11
11
  ## Install
12
12
 
@@ -119,7 +119,7 @@ Checks Python version, that `~/.traceact` is writable, whether a viewer is alrea
119
119
 
120
120
  TraceAct exists to give you X-ray vision for your code. That means nothing TraceAct does itself should take that vision away.
121
121
 
122
- Wherever TraceAct might skip, drop, or truncate data a trace sampled out by `sample_rate`, events truncated by a budget limit, records dropped by `AsyncSink` under backpressure there is always an observable signal. The `budget_hit` flag marks truncated traces. The `AsyncSink.dropped` counter counts every dropped record. Sampling decisions are made before a trace object is created, so nothing is half-recorded.
122
+ Wherever TraceAct might skip, drop, or truncate data, there is a signal for it. Events truncated by a budget limit set the `budget_hit` flag. Records dropped by `AsyncSink` under backpressure increment `AsyncSink.dropped`. A failure inside a sampled-out trace is recorded anyway (with `always_trace_errors`, on by default) and marked `sampled_out` so you know its detail was not captured. The one deliberate silence is a sampled-out *success* that's what sampling is for — and it's opt-in through `sample_rate`.
123
123
 
124
124
  The design choice is always: **silent by default, observable by choice**. You decide whether to log, alert on, or ignore those signals. TraceAct never makes that decision for you.
125
125
 
@@ -403,7 +403,9 @@ def export_report():
403
403
 
404
404
  **Sampling and nested traces:**
405
405
 
406
- When `sample_rate < 1.0` and a trace is sampled out, a skip sentinel is pushed onto the ContextVar. All nested `@traced_action` calls inside that function also produce nothing. A child trace can't appear in the sink without its parent.
406
+ When `sample_rate < 1.0` and a trace is sampled out, a skip sentinel is pushed onto the ContextVar. All nested `@traced_action` calls inside that function are suppressed with it — a successful child trace can't appear in the sink without its parent.
407
+
408
+ One exception, on by default: with `always_trace_errors=True`, a failure inside a sampled-out trace still produces a record. Nothing was recording while the action ran, so the record carries the action's identity, true timing, and the error, but empty steps/events/inputs — marked `sampled_out: true` to explain the reduced detail. Each suppressed frame the exception passes through records its own failure, the same shape as an unsampled run. Set `always_trace_errors=False` to make suppression absolute.
407
409
 
408
410
  ---
409
411
 
@@ -665,13 +667,13 @@ JsonlSink("data/traces/traces.jsonl")
665
667
  JsonlSink("/absolute/path/to/traces.jsonl")
666
668
  ```
667
669
 
668
- **Rotation (`max_bytes`):** by default the file grows without limit. Pass `max_bytes` to cap the active file's size — once the next write would exceed it, the current file is renamed to `<path>.<UTC timestamp>` and a fresh file starts at `path`:
670
+ **Rotation (`max_bytes`):** by default the file grows without limit. Pass `max_bytes` to cap the active file's size — once the next write would exceed it, the current file is renamed to `<stem>.<UTC timestamp><extension>` (e.g. `traces.20260726T120000000000Z.jsonl` — the extension stays last so rotated segments keep matching the `*.jsonl` pattern folder sources read) and a fresh file starts at `path`:
669
671
 
670
672
  ```python
671
673
  JsonlSink("data/traces/traces.jsonl", max_bytes=50_000_000) # cap ~50 MB per file
672
674
  ```
673
675
 
674
- Rotation renames rather than deletes, so history isn't lost — it's just no longer at `path`. Point the viewer at the containing **folder** rather than the single file to see the active file plus every rotated segment merged together: `traceact view data/traces/`. TraceAct doesn't currently delete old rotated segments on a schedule; clean them up yourself (e.g. a cron job or a retention script) if disk usage matters.
676
+ Rotation renames rather than deletes, so history isn't lost — it's just no longer at `path`. Point the viewer at the containing **folder** rather than the single file to see the active file plus every rotated segment merged together: `traceact view data/traces/`. TraceAct doesn't currently delete old rotated segments on a schedule; clean them up yourself (e.g. a cron job or a retention script) to keep disk usage down.
675
677
 
676
678
  ### ConsoleSink
677
679
 
@@ -1197,6 +1199,7 @@ The full JSON object written to the JSONL sink:
1197
1199
  "actor": "user",
1198
1200
  "status": "completed",
1199
1201
  "budget_hit": false,
1202
+ "sampled_out": false,
1200
1203
  "started_at": "2026-07-20T08:30:00.000Z",
1201
1204
  "ended_at": "2026-07-20T08:30:00.015Z",
1202
1205
  "duration_ms": 15.2,
@@ -1250,6 +1253,8 @@ The full JSON object written to the JSONL sink:
1250
1253
 
1251
1254
  **`budget_hit`** is a separate boolean field, not a status. A trace can be `"completed"` with `budget_hit: true`, meaning the function ran to completion but TraceAct stopped recording events partway through.
1252
1255
 
1256
+ **`sampled_out`** is `true` only on a failure record promoted from a sampled-out trace (`always_trace_errors`, on by default — see [Sampling and nested traces](#parent-and-child-traces)). Such a record has `status: "failed"` and the error, but empty `steps`/`events`/`inputs`, because nothing was recording while the action ran. It is `false` on every normally recorded trace.
1257
+
1253
1258
  ---
1254
1259
 
1255
1260
  ## Viewing traces
@@ -1385,7 +1390,7 @@ The trace log's search box and the row-limit setting both operate on the live-ta
1385
1390
  GET /api/query?source=traces&status=failed&action__contains=order&limit=200
1386
1391
  ```
1387
1392
 
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.
1393
+ - 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. Because `source` and `limit` are reserved for the endpoint itself, trace fields with those two names can't be filtered over HTTP — use `TraceLog.filter()` directly for that.
1389
1394
  - `__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
1395
  - **`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
1396
  - The response carries two separate completeness flags, both `false` when the result is everything that matched:
@@ -1523,7 +1528,7 @@ they answer separate questions:
1523
1528
  | `traceact-trace-id` | `upstream_trace_id` | "which trace in another service triggered me?" (causal lineage) |
1524
1529
  | `traceact-correlation-id` | `correlation_id` | "which wider workflow do I belong to?" (business grouping, passed through untouched) |
1525
1530
 
1526
- Keeping them separate matters: a trace can have an upstream parent in one
1531
+ Keeping them separate is deliberate: a trace can have an upstream parent in one
1527
1532
  service *and* belong to a correlation group that was assigned several hops
1528
1533
  earlier. Folding one into the other silently discards whichever one loses.
1529
1534
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "traceact"
7
- version = "0.6.0"
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"
@@ -203,6 +203,43 @@ class TestFaultTolerance:
203
203
  sink.close()
204
204
 
205
205
 
206
+ # ---------------------------------------------------------------------------
207
+ # Closed-sink contract
208
+ # ---------------------------------------------------------------------------
209
+
210
+ class TestWriteAfterClose:
211
+ def test_write_after_close_is_counted_dropped_not_enqueued(self):
212
+ inner = CaptureSink()
213
+ sink = AsyncSink([inner])
214
+ sink.write({"trace_id": "trc_1", "action": "a"})
215
+ sink.close()
216
+
217
+ sink.write({"trace_id": "trc_late", "action": "b"})
218
+ sink.write({"trace_id": "trc_later", "action": "c"})
219
+
220
+ assert sink.dropped == 2
221
+ assert [r["trace_id"] for r in inner.records] == ["trc_1"]
222
+
223
+ def test_write_after_close_does_not_restart_worker(self):
224
+ sink = AsyncSink([CaptureSink()])
225
+ sink.write({"trace_id": "trc_1", "action": "a"})
226
+ sink.close()
227
+ sink.write({"trace_id": "trc_late", "action": "b"})
228
+ assert sink._started is False
229
+
230
+ def test_close_before_any_write_still_closes(self):
231
+ sink = AsyncSink([CaptureSink()])
232
+ sink.close()
233
+ sink.write({"trace_id": "trc_1", "action": "a"})
234
+ assert sink.dropped == 1
235
+
236
+ def test_close_twice_is_safe(self):
237
+ sink = AsyncSink([CaptureSink()])
238
+ sink.write({"trace_id": "trc_1", "action": "a"})
239
+ sink.close()
240
+ sink.close() # must not raise
241
+
242
+
206
243
  # ---------------------------------------------------------------------------
207
244
  # Exported from public API
208
245
  # ---------------------------------------------------------------------------
@@ -140,6 +140,18 @@ class TestOtlpAttr:
140
140
  # ---------------------------------------------------------------------------
141
141
 
142
142
  class TestToOtlpSpan:
143
+ def test_sampled_out_true_emitted_as_attribute(self):
144
+ span = _to_otlp_span(_trace(sampled_out=True))
145
+ keys = {a["key"] for a in span["attributes"]}
146
+ assert "traceact.sampled_out" in keys
147
+
148
+ def test_sampled_out_false_emits_no_attribute(self):
149
+ # Emitted only when true, like budget_hit — a false flag on every
150
+ # span would be attribute noise in the collector.
151
+ span = _to_otlp_span(_trace(sampled_out=False))
152
+ keys = {a["key"] for a in span["attributes"]}
153
+ assert "traceact.sampled_out" not in keys
154
+
143
155
  def test_trace_id_field(self):
144
156
  span = _to_otlp_span(_trace())
145
157
  assert span["traceId"] == _trace_id_hex("trc_order_create")
@@ -514,6 +514,36 @@ class TestTraceActMiddleware(_SinkTestCase):
514
514
  self.assertEqual(rec["upstream_trace_id"], "trc_stream")
515
515
  self.assertEqual(rec["correlation_id"], "corr_s")
516
516
 
517
+ def test_len_preserved_for_list_bodies(self):
518
+ # PEP 3333 servers may special-case a body of len() == 1 (single
519
+ # write with Content-Length instead of chunked encoding). The
520
+ # streaming-safe wrapper must not hide __len__ when the body has it.
521
+ def app_with_list_body(environ, start_response):
522
+ start_response("200 OK", [])
523
+ return [b"whole body"]
524
+
525
+ app = TraceActMiddleware(app_with_list_body)
526
+ body = app(self._environ(trace_id="trc_len"), lambda s, h: None)
527
+ assert len(body) == 1
528
+ list(body)
529
+ body.close()
530
+
531
+ def test_no_len_on_generator_bodies(self):
532
+ # A generator body has no __len__; the wrapper must not grow one,
533
+ # since len() raising from inside would break servers that only
534
+ # check hasattr before calling it.
535
+ def streaming_app(environ, start_response):
536
+ start_response("200 OK", [])
537
+ def generate():
538
+ yield b"chunk"
539
+ return generate()
540
+
541
+ app = TraceActMiddleware(streaming_app)
542
+ body = app(self._environ(trace_id="trc_nolen"), lambda s, h: None)
543
+ assert not hasattr(body, "__len__")
544
+ list(body)
545
+ body.close()
546
+
517
547
  def test_flask_end_to_end(self):
518
548
  """Real Flask app behind the middleware, driven by its test client."""
519
549
  from flask import Flask
@@ -0,0 +1,165 @@
1
+ # test_sinks.py — JsonlSink writes, size-based rotation, and the shared
2
+ # buffered-mode record buffer.
3
+
4
+ import json
5
+ import os
6
+ import threading
7
+
8
+ from traceact import JsonlSink, TraceLog
9
+
10
+ from conftest import read_traces
11
+
12
+
13
+ def test_jsonl_sink_appends_one_line_per_record(tmp_path):
14
+ path = tmp_path / "traces.jsonl"
15
+ sink = JsonlSink(str(path))
16
+
17
+ sink.write({"trace_id": "trc_1", "action": "a", "started_at": "t1"})
18
+ sink.write({"trace_id": "trc_2", "action": "a", "started_at": "t2"})
19
+
20
+ records = read_traces(path)
21
+ assert [r["trace_id"] for r in records] == ["trc_1", "trc_2"]
22
+
23
+
24
+ def test_jsonl_sink_creates_parent_directories(tmp_path):
25
+ path = tmp_path / "nested" / "dir" / "traces.jsonl"
26
+ JsonlSink(str(path)) # should not raise
27
+
28
+ assert path.parent.is_dir()
29
+
30
+
31
+ def test_max_bytes_rotates_active_file_by_renaming(tmp_path):
32
+ path = tmp_path / "traces.jsonl"
33
+ sink = JsonlSink(str(path), max_bytes=500)
34
+
35
+ for i in range(20):
36
+ sink.write({
37
+ "trace_id": f"trc_{i}", "action": "a", "started_at": f"t{i}",
38
+ "padding": "x" * 20,
39
+ })
40
+
41
+ siblings = sorted(os.listdir(tmp_path))
42
+ rotated = [f for f in siblings if f != "traces.jsonl"]
43
+
44
+ # More than one file exists — rotation happened at least once.
45
+ assert len(rotated) > 0
46
+ # The active file never exceeds the cap.
47
+ assert os.path.getsize(path) <= 500
48
+ # Every rotated segment is itself under the cap too (each is a snapshot
49
+ # of what was the active file at the moment it got rotated away).
50
+ for name in rotated:
51
+ assert os.path.getsize(tmp_path / name) <= 500
52
+
53
+ # No record was lost: total lines across all files == number written.
54
+ total_lines = 0
55
+ for name in siblings:
56
+ with open(tmp_path / name, encoding="utf-8") as f:
57
+ total_lines += sum(1 for line in f if line.strip())
58
+ assert total_lines == 20
59
+
60
+
61
+ def test_no_max_bytes_never_rotates(tmp_path):
62
+ path = tmp_path / "traces.jsonl"
63
+ sink = JsonlSink(str(path)) # max_bytes=None (default)
64
+
65
+ for i in range(50):
66
+ sink.write({
67
+ "trace_id": f"trc_{i}", "action": "a", "started_at": f"t{i}",
68
+ "padding": "x" * 50,
69
+ })
70
+
71
+ assert sorted(os.listdir(tmp_path)) == ["traces.jsonl"]
72
+ assert len(read_traces(path)) == 50
73
+
74
+
75
+ def test_rotated_segments_keep_jsonl_extension(tmp_path):
76
+ # The rotation timestamp goes before the extension, not after it. A name
77
+ # ending in anything other than .jsonl would be invisible to the *.jsonl
78
+ # folder-source pattern the viewer and TraceLog read.
79
+ path = tmp_path / "traces.jsonl"
80
+ sink = JsonlSink(str(path), max_bytes=300)
81
+
82
+ for i in range(10):
83
+ sink.write({
84
+ "trace_id": f"trc_{i}", "action": "a", "started_at": f"t{i}",
85
+ "padding": "x" * 20,
86
+ })
87
+
88
+ rotated = [f for f in os.listdir(tmp_path) if f != "traces.jsonl"]
89
+ assert len(rotated) > 0
90
+ for name in rotated:
91
+ assert name.endswith(".jsonl")
92
+ assert name.startswith("traces.")
93
+
94
+
95
+ def test_folder_source_reads_active_plus_rotated_segments(tmp_path):
96
+ # End to end: every record written through a rotating sink is readable
97
+ # back through a folder source, active file and rotated segments together.
98
+ path = tmp_path / "traces.jsonl"
99
+ sink = JsonlSink(str(path), max_bytes=300)
100
+
101
+ for i in range(15):
102
+ sink.write({
103
+ "trace_id": f"trc_{i}", "action": "a",
104
+ "started_at": f"2026-07-26T00:{i:02d}:00Z",
105
+ "padding": "x" * 20,
106
+ })
107
+
108
+ assert len(os.listdir(tmp_path)) > 1 # rotation happened
109
+ assert TraceLog(str(tmp_path)).count() == 15
110
+
111
+
112
+ def test_folder_source_reads_legacy_rotated_names(tmp_path):
113
+ # Segments rotated by versions that appended the timestamp AFTER the
114
+ # extension (traces.jsonl.<ts>) must stay reachable too.
115
+ legacy = tmp_path / "traces.jsonl.20260726T120000000000Z"
116
+ with open(legacy, "w", encoding="utf-8") as f:
117
+ f.write(json.dumps({"trace_id": "trc_old", "action": "a",
118
+ "started_at": "2026-07-26T00:00:00Z"}) + "\n")
119
+ active = tmp_path / "traces.jsonl"
120
+ with open(active, "w", encoding="utf-8") as f:
121
+ f.write(json.dumps({"trace_id": "trc_new", "action": "a",
122
+ "started_at": "2026-07-26T01:00:00Z"}) + "\n")
123
+
124
+ log = TraceLog(str(tmp_path))
125
+ assert log.count() == 2
126
+
127
+ from traceact.viewer.reader import SourceReader
128
+ assert len(SourceReader(str(tmp_path)).snapshot(10)) == 2
129
+
130
+
131
+ def test_buffer_records_appended_during_flush_are_not_lost():
132
+ # flush_buffer snapshots and clears atomically; concurrent buffer_record
133
+ # calls during repeated flushes must never lose a record between the
134
+ # iterate and the clear.
135
+ from traceact.sinks import buffer_record, flush_buffer, _buffer
136
+
137
+ written = []
138
+
139
+ class Capture:
140
+ def write(self, record):
141
+ written.append(record)
142
+
143
+ total = 2000
144
+ stop = threading.Event()
145
+
146
+ def producer():
147
+ for i in range(total):
148
+ buffer_record({"n": i})
149
+
150
+ def flusher():
151
+ while not stop.is_set():
152
+ flush_buffer([Capture()])
153
+
154
+ p = threading.Thread(target=producer)
155
+ f = threading.Thread(target=flusher)
156
+ f.start()
157
+ p.start()
158
+ p.join()
159
+ stop.set()
160
+ f.join()
161
+ flush_buffer([Capture()]) # drain whatever the last cycle left behind
162
+
163
+ leftover = len(_buffer)
164
+ assert len(written) + leftover == total
165
+ assert leftover == 0