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.
- {traceact-0.6.0/traceact.egg-info → traceact-0.6.2}/PKG-INFO +3 -3
- {traceact-0.6.0 → traceact-0.6.2}/README.md +2 -2
- {traceact-0.6.0 → traceact-0.6.2}/USAGE.md +10 -5
- {traceact-0.6.0 → traceact-0.6.2}/pyproject.toml +1 -1
- {traceact-0.6.0 → traceact-0.6.2}/tests/test_async_sink.py +37 -0
- {traceact-0.6.0 → traceact-0.6.2}/tests/test_otlp_sink.py +12 -0
- {traceact-0.6.0 → traceact-0.6.2}/tests/test_propagation.py +30 -0
- traceact-0.6.2/tests/test_sinks.py +165 -0
- traceact-0.6.2/tests/test_trace_hardening.py +301 -0
- traceact-0.6.2/tests/test_viewer_instance.py +257 -0
- {traceact-0.6.0 → traceact-0.6.2}/traceact/__init__.py +1 -1
- {traceact-0.6.0 → traceact-0.6.2}/traceact/budget.py +22 -8
- {traceact-0.6.0 → traceact-0.6.2}/traceact/decorators.py +14 -3
- {traceact-0.6.0 → traceact-0.6.2}/traceact/log.py +19 -5
- {traceact-0.6.0 → traceact-0.6.2}/traceact/middleware.py +23 -0
- {traceact-0.6.0 → traceact-0.6.2}/traceact/sinks.py +60 -20
- {traceact-0.6.0 → traceact-0.6.2}/traceact/trace.py +113 -21
- {traceact-0.6.0 → traceact-0.6.2}/traceact/viewer/cli.py +13 -2
- {traceact-0.6.0 → traceact-0.6.2}/traceact/viewer/instance.py +32 -1
- {traceact-0.6.0 → traceact-0.6.2}/traceact/viewer/reader.py +7 -2
- {traceact-0.6.0 → traceact-0.6.2}/traceact/viewer/server.py +8 -0
- {traceact-0.6.0 → traceact-0.6.2}/traceact/viewer/static/app.js +80 -22
- {traceact-0.6.0 → traceact-0.6.2}/traceact/viewer/static/index.html +5 -5
- {traceact-0.6.0 → traceact-0.6.2}/traceact/viewer/static/styles.css +18 -2
- {traceact-0.6.0 → traceact-0.6.2/traceact.egg-info}/PKG-INFO +3 -3
- {traceact-0.6.0 → traceact-0.6.2}/traceact.egg-info/SOURCES.txt +2 -0
- traceact-0.6.0/tests/test_sinks.py +0 -69
- {traceact-0.6.0 → traceact-0.6.2}/LICENSE +0 -0
- {traceact-0.6.0 → traceact-0.6.2}/MANIFEST.in +0 -0
- {traceact-0.6.0 → traceact-0.6.2}/setup.cfg +0 -0
- {traceact-0.6.0 → traceact-0.6.2}/tests/test_decorators.py +0 -0
- {traceact-0.6.0 → traceact-0.6.2}/tests/test_doctor.py +0 -0
- {traceact-0.6.0 → traceact-0.6.2}/tests/test_http_sink.py +0 -0
- {traceact-0.6.0 → traceact-0.6.2}/tests/test_reader.py +0 -0
- {traceact-0.6.0 → traceact-0.6.2}/tests/test_redaction.py +0 -0
- {traceact-0.6.0 → traceact-0.6.2}/tests/test_sqlite_sink.py +0 -0
- {traceact-0.6.0 → traceact-0.6.2}/tests/test_tracelog.py +0 -0
- {traceact-0.6.0 → traceact-0.6.2}/tests/test_viewer_query.py +0 -0
- {traceact-0.6.0 → traceact-0.6.2}/traceact/config.py +0 -0
- {traceact-0.6.0 → traceact-0.6.2}/traceact/context.py +0 -0
- {traceact-0.6.0 → traceact-0.6.2}/traceact/helpers.py +0 -0
- {traceact-0.6.0 → traceact-0.6.2}/traceact/ids.py +0 -0
- {traceact-0.6.0 → traceact-0.6.2}/traceact/propagation.py +0 -0
- {traceact-0.6.0 → traceact-0.6.2}/traceact/redaction.py +0 -0
- {traceact-0.6.0 → traceact-0.6.2}/traceact/viewer/__init__.py +0 -0
- {traceact-0.6.0 → traceact-0.6.2}/traceact/viewer/doctor.py +0 -0
- {traceact-0.6.0 → traceact-0.6.2}/traceact.egg-info/dependency_links.txt +0 -0
- {traceact-0.6.0 → traceact-0.6.2}/traceact.egg-info/entry_points.txt +0 -0
- {traceact-0.6.0 → traceact-0.6.2}/traceact.egg-info/requires.txt +0 -0
- {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.
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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 `<
|
|
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)
|
|
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
|
|
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
|
|
|
@@ -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
|