traceact 0.3.0__tar.gz → 0.4.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 (45) hide show
  1. {traceact-0.3.0/traceact.egg-info → traceact-0.4.0}/PKG-INFO +10 -2
  2. {traceact-0.3.0 → traceact-0.4.0}/README.md +9 -1
  3. {traceact-0.3.0 → traceact-0.4.0}/USAGE.md +337 -7
  4. {traceact-0.3.0 → traceact-0.4.0}/pyproject.toml +1 -1
  5. traceact-0.4.0/tests/test_async_sink.py +212 -0
  6. traceact-0.4.0/tests/test_http_sink.py +148 -0
  7. traceact-0.4.0/tests/test_otlp_sink.py +464 -0
  8. traceact-0.4.0/tests/test_sqlite_sink.py +172 -0
  9. traceact-0.4.0/tests/test_tracelog.py +466 -0
  10. traceact-0.4.0/traceact/__init__.py +82 -0
  11. traceact-0.4.0/traceact/log.py +421 -0
  12. traceact-0.4.0/traceact/sinks.py +1260 -0
  13. {traceact-0.3.0 → traceact-0.4.0}/traceact/viewer/static/app.js +100 -2
  14. {traceact-0.3.0 → traceact-0.4.0}/traceact/viewer/static/index.html +14 -1
  15. {traceact-0.3.0 → traceact-0.4.0}/traceact/viewer/static/styles.css +37 -6
  16. {traceact-0.3.0 → traceact-0.4.0/traceact.egg-info}/PKG-INFO +10 -2
  17. {traceact-0.3.0 → traceact-0.4.0}/traceact.egg-info/SOURCES.txt +6 -0
  18. traceact-0.3.0/traceact/__init__.py +0 -51
  19. traceact-0.3.0/traceact/sinks.py +0 -520
  20. {traceact-0.3.0 → traceact-0.4.0}/LICENSE +0 -0
  21. {traceact-0.3.0 → traceact-0.4.0}/MANIFEST.in +0 -0
  22. {traceact-0.3.0 → traceact-0.4.0}/setup.cfg +0 -0
  23. {traceact-0.3.0 → traceact-0.4.0}/tests/test_decorators.py +0 -0
  24. {traceact-0.3.0 → traceact-0.4.0}/tests/test_doctor.py +0 -0
  25. {traceact-0.3.0 → traceact-0.4.0}/tests/test_reader.py +0 -0
  26. {traceact-0.3.0 → traceact-0.4.0}/tests/test_redaction.py +0 -0
  27. {traceact-0.3.0 → traceact-0.4.0}/tests/test_sinks.py +0 -0
  28. {traceact-0.3.0 → traceact-0.4.0}/traceact/budget.py +0 -0
  29. {traceact-0.3.0 → traceact-0.4.0}/traceact/config.py +0 -0
  30. {traceact-0.3.0 → traceact-0.4.0}/traceact/context.py +0 -0
  31. {traceact-0.3.0 → traceact-0.4.0}/traceact/decorators.py +0 -0
  32. {traceact-0.3.0 → traceact-0.4.0}/traceact/helpers.py +0 -0
  33. {traceact-0.3.0 → traceact-0.4.0}/traceact/ids.py +0 -0
  34. {traceact-0.3.0 → traceact-0.4.0}/traceact/redaction.py +0 -0
  35. {traceact-0.3.0 → traceact-0.4.0}/traceact/trace.py +0 -0
  36. {traceact-0.3.0 → traceact-0.4.0}/traceact/viewer/__init__.py +0 -0
  37. {traceact-0.3.0 → traceact-0.4.0}/traceact/viewer/cli.py +0 -0
  38. {traceact-0.3.0 → traceact-0.4.0}/traceact/viewer/doctor.py +0 -0
  39. {traceact-0.3.0 → traceact-0.4.0}/traceact/viewer/instance.py +0 -0
  40. {traceact-0.3.0 → traceact-0.4.0}/traceact/viewer/reader.py +0 -0
  41. {traceact-0.3.0 → traceact-0.4.0}/traceact/viewer/server.py +0 -0
  42. {traceact-0.3.0 → traceact-0.4.0}/traceact.egg-info/dependency_links.txt +0 -0
  43. {traceact-0.3.0 → traceact-0.4.0}/traceact.egg-info/entry_points.txt +0 -0
  44. {traceact-0.3.0 → traceact-0.4.0}/traceact.egg-info/requires.txt +0 -0
  45. {traceact-0.3.0 → traceact-0.4.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.0
3
+ Version: 0.4.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
@@ -143,7 +143,15 @@ Checks Python version, that `~/.traceact` is writable, whether a viewer is alrea
143
143
  | `Step` | A human-readable timeline marker within a trace |
144
144
  | `Event` | A structured operation: db, http, file, model, job, etc. |
145
145
  | `Touch` | A resource involved in the trace (auto-derived from events) |
146
- | `Sink` | Where completed traces are written (`JsonlSink`, `ConsoleSink`) |
146
+ | `Sink` | Where completed traces are written (`JsonlSink`, `ConsoleSink`, `AsyncSink`) |
147
+
148
+ ### Design principle: observable by choice, never forced blind
149
+
150
+ TraceAct exists to give you X-ray vision for your code. That means nothing TraceAct does itself should take that vision away.
151
+
152
+ 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.
153
+
154
+ 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.
147
155
 
148
156
  ## Wiring into a web app
149
157
 
@@ -113,7 +113,15 @@ Checks Python version, that `~/.traceact` is writable, whether a viewer is alrea
113
113
  | `Step` | A human-readable timeline marker within a trace |
114
114
  | `Event` | A structured operation: db, http, file, model, job, etc. |
115
115
  | `Touch` | A resource involved in the trace (auto-derived from events) |
116
- | `Sink` | Where completed traces are written (`JsonlSink`, `ConsoleSink`) |
116
+ | `Sink` | Where completed traces are written (`JsonlSink`, `ConsoleSink`, `AsyncSink`) |
117
+
118
+ ### Design principle: observable by choice, never forced blind
119
+
120
+ TraceAct exists to give you X-ray vision for your code. That means nothing TraceAct does itself should take that vision away.
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.
123
+
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.
117
125
 
118
126
  ## Wiring into a web app
119
127
 
@@ -14,7 +14,7 @@ traceact/
14
14
  context.py — ContextVar for active trace, SKIP sentinel
15
15
  redaction.py — SENSITIVE_PATTERNS baseline + REDACTION_PRESETS registry
16
16
  sinks.py — JsonlSink (thread-safe, rotation via max_bytes), ConsoleSink,
17
- AsyncSink (not yet public)
17
+ AsyncSink (background-thread wrapper; public as of v0.4)
18
18
  helpers.py — TraceHelpersMixin (trace.db, trace.http, trace.file, trace.model)
19
19
  ids.py — ID generation (trc_, evt_, stp_, corr_ prefixes)
20
20
 
@@ -52,12 +52,13 @@ tests/ — pytest suite (pip install -e ".[dev]" && pytest)
52
52
  13. [Background jobs and correlation IDs](#background-jobs-and-correlation-ids)
53
53
  14. [Input capture](#input-capture)
54
54
  15. [Sinks](#sinks)
55
- 16. [Budget configuration](#budget-configuration)
56
- 17. [TraceConfig fields](#traceconfig-fields)
57
- 18. [Test isolation](#test-isolation)
58
- 19. [Trace record schema](#trace-record-schema)
59
- 20. [Viewing traces](#viewing-traces)
60
- 21. [Integrating the viewer into your app](#integrating-the-viewer-into-your-app)
55
+ 16. [TraceLog](#tracelog)
56
+ 17. [Budget configuration](#budget-configuration)
57
+ 18. [TraceConfig fields](#traceconfig-fields)
58
+ 19. [Test isolation](#test-isolation)
59
+ 20. [Trace record schema](#trace-record-schema)
60
+ 21. [Viewing traces](#viewing-traces)
61
+ 22. [Integrating the viewer into your app](#integrating-the-viewer-into-your-app)
61
62
 
62
63
  ---
63
64
 
@@ -688,12 +689,341 @@ configure(sinks=[
688
689
  ])
689
690
  ```
690
691
 
692
+ ### AsyncSink
693
+
694
+ Wraps any other sink(s) and performs all writes on a background thread, keeping I/O completely off the application's hot path. The traced function drops the record into an in-memory queue and returns immediately; a single worker thread drains the queue at its own pace.
695
+
696
+ ```python
697
+ from traceact import AsyncSink, JsonlSink
698
+
699
+ configure(sinks=[
700
+ AsyncSink([JsonlSink("data/traces/traces.jsonl")])
701
+ ])
702
+ ```
703
+
704
+ **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.
705
+
706
+ **Backpressure — and why drops are always observable:**
707
+
708
+ If your application produces traces faster than the worker can write them, the queue fills up. TraceAct will never crash or block your app to protect a trace record — but it will never drop records *silently* either. Every record dropped under a backpressure policy is counted in `AsyncSink.dropped`. Check it, log it, or expose it in a health endpoint:
709
+
710
+ ```python
711
+ sink = AsyncSink([JsonlSink("traces.jsonl")])
712
+ configure(sinks=[sink])
713
+
714
+ # later, in a health check or periodic log:
715
+ if sink.dropped > 0:
716
+ logger.warning("AsyncSink dropped %d trace records (queue full)", sink.dropped)
717
+ ```
718
+
719
+ The whole point of TraceAct is X-ray vision for your code. A silent drop is blindness. The `dropped` counter means you can *choose* to ignore dropped records — but the choice is yours, not the library's.
720
+
721
+ Three policies for when the queue is full:
722
+
723
+ | Policy | Behaviour | Use when |
724
+ |---|---|---|
725
+ | `"drop_newest"` (default) | Discard the incoming record; count it | Older in-flight records are more diagnostically useful |
726
+ | `"drop_oldest"` | Evict the oldest queued record to make room | Recent traces matter more than historical ones |
727
+ | `"block"` | Stall the calling thread until there is space | Zero loss is required and brief latency is acceptable |
728
+
729
+ ```python
730
+ AsyncSink([JsonlSink("traces.jsonl")], max_queue=50_000, on_full="drop_oldest")
731
+ ```
732
+
733
+ **Graceful shutdown:** `AsyncSink` registers an `atexit` hook on first write. When the process exits normally, the worker flushes all buffered records before stopping — short-lived scripts don't need to call `close()` explicitly, but you can call it yourself at a known shutdown point to flush sooner.
734
+
735
+ **Fork safety:** `os.fork()` doesn't copy background threads into child processes. `AsyncSink` registers a post-fork handler to reset the worker in the child so it starts fresh on the next write.
736
+
737
+ **Wrapping multiple sinks:**
738
+
739
+ ```python
740
+ AsyncSink([
741
+ JsonlSink("data/traces/traces.jsonl"),
742
+ ConsoleSink(pretty=False),
743
+ ])
744
+ ```
745
+
746
+ Both inner sinks receive every record on the background thread. A failing inner sink is caught and skipped so one bad sink can't kill the worker or lose records destined for the others.
747
+
748
+ ### SqliteSink
749
+
750
+ Writes finished traces to a local SQLite database using stdlib `sqlite3` — no extra dependencies. Common fields (`action`, `kind`, `status`, `started_at`, `correlation_id`, etc.) are stored as indexed scalar columns for fast filtering; the full trace record is also stored as JSON in a `record` column so no detail is ever lost. The schema is created automatically on first write.
751
+
752
+ ```python
753
+ from traceact import SqliteSink, configure
754
+
755
+ configure(sinks=[SqliteSink("data/traces.db")])
756
+ ```
757
+
758
+ **Custom table name:**
759
+
760
+ ```python
761
+ SqliteSink("data/traces.db", table="my_traces")
762
+ ```
763
+
764
+ **Querying traces directly from the database:**
765
+
766
+ ```python
767
+ import sqlite3, json
768
+
769
+ conn = sqlite3.connect("data/traces.db")
770
+
771
+ # All failures in the last hour:
772
+ rows = conn.execute("""
773
+ SELECT action, duration_ms, record
774
+ FROM traces
775
+ WHERE status = 'failed'
776
+ AND started_at > datetime('now', '-1 hour')
777
+ ORDER BY started_at DESC
778
+ """).fetchall()
779
+
780
+ for action, ms, raw in rows:
781
+ record = json.loads(raw)
782
+ print(f"{action} {ms}ms errors={record.get('errors')}")
783
+ ```
784
+
785
+ **Concurrent writes:** SQLite is opened in WAL mode so reads and writes can proceed concurrently. For high-concurrency workloads, wrap in `AsyncSink` so write latency stays off the application's hot path:
786
+
787
+ ```python
788
+ from traceact import AsyncSink, SqliteSink, configure
789
+
790
+ configure(sinks=[AsyncSink([SqliteSink("data/traces.db")])])
791
+ ```
792
+
793
+ **Write errors** are printed to stderr and never propagated to the caller — a database hiccup doesn't interrupt the traced function.
794
+
795
+ ### HttpSink
796
+
797
+ POSTs each finished trace as a JSON body to an HTTP or HTTPS endpoint. Uses stdlib `urllib` only — zero extra dependencies.
798
+
799
+ ```python
800
+ from traceact import HttpSink, AsyncSink, configure
801
+
802
+ configure(sinks=[
803
+ AsyncSink([HttpSink("https://collector.example.com/traces")])
804
+ ])
805
+ ```
806
+
807
+ **Always wrap in `AsyncSink` for production use.** Each write makes a synchronous HTTP request; without `AsyncSink` that latency hits every traced function call on the return path.
808
+
809
+ **Custom headers** (API keys, auth tokens):
810
+
811
+ ```python
812
+ HttpSink(
813
+ "https://collector.example.com/traces",
814
+ headers={"Authorization": "Bearer <your-token>"},
815
+ )
816
+ ```
817
+
818
+ **Custom timeout** (default: 5 seconds):
819
+
820
+ ```python
821
+ HttpSink("https://collector.example.com/traces", timeout=2.0)
822
+ ```
823
+
824
+ **Observable failures:** network errors, timeouts, and non-2xx responses are counted in `HttpSink.failed` — never raised, never silently swallowed. Check it in a health endpoint or periodic log:
825
+
826
+ ```python
827
+ sink = HttpSink("https://collector.example.com/traces")
828
+ configure(sinks=[AsyncSink([sink])])
829
+
830
+ # in a health check or periodic log:
831
+ if sink.failed > 0:
832
+ logger.warning("HttpSink: %d trace deliveries failed", sink.failed)
833
+ ```
834
+
835
+ ### OtlpSink
836
+
837
+ Exports finished traces to any OTLP-compatible collector — Jaeger, Grafana Tempo, Honeycomb, Datadog agent, the OpenTelemetry Collector, and others. Uses OTLP/HTTP+JSON over stdlib `urllib`. Zero extra dependencies; no `opentelemetry-sdk` required.
838
+
839
+ ```python
840
+ from traceact import OtlpSink, AsyncSink, configure
841
+
842
+ configure(sinks=[
843
+ AsyncSink([OtlpSink("http://localhost:4318")])
844
+ ])
845
+ ```
846
+
847
+ Point `endpoint` at your collector's OTLP HTTP receiver base URL (the standard port is 4318). TraceAct appends `/v1/traces` automatically.
848
+
849
+ **Always wrap in `AsyncSink` for production use** — each write makes a synchronous HTTP request.
850
+
851
+ **SaaS collectors (Honeycomb, Datadog, etc.):**
852
+
853
+ ```python
854
+ # Honeycomb
855
+ OtlpSink(
856
+ "https://api.honeycomb.io",
857
+ headers={"x-honeycomb-team": "<your-api-key>"},
858
+ )
859
+
860
+ # Datadog (OTLP agent receiver, default port 4318)
861
+ OtlpSink("http://localhost:4318")
862
+
863
+ # Grafana Cloud
864
+ OtlpSink(
865
+ "https://<your-instance>.grafana.net/otlp",
866
+ headers={"Authorization": "Basic <base64-encoded-credentials>"},
867
+ )
868
+ ```
869
+
870
+ **Service name and resource attributes:**
871
+
872
+ ```python
873
+ OtlpSink(
874
+ "http://localhost:4318",
875
+ resource_attributes={
876
+ "service.name": "orders-api",
877
+ "deployment.env": "production",
878
+ "service.version": "2.1.0",
879
+ },
880
+ )
881
+ ```
882
+
883
+ These appear as resource-level attributes on every span exported by this sink. `service.name` defaults to `"traceact"` if you don't set it.
884
+
885
+ **How TraceAct records map to OTel spans:**
886
+
887
+ | TraceAct field | OTel span field |
888
+ |---|---|
889
+ | `action` | Span name |
890
+ | `kind` (`db`, `http`, `cache`, `model`, `auth`, `payment`) | SpanKind CLIENT |
891
+ | `kind` (`queue`, `email`, `export`) | SpanKind PRODUCER |
892
+ | `kind` (`job`) | SpanKind CONSUMER |
893
+ | Everything else | SpanKind INTERNAL |
894
+ | `started_at` / `ended_at` | `startTimeUnixNano` / `endTimeUnixNano` |
895
+ | `status = "completed"` | StatusCode OK |
896
+ | `status = "failed"` | StatusCode ERROR |
897
+ | `parent_trace_id` | `parentSpanId` |
898
+ | `steps` | Span events (`name="step"`) |
899
+ | `errors` | Span events (`name="exception"`) |
900
+ | `inputs.*` | Span attributes `traceact.input.*` |
901
+ | `outputs.*` | Span attributes `traceact.output.*` |
902
+ | `touches` | Span attributes `traceact.touch.N.kind/target` |
903
+ | `trace_id`, `correlation_id`, `actor`, etc. | Span attributes `traceact.*` |
904
+ | Any unlisted scalar field | Span attribute `traceact.<field>` |
905
+
906
+ TraceAct IDs (`trc_...`) are hashed with MD5 to produce the 128-bit trace ID and 64-bit span ID that OTel requires. The original TraceAct IDs are always preserved as `traceact.trace_id` (and `traceact.root_trace_id`, `traceact.correlation_id`) span attributes so you can cross-reference them.
907
+
908
+ **Observable failures:** network errors, timeouts, and non-2xx responses from the collector are counted in `OtlpSink.failed`:
909
+
910
+ ```python
911
+ sink = OtlpSink("http://localhost:4318")
912
+ configure(sinks=[AsyncSink([sink])])
913
+
914
+ if sink.failed > 0:
915
+ logger.warning("OtlpSink: %d trace deliveries failed", sink.failed)
916
+ ```
917
+
691
918
  ### Fallback
692
919
 
693
920
  If no sinks are configured when a trace finishes, TraceAct falls back to `ConsoleSink()` so traces aren't silently dropped.
694
921
 
695
922
  ---
696
923
 
924
+ ## TraceLog
925
+
926
+ `TraceLog` is the programmatic query interface for TraceAct JSONL files. Use it when code — an AI agent, a test suite, or a background script — needs to read trace data without opening a browser.
927
+
928
+ ```python
929
+ from traceact import TraceLog
930
+
931
+ log = TraceLog("data/traces/traces.jsonl") # file or folder
932
+ ```
933
+
934
+ A folder source behaves the same as in the viewer: all `.jsonl` files inside it are merged on every read.
935
+
936
+ ### Filtering
937
+
938
+ `filter()` returns a **new** `TraceLog` — the original is never mutated.
939
+
940
+ ```python
941
+ failures = log.filter(status="failed")
942
+ db_traces = log.filter(kind="db")
943
+
944
+ # AND logic: both conditions must hold
945
+ recent_db_failures = log.filter(kind="db", status="failed")
946
+
947
+ # Chained calls are equivalent
948
+ same_thing = log.filter(kind="db").filter(status="failed")
949
+ ```
950
+
951
+ **Supported operators:**
952
+
953
+ | Syntax | Behaviour |
954
+ |---|---|
955
+ | `field=value` | Exact equality (case-sensitive) |
956
+ | `field__contains=value` | Case-insensitive substring |
957
+ | `field__startswith=value` | Case-insensitive prefix |
958
+ | `field__endswith=value` | Case-insensitive suffix |
959
+ | `field__re=pattern` | `re.search` — partial regex match |
960
+
961
+ ```python
962
+ log.filter(action__contains="order") # any action with "order"
963
+ log.filter(action__startswith="payment") # actions starting with "payment"
964
+ log.filter(action__re=r"^order\.(create|update)$")
965
+ log.filter(correlation_id="job_abc123") # find one background job's traces
966
+ ```
967
+
968
+ ### Terminal methods
969
+
970
+ ```python
971
+ log.filter(status="failed").all() # List[dict], oldest-first
972
+ log.filter(status="failed").last(10) # 10 most recent
973
+ log.filter(status="failed").first(10) # 10 oldest
974
+ log.filter(status="failed").count() # int
975
+
976
+ log.filter(status="failed").render_table() # pretty-print to stdout
977
+ log.filter(status="failed").render_table(n=25) # cap rows shown
978
+ ```
979
+
980
+ Each terminal call re-reads the JSONL file(s) — there is no caching, so you always get the current state of a live source.
981
+
982
+ ### Using TraceLog in tests
983
+
984
+ ```python
985
+ import pytest
986
+ from traceact import TraceLog, configure, reset_config, JsonlSink
987
+
988
+ def test_checkout_records_payment_touch(tmp_path):
989
+ sink_path = tmp_path / "traces.jsonl"
990
+ configure(sinks=[JsonlSink(str(sink_path))])
991
+
992
+ # run the function under test
993
+ checkout(user_id="u_42", amount=99.00)
994
+
995
+ log = TraceLog(str(sink_path))
996
+ traces = log.filter(action="checkout").all()
997
+
998
+ assert len(traces) == 1
999
+ touch_kinds = [t["kind"] for t in traces[0].get("touches", [])]
1000
+ assert "payment" in touch_kinds
1001
+
1002
+ reset_config()
1003
+ ```
1004
+
1005
+ ### TraceLog.view() — shared lens
1006
+
1007
+ `view()` opens the viewer pre-filtered to match the `TraceLog`'s current filters. The viewer shows each active filter as a dismissable badge above the trace list — a human can remove any badge to widen the view, and the search box still works on top.
1008
+
1009
+ ```python
1010
+ # Open the viewer showing only failed traces
1011
+ TraceLog("data/traces/traces.jsonl").filter(status="failed").view()
1012
+
1013
+ # Open with multiple filters
1014
+ TraceLog("data/traces/traces.jsonl") \
1015
+ .filter(kind="db", status="failed") \
1016
+ .view()
1017
+
1018
+ # Get the URL without opening a browser (useful in CI or a script)
1019
+ url = log.filter(status="failed").view(open_browser=False)
1020
+ print(f"Open the viewer at: {url}")
1021
+ ```
1022
+
1023
+ The viewer is launched (or an existing instance is reused) and pointed at the same source file or folder that the `TraceLog` reads from. The viewer's normal behaviour when opened without `view()` is completely unchanged.
1024
+
1025
+ ---
1026
+
697
1027
  ## Budget configuration
698
1028
 
699
1029
  `TraceBudget` controls how much TraceAct records. When a limit is reached, `budget_hit` is set to `True` on the trace and recording stops. The wrapped function continues running normally.
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "traceact"
7
- version = "0.3.0"
7
+ version = "0.4.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"
@@ -0,0 +1,212 @@
1
+ # tests/test_async_sink.py
2
+ #
3
+ # Tests for AsyncSink — the background-thread sink wrapper.
4
+ #
5
+ # Every test creates a fresh AsyncSink and closes it explicitly so no worker
6
+ # threads leak between tests. The flush() call before assertions ensures the
7
+ # worker has processed all enqueued records.
8
+
9
+ import time
10
+ import threading
11
+ import pytest
12
+
13
+ from traceact import AsyncSink, JsonlSink, ConsoleSink
14
+
15
+
16
+ # ---------------------------------------------------------------------------
17
+ # A minimal in-memory sink for testing — captures records without touching disk.
18
+ # ---------------------------------------------------------------------------
19
+
20
+ class CaptureSink:
21
+ def __init__(self):
22
+ self.records = []
23
+ self._lock = threading.Lock()
24
+
25
+ def write(self, record):
26
+ with self._lock:
27
+ self.records.append(record)
28
+
29
+
30
+ class FailingSink:
31
+ """A sink whose write() always raises — to test that the worker survives."""
32
+ def write(self, record):
33
+ raise RuntimeError("deliberate failure")
34
+
35
+
36
+ # ---------------------------------------------------------------------------
37
+ # Basic write-through behaviour
38
+ # ---------------------------------------------------------------------------
39
+
40
+ class TestWriteThrough:
41
+ def test_records_reach_inner_sink(self):
42
+ inner = CaptureSink()
43
+ sink = AsyncSink([inner])
44
+ try:
45
+ sink.write({"trace_id": "trc_1", "action": "a"})
46
+ sink.write({"trace_id": "trc_2", "action": "b"})
47
+ sink.flush()
48
+ assert len(inner.records) == 2
49
+ finally:
50
+ sink.close()
51
+
52
+ def test_multiple_inner_sinks_all_receive_record(self):
53
+ a, b = CaptureSink(), CaptureSink()
54
+ sink = AsyncSink([a, b])
55
+ try:
56
+ sink.write({"trace_id": "trc_1", "action": "x"})
57
+ sink.flush()
58
+ assert len(a.records) == 1
59
+ assert len(b.records) == 1
60
+ finally:
61
+ sink.close()
62
+
63
+ def test_write_returns_immediately(self):
64
+ """write() must not block even with a slow inner sink."""
65
+ class SlowSink:
66
+ def write(self, record):
67
+ time.sleep(0.1)
68
+
69
+ sink = AsyncSink([SlowSink()])
70
+ try:
71
+ start = time.monotonic()
72
+ for _ in range(5):
73
+ sink.write({"trace_id": "trc_x", "action": "slow"})
74
+ elapsed = time.monotonic() - start
75
+ # All 5 writes should have returned in far less than 5×0.1 s.
76
+ assert elapsed < 0.2, f"write() blocked for {elapsed:.2f}s"
77
+ finally:
78
+ sink.close()
79
+
80
+ def test_empty_sinks_list_is_valid(self):
81
+ sink = AsyncSink([])
82
+ try:
83
+ sink.write({"trace_id": "trc_1", "action": "a"})
84
+ sink.flush() # should complete without error
85
+ finally:
86
+ sink.close()
87
+
88
+
89
+ # ---------------------------------------------------------------------------
90
+ # Worker lifecycle
91
+ # ---------------------------------------------------------------------------
92
+
93
+ class TestWorkerLifecycle:
94
+ def test_worker_not_started_until_first_write(self):
95
+ sink = AsyncSink([CaptureSink()])
96
+ try:
97
+ assert not sink._started
98
+ sink.write({"trace_id": "trc_1", "action": "a"})
99
+ assert sink._started
100
+ finally:
101
+ sink.close()
102
+
103
+ def test_close_is_idempotent(self):
104
+ sink = AsyncSink([CaptureSink()])
105
+ sink.write({"trace_id": "trc_1", "action": "a"})
106
+ sink.close()
107
+ sink.close() # second close must not raise
108
+
109
+ def test_flush_before_any_write_is_safe(self):
110
+ sink = AsyncSink([CaptureSink()])
111
+ try:
112
+ sink.flush() # no worker yet — must not raise or block
113
+ finally:
114
+ sink.close()
115
+
116
+ def test_records_flushed_on_close(self):
117
+ inner = CaptureSink()
118
+ sink = AsyncSink([inner])
119
+ sink.write({"trace_id": "trc_1", "action": "a"})
120
+ sink.close()
121
+ # After close(), the worker has drained the queue.
122
+ assert len(inner.records) == 1
123
+
124
+
125
+ # ---------------------------------------------------------------------------
126
+ # Backpressure policies
127
+ # ---------------------------------------------------------------------------
128
+
129
+ class TestBackpressurePolicies:
130
+ def test_drop_newest_never_blocks(self):
131
+ """With a full queue and drop_newest, write() returns without raising."""
132
+ sink = AsyncSink([CaptureSink()], max_queue=2, on_full="drop_newest")
133
+ try:
134
+ # Stuff the queue (worker may or may not have drained some).
135
+ for i in range(10):
136
+ sink.write({"trace_id": f"trc_{i}", "action": "a"})
137
+ # Should not raise or block.
138
+ finally:
139
+ sink.close()
140
+
141
+ def test_drop_newest_increments_dropped(self):
142
+ """Records dropped under drop_newest are counted."""
143
+ # Use a barrier to hold the worker so the queue fills predictably.
144
+ barrier = threading.Event()
145
+
146
+ class BlockingSink:
147
+ def write(self, record):
148
+ barrier.wait()
149
+
150
+ sink = AsyncSink([BlockingSink()], max_queue=1, on_full="drop_newest")
151
+ try:
152
+ # First write starts the worker; the worker blocks on barrier.
153
+ sink.write({"trace_id": "trc_0", "action": "first"})
154
+ time.sleep(0.05) # let the worker pick up the first record
155
+
156
+ # Queue is now empty but worker is blocked. Fill it.
157
+ sink.write({"trace_id": "trc_1", "action": "queued"})
158
+ # This one should be dropped (queue is full).
159
+ sink.write({"trace_id": "trc_2", "action": "overflow"})
160
+
161
+ assert sink.dropped >= 1
162
+ finally:
163
+ barrier.set()
164
+ sink.close()
165
+
166
+ def test_drop_oldest_policy_accepted(self):
167
+ sink = AsyncSink([CaptureSink()], max_queue=2, on_full="drop_oldest")
168
+ try:
169
+ for i in range(5):
170
+ sink.write({"trace_id": f"trc_{i}", "action": "a"})
171
+ finally:
172
+ sink.close()
173
+
174
+ def test_block_policy_accepted(self):
175
+ sink = AsyncSink([CaptureSink()], max_queue=10, on_full="block")
176
+ try:
177
+ sink.write({"trace_id": "trc_1", "action": "a"})
178
+ sink.flush()
179
+ finally:
180
+ sink.close()
181
+
182
+ def test_invalid_on_full_raises(self):
183
+ with pytest.raises(ValueError, match="on_full must be"):
184
+ AsyncSink([CaptureSink()], on_full="discard")
185
+
186
+
187
+ # ---------------------------------------------------------------------------
188
+ # Fault tolerance
189
+ # ---------------------------------------------------------------------------
190
+
191
+ class TestFaultTolerance:
192
+ def test_failing_inner_sink_does_not_crash_worker(self):
193
+ """A sink that always raises must not kill the worker or lose other records."""
194
+ good = CaptureSink()
195
+ sink = AsyncSink([FailingSink(), good])
196
+ try:
197
+ sink.write({"trace_id": "trc_1", "action": "a"})
198
+ sink.write({"trace_id": "trc_2", "action": "b"})
199
+ sink.flush()
200
+ # The worker survived — good sink received both records.
201
+ assert len(good.records) == 2
202
+ finally:
203
+ sink.close()
204
+
205
+
206
+ # ---------------------------------------------------------------------------
207
+ # Exported from public API
208
+ # ---------------------------------------------------------------------------
209
+
210
+ def test_async_sink_importable_from_top_level():
211
+ from traceact import AsyncSink as AS
212
+ assert AS is AsyncSink