traceact 0.3.0__tar.gz → 0.5.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 (49) hide show
  1. {traceact-0.3.0/traceact.egg-info → traceact-0.5.0}/PKG-INFO +15 -2
  2. {traceact-0.3.0 → traceact-0.5.0}/README.md +9 -1
  3. {traceact-0.3.0 → traceact-0.5.0}/USAGE.md +504 -7
  4. {traceact-0.3.0 → traceact-0.5.0}/pyproject.toml +17 -1
  5. traceact-0.5.0/tests/test_async_sink.py +212 -0
  6. traceact-0.5.0/tests/test_http_sink.py +148 -0
  7. traceact-0.5.0/tests/test_otlp_sink.py +464 -0
  8. traceact-0.5.0/tests/test_propagation.py +873 -0
  9. {traceact-0.3.0 → traceact-0.5.0}/tests/test_redaction.py +1 -1
  10. traceact-0.5.0/tests/test_sqlite_sink.py +172 -0
  11. traceact-0.5.0/tests/test_tracelog.py +466 -0
  12. traceact-0.5.0/traceact/__init__.py +112 -0
  13. {traceact-0.3.0 → traceact-0.5.0}/traceact/config.py +12 -0
  14. traceact-0.5.0/traceact/log.py +421 -0
  15. traceact-0.5.0/traceact/middleware.py +225 -0
  16. traceact-0.5.0/traceact/propagation.py +267 -0
  17. {traceact-0.3.0 → traceact-0.5.0}/traceact/redaction.py +12 -0
  18. traceact-0.5.0/traceact/sinks.py +1301 -0
  19. {traceact-0.3.0 → traceact-0.5.0}/traceact/trace.py +37 -0
  20. {traceact-0.3.0 → traceact-0.5.0}/traceact/viewer/static/app.js +207 -4
  21. {traceact-0.3.0 → traceact-0.5.0}/traceact/viewer/static/index.html +24 -4
  22. {traceact-0.3.0 → traceact-0.5.0}/traceact/viewer/static/styles.css +79 -6
  23. {traceact-0.3.0 → traceact-0.5.0/traceact.egg-info}/PKG-INFO +15 -2
  24. {traceact-0.3.0 → traceact-0.5.0}/traceact.egg-info/SOURCES.txt +9 -0
  25. traceact-0.5.0/traceact.egg-info/requires.txt +9 -0
  26. traceact-0.3.0/traceact/__init__.py +0 -51
  27. traceact-0.3.0/traceact/sinks.py +0 -520
  28. traceact-0.3.0/traceact.egg-info/requires.txt +0 -4
  29. {traceact-0.3.0 → traceact-0.5.0}/LICENSE +0 -0
  30. {traceact-0.3.0 → traceact-0.5.0}/MANIFEST.in +0 -0
  31. {traceact-0.3.0 → traceact-0.5.0}/setup.cfg +0 -0
  32. {traceact-0.3.0 → traceact-0.5.0}/tests/test_decorators.py +0 -0
  33. {traceact-0.3.0 → traceact-0.5.0}/tests/test_doctor.py +0 -0
  34. {traceact-0.3.0 → traceact-0.5.0}/tests/test_reader.py +0 -0
  35. {traceact-0.3.0 → traceact-0.5.0}/tests/test_sinks.py +0 -0
  36. {traceact-0.3.0 → traceact-0.5.0}/traceact/budget.py +0 -0
  37. {traceact-0.3.0 → traceact-0.5.0}/traceact/context.py +0 -0
  38. {traceact-0.3.0 → traceact-0.5.0}/traceact/decorators.py +0 -0
  39. {traceact-0.3.0 → traceact-0.5.0}/traceact/helpers.py +0 -0
  40. {traceact-0.3.0 → traceact-0.5.0}/traceact/ids.py +0 -0
  41. {traceact-0.3.0 → traceact-0.5.0}/traceact/viewer/__init__.py +0 -0
  42. {traceact-0.3.0 → traceact-0.5.0}/traceact/viewer/cli.py +0 -0
  43. {traceact-0.3.0 → traceact-0.5.0}/traceact/viewer/doctor.py +0 -0
  44. {traceact-0.3.0 → traceact-0.5.0}/traceact/viewer/instance.py +0 -0
  45. {traceact-0.3.0 → traceact-0.5.0}/traceact/viewer/reader.py +0 -0
  46. {traceact-0.3.0 → traceact-0.5.0}/traceact/viewer/server.py +0 -0
  47. {traceact-0.3.0 → traceact-0.5.0}/traceact.egg-info/dependency_links.txt +0 -0
  48. {traceact-0.3.0 → traceact-0.5.0}/traceact.egg-info/entry_points.txt +0 -0
  49. {traceact-0.3.0 → traceact-0.5.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.5.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
@@ -26,6 +26,11 @@ License-File: LICENSE
26
26
  Provides-Extra: dev
27
27
  Requires-Dist: pytest>=7.0; extra == "dev"
28
28
  Requires-Dist: pytest-asyncio>=0.21; extra == "dev"
29
+ Requires-Dist: pytest-randomly>=3.15; extra == "dev"
30
+ Requires-Dist: flask>=3.0; extra == "dev"
31
+ Requires-Dist: starlette>=0.36; extra == "dev"
32
+ Requires-Dist: django>=4.2; extra == "dev"
33
+ Requires-Dist: requests>=2.31; extra == "dev"
29
34
  Dynamic: license-file
30
35
 
31
36
  # TraceAct
@@ -143,7 +148,15 @@ Checks Python version, that `~/.traceact` is writable, whether a viewer is alrea
143
148
  | `Step` | A human-readable timeline marker within a trace |
144
149
  | `Event` | A structured operation: db, http, file, model, job, etc. |
145
150
  | `Touch` | A resource involved in the trace (auto-derived from events) |
146
- | `Sink` | Where completed traces are written (`JsonlSink`, `ConsoleSink`) |
151
+ | `Sink` | Where completed traces are written (`JsonlSink`, `ConsoleSink`, `AsyncSink`) |
152
+
153
+ ### Design principle: observable by choice, never forced blind
154
+
155
+ TraceAct exists to give you X-ray vision for your code. That means nothing TraceAct does itself should take that vision away.
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.
158
+
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.
147
160
 
148
161
  ## Wiring into a web app
149
162
 
@@ -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,9 +14,12 @@ 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
+ propagation.py — extract_trace_id, inject_headers, propagate context manager,
21
+ _INCOMING_TRACE_ID ContextVar for cross-service correlation
22
+ middleware.py — TraceActMiddleware (WSGI), TraceActASGIMiddleware (ASGI)
20
23
 
21
24
  viewer/
22
25
  cli.py — `traceact view` / `traceact show` / `traceact doctor` CLI entry point
@@ -52,12 +55,14 @@ tests/ — pytest suite (pip install -e ".[dev]" && pytest)
52
55
  13. [Background jobs and correlation IDs](#background-jobs-and-correlation-ids)
53
56
  14. [Input capture](#input-capture)
54
57
  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)
58
+ 16. [TraceLog](#tracelog)
59
+ 17. [Budget configuration](#budget-configuration)
60
+ 18. [TraceConfig fields](#traceconfig-fields)
61
+ 19. [Test isolation](#test-isolation)
62
+ 20. [Trace record schema](#trace-record-schema)
63
+ 21. [Viewing traces](#viewing-traces)
64
+ 22. [Integrating the viewer into your app](#integrating-the-viewer-into-your-app)
65
+ 23. [Distributed propagation](#distributed-propagation)
61
66
 
62
67
  ---
63
68
 
@@ -688,12 +693,341 @@ configure(sinks=[
688
693
  ])
689
694
  ```
690
695
 
696
+ ### AsyncSink
697
+
698
+ 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.
699
+
700
+ ```python
701
+ from traceact import AsyncSink, JsonlSink
702
+
703
+ configure(sinks=[
704
+ AsyncSink([JsonlSink("data/traces/traces.jsonl")])
705
+ ])
706
+ ```
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.
709
+
710
+ **Backpressure — and why drops are always observable:**
711
+
712
+ 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:
713
+
714
+ ```python
715
+ sink = AsyncSink([JsonlSink("traces.jsonl")])
716
+ configure(sinks=[sink])
717
+
718
+ # later, in a health check or periodic log:
719
+ if sink.dropped > 0:
720
+ logger.warning("AsyncSink dropped %d trace records (queue full)", sink.dropped)
721
+ ```
722
+
723
+ 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.
724
+
725
+ Three policies for when the queue is full:
726
+
727
+ | Policy | Behaviour | Use when |
728
+ |---|---|---|
729
+ | `"drop_newest"` (default) | Discard the incoming record; count it | Older in-flight records are more diagnostically useful |
730
+ | `"drop_oldest"` | Evict the oldest queued record to make room | Recent traces matter more than historical ones |
731
+ | `"block"` | Stall the calling thread until there is space | Zero loss is required and brief latency is acceptable |
732
+
733
+ ```python
734
+ AsyncSink([JsonlSink("traces.jsonl")], max_queue=50_000, on_full="drop_oldest")
735
+ ```
736
+
737
+ **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.
738
+
739
+ **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.
740
+
741
+ **Wrapping multiple sinks:**
742
+
743
+ ```python
744
+ AsyncSink([
745
+ JsonlSink("data/traces/traces.jsonl"),
746
+ ConsoleSink(pretty=False),
747
+ ])
748
+ ```
749
+
750
+ 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.
751
+
752
+ ### SqliteSink
753
+
754
+ 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.
755
+
756
+ ```python
757
+ from traceact import SqliteSink, configure
758
+
759
+ configure(sinks=[SqliteSink("data/traces.db")])
760
+ ```
761
+
762
+ **Custom table name:**
763
+
764
+ ```python
765
+ SqliteSink("data/traces.db", table="my_traces")
766
+ ```
767
+
768
+ **Querying traces directly from the database:**
769
+
770
+ ```python
771
+ import sqlite3, json
772
+
773
+ conn = sqlite3.connect("data/traces.db")
774
+
775
+ # All failures in the last hour:
776
+ rows = conn.execute("""
777
+ SELECT action, duration_ms, record
778
+ FROM traces
779
+ WHERE status = 'failed'
780
+ AND started_at > datetime('now', '-1 hour')
781
+ ORDER BY started_at DESC
782
+ """).fetchall()
783
+
784
+ for action, ms, raw in rows:
785
+ record = json.loads(raw)
786
+ print(f"{action} {ms}ms errors={record.get('errors')}")
787
+ ```
788
+
789
+ **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:
790
+
791
+ ```python
792
+ from traceact import AsyncSink, SqliteSink, configure
793
+
794
+ configure(sinks=[AsyncSink([SqliteSink("data/traces.db")])])
795
+ ```
796
+
797
+ **Write errors** are printed to stderr and never propagated to the caller — a database hiccup doesn't interrupt the traced function.
798
+
799
+ ### HttpSink
800
+
801
+ POSTs each finished trace as a JSON body to an HTTP or HTTPS endpoint. Uses stdlib `urllib` only — zero extra dependencies.
802
+
803
+ ```python
804
+ from traceact import HttpSink, AsyncSink, configure
805
+
806
+ configure(sinks=[
807
+ AsyncSink([HttpSink("https://collector.example.com/traces")])
808
+ ])
809
+ ```
810
+
811
+ **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.
812
+
813
+ **Custom headers** (API keys, auth tokens):
814
+
815
+ ```python
816
+ HttpSink(
817
+ "https://collector.example.com/traces",
818
+ headers={"Authorization": "Bearer <your-token>"},
819
+ )
820
+ ```
821
+
822
+ **Custom timeout** (default: 5 seconds):
823
+
824
+ ```python
825
+ HttpSink("https://collector.example.com/traces", timeout=2.0)
826
+ ```
827
+
828
+ **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:
829
+
830
+ ```python
831
+ sink = HttpSink("https://collector.example.com/traces")
832
+ configure(sinks=[AsyncSink([sink])])
833
+
834
+ # in a health check or periodic log:
835
+ if sink.failed > 0:
836
+ logger.warning("HttpSink: %d trace deliveries failed", sink.failed)
837
+ ```
838
+
839
+ ### OtlpSink
840
+
841
+ 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.
842
+
843
+ ```python
844
+ from traceact import OtlpSink, AsyncSink, configure
845
+
846
+ configure(sinks=[
847
+ AsyncSink([OtlpSink("http://localhost:4318")])
848
+ ])
849
+ ```
850
+
851
+ Point `endpoint` at your collector's OTLP HTTP receiver base URL (the standard port is 4318). TraceAct appends `/v1/traces` automatically.
852
+
853
+ **Always wrap in `AsyncSink` for production use** — each write makes a synchronous HTTP request.
854
+
855
+ **SaaS collectors (Honeycomb, Datadog, etc.):**
856
+
857
+ ```python
858
+ # Honeycomb
859
+ OtlpSink(
860
+ "https://api.honeycomb.io",
861
+ headers={"x-honeycomb-team": "<your-api-key>"},
862
+ )
863
+
864
+ # Datadog (OTLP agent receiver, default port 4318)
865
+ OtlpSink("http://localhost:4318")
866
+
867
+ # Grafana Cloud
868
+ OtlpSink(
869
+ "https://<your-instance>.grafana.net/otlp",
870
+ headers={"Authorization": "Basic <base64-encoded-credentials>"},
871
+ )
872
+ ```
873
+
874
+ **Service name and resource attributes:**
875
+
876
+ ```python
877
+ OtlpSink(
878
+ "http://localhost:4318",
879
+ resource_attributes={
880
+ "service.name": "orders-api",
881
+ "deployment.env": "production",
882
+ "service.version": "2.1.0",
883
+ },
884
+ )
885
+ ```
886
+
887
+ These appear as resource-level attributes on every span exported by this sink. `service.name` defaults to `"traceact"` if you don't set it.
888
+
889
+ **How TraceAct records map to OTel spans:**
890
+
891
+ | TraceAct field | OTel span field |
892
+ |---|---|
893
+ | `action` | Span name |
894
+ | `kind` (`db`, `http`, `cache`, `model`, `auth`, `payment`) | SpanKind CLIENT |
895
+ | `kind` (`queue`, `email`, `export`) | SpanKind PRODUCER |
896
+ | `kind` (`job`) | SpanKind CONSUMER |
897
+ | Everything else | SpanKind INTERNAL |
898
+ | `started_at` / `ended_at` | `startTimeUnixNano` / `endTimeUnixNano` |
899
+ | `status = "completed"` | StatusCode OK |
900
+ | `status = "failed"` | StatusCode ERROR |
901
+ | `parent_trace_id` | `parentSpanId` |
902
+ | `steps` | Span events (`name="step"`) |
903
+ | `errors` | Span events (`name="exception"`) |
904
+ | `inputs.*` | Span attributes `traceact.input.*` |
905
+ | `outputs.*` | Span attributes `traceact.output.*` |
906
+ | `touches` | Span attributes `traceact.touch.N.kind/target` |
907
+ | `trace_id`, `correlation_id`, `actor`, etc. | Span attributes `traceact.*` |
908
+ | Any unlisted scalar field | Span attribute `traceact.<field>` |
909
+
910
+ 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.
911
+
912
+ **Observable failures:** network errors, timeouts, and non-2xx responses from the collector are counted in `OtlpSink.failed`:
913
+
914
+ ```python
915
+ sink = OtlpSink("http://localhost:4318")
916
+ configure(sinks=[AsyncSink([sink])])
917
+
918
+ if sink.failed > 0:
919
+ logger.warning("OtlpSink: %d trace deliveries failed", sink.failed)
920
+ ```
921
+
691
922
  ### Fallback
692
923
 
693
924
  If no sinks are configured when a trace finishes, TraceAct falls back to `ConsoleSink()` so traces aren't silently dropped.
694
925
 
695
926
  ---
696
927
 
928
+ ## TraceLog
929
+
930
+ `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.
931
+
932
+ ```python
933
+ from traceact import TraceLog
934
+
935
+ log = TraceLog("data/traces/traces.jsonl") # file or folder
936
+ ```
937
+
938
+ A folder source behaves the same as in the viewer: all `.jsonl` files inside it are merged on every read.
939
+
940
+ ### Filtering
941
+
942
+ `filter()` returns a **new** `TraceLog` — the original is never mutated.
943
+
944
+ ```python
945
+ failures = log.filter(status="failed")
946
+ db_traces = log.filter(kind="db")
947
+
948
+ # AND logic: both conditions must hold
949
+ recent_db_failures = log.filter(kind="db", status="failed")
950
+
951
+ # Chained calls are equivalent
952
+ same_thing = log.filter(kind="db").filter(status="failed")
953
+ ```
954
+
955
+ **Supported operators:**
956
+
957
+ | Syntax | Behaviour |
958
+ |---|---|
959
+ | `field=value` | Exact equality (case-sensitive) |
960
+ | `field__contains=value` | Case-insensitive substring |
961
+ | `field__startswith=value` | Case-insensitive prefix |
962
+ | `field__endswith=value` | Case-insensitive suffix |
963
+ | `field__re=pattern` | `re.search` — partial regex match |
964
+
965
+ ```python
966
+ log.filter(action__contains="order") # any action with "order"
967
+ log.filter(action__startswith="payment") # actions starting with "payment"
968
+ log.filter(action__re=r"^order\.(create|update)$")
969
+ log.filter(correlation_id="job_abc123") # find one background job's traces
970
+ ```
971
+
972
+ ### Terminal methods
973
+
974
+ ```python
975
+ log.filter(status="failed").all() # List[dict], oldest-first
976
+ log.filter(status="failed").last(10) # 10 most recent
977
+ log.filter(status="failed").first(10) # 10 oldest
978
+ log.filter(status="failed").count() # int
979
+
980
+ log.filter(status="failed").render_table() # pretty-print to stdout
981
+ log.filter(status="failed").render_table(n=25) # cap rows shown
982
+ ```
983
+
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
+
986
+ ### Using TraceLog in tests
987
+
988
+ ```python
989
+ import pytest
990
+ from traceact import TraceLog, configure, reset_config, JsonlSink
991
+
992
+ def test_checkout_records_payment_touch(tmp_path):
993
+ sink_path = tmp_path / "traces.jsonl"
994
+ configure(sinks=[JsonlSink(str(sink_path))])
995
+
996
+ # run the function under test
997
+ checkout(user_id="u_42", amount=99.00)
998
+
999
+ log = TraceLog(str(sink_path))
1000
+ traces = log.filter(action="checkout").all()
1001
+
1002
+ assert len(traces) == 1
1003
+ touch_kinds = [t["kind"] for t in traces[0].get("touches", [])]
1004
+ assert "payment" in touch_kinds
1005
+
1006
+ reset_config()
1007
+ ```
1008
+
1009
+ ### TraceLog.view() — shared lens
1010
+
1011
+ `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.
1012
+
1013
+ ```python
1014
+ # Open the viewer showing only failed traces
1015
+ TraceLog("data/traces/traces.jsonl").filter(status="failed").view()
1016
+
1017
+ # Open with multiple filters
1018
+ TraceLog("data/traces/traces.jsonl") \
1019
+ .filter(kind="db", status="failed") \
1020
+ .view()
1021
+
1022
+ # Get the URL without opening a browser (useful in CI or a script)
1023
+ url = log.filter(status="failed").view(open_browser=False)
1024
+ print(f"Open the viewer at: {url}")
1025
+ ```
1026
+
1027
+ 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.
1028
+
1029
+ ---
1030
+
697
1031
  ## Budget configuration
698
1032
 
699
1033
  `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.
@@ -1134,6 +1468,165 @@ Because `launch_or_connect` starts the viewer on `127.0.0.1` by default, the ret
1134
1468
 
1135
1469
  ---
1136
1470
 
1471
+ ## Distributed propagation
1472
+
1473
+ When a traced action in Service A calls Service B, TraceAct links the two
1474
+ services' traces together with two separate HTTP headers — separate because
1475
+ they answer separate questions:
1476
+
1477
+ | Header | Sets on the receiving trace | Answers |
1478
+ |---|---|---|
1479
+ | `traceact-trace-id` | `upstream_trace_id` | "which trace in another service triggered me?" (causal lineage) |
1480
+ | `traceact-correlation-id` | `correlation_id` | "which wider workflow do I belong to?" (business grouping, passed through untouched) |
1481
+
1482
+ Keeping them separate matters: a trace can have an upstream parent in one
1483
+ service *and* belong to a correlation group that was assigned several hops
1484
+ earlier. Folding one into the other silently discards whichever one loses.
1485
+
1486
+ ### Outbound: stamp both headers
1487
+
1488
+ `inject_headers()` reads the active trace and stamps its `trace_id` (always)
1489
+ and its `correlation_id` (if set) onto an outbound headers dict. It never
1490
+ mutates the dict you pass in.
1491
+
1492
+ ```python
1493
+ import requests
1494
+ from traceact import inject_headers
1495
+
1496
+ with ActionTrace.start(action="order.submit", correlation_id="corr_wf_9f2a") as trace:
1497
+ headers = inject_headers({"Content-Type": "application/json"})
1498
+ requests.post("https://payments.internal/charge", json=payload, headers=headers)
1499
+ ```
1500
+
1501
+ Works the same with `httpx`, `urllib`, or any other HTTP client — `inject_headers`
1502
+ just returns a plain dict.
1503
+
1504
+ ### Inbound: extract the headers (manual)
1505
+
1506
+ Use the `propagate` context manager when you want explicit control, or when
1507
+ your framework isn't covered by the automatic middleware. Pass the framework's
1508
+ header object **directly** — `request.headers` works as-is on Flask, Django,
1509
+ FastAPI, and Starlette, and so does a plain dict in any casing:
1510
+
1511
+ ```python
1512
+ from traceact import propagate, ActionTrace
1513
+
1514
+ def handle_charge(request):
1515
+ with propagate(request.headers):
1516
+ with ActionTrace.start(action="charge.process") as trace:
1517
+ # trace.upstream_trace_id == Service A's trace_id
1518
+ # trace.correlation_id == whatever correlation Service A had
1519
+ ...
1520
+ ```
1521
+
1522
+ HTTP header names are case-insensitive and every framework reconstructs them
1523
+ differently (Flask/Werkzeug and Django hand back `Traceact-Trace-Id`,
1524
+ Title-Case; ASGI delivers raw lowercase bytes). `propagate()` and
1525
+ `extract_trace_id()`/`extract_correlation_id()` normalise all of that
1526
+ internally — pass the header object however your framework gives it to you,
1527
+ including `dict(request.headers)` if you already have that.
1528
+
1529
+ ### Inbound: automatic via middleware (Flask / Django)
1530
+
1531
+ ```python
1532
+ from traceact import TraceActMiddleware
1533
+
1534
+ # Flask
1535
+ app.wsgi_app = TraceActMiddleware(app.wsgi_app)
1536
+
1537
+ # Django (in wsgi.py)
1538
+ from django.core.wsgi import get_wsgi_application
1539
+ application = TraceActMiddleware(get_wsgi_application())
1540
+ ```
1541
+
1542
+ Reads both headers from the WSGI environ and applies them to every trace
1543
+ started during that request — including traces started while a streamed
1544
+ response body is generated (the context is held until the response is fully
1545
+ closed, not just until the view function returns). If neither header is
1546
+ present, the middleware is fully transparent.
1547
+
1548
+ ### Inbound: automatic via middleware (FastAPI / Starlette)
1549
+
1550
+ ```python
1551
+ from traceact import TraceActASGIMiddleware
1552
+ from fastapi import FastAPI
1553
+
1554
+ app = FastAPI()
1555
+ app.add_middleware(TraceActASGIMiddleware)
1556
+ ```
1557
+
1558
+ Or wrap manually:
1559
+
1560
+ ```python
1561
+ app = TraceActASGIMiddleware(app)
1562
+ ```
1563
+
1564
+ ### How the chain looks in the viewer and TraceLog
1565
+
1566
+ Both services write their own trace records. Query by whichever link answers
1567
+ your question — `upstream_trace_id` for "what did this specific call trigger",
1568
+ `correlation_id` for "show me the whole workflow":
1569
+
1570
+ ```python
1571
+ from traceact import TraceLog
1572
+
1573
+ log = TraceLog("traces.jsonl")
1574
+ log.filter(upstream_trace_id="trc_abc123").all() # traces this call triggered
1575
+ log.filter(correlation_id="corr_wf_9f2a").all() # the entire workflow
1576
+ log.filter(correlation_id="corr_wf_9f2a").view() # same, in the browser viewer
1577
+ ```
1578
+
1579
+ The inspector's summary card shows both when present, each in full (not
1580
+ shortened) so they can be copied and searched against another service's logs.
1581
+
1582
+ ### `ai_prompts` redaction preset
1583
+
1584
+ For AI pipelines where trace payloads must not store raw prompt text, model
1585
+ responses, or conversation history, enable the `ai_prompts` preset:
1586
+
1587
+ ```python
1588
+ configure(
1589
+ config=TraceConfig(redaction_presets=["ai_prompts"]),
1590
+ )
1591
+ ```
1592
+
1593
+ Redacted fields include: `raw_prompt`, `prompt_content`, `system_prompt`,
1594
+ `raw_response`, `response_content`, `conversation`, `message_content`,
1595
+ `file_content`, `source_excerpt`, `context_window`, `completion`,
1596
+ `generation`, `output_text`. Safe fields like `model`, `latency_ms`,
1597
+ `prompt_id`, and token counts are unaffected.
1598
+
1599
+ | What gets redacted | What stays |
1600
+ |---|---|
1601
+ | Raw prompts and responses | Model name, version |
1602
+ | System prompts | Token counts (as long as field name has no "token" substring) |
1603
+ | Conversation history | Latency, cost |
1604
+ | File and source excerpts | Trace IDs, correlation IDs |
1605
+
1606
+ Combine with other presets:
1607
+
1608
+ ```python
1609
+ TraceConfig(redaction_presets=["ai_prompts", "api_keys"])
1610
+ ```
1611
+
1612
+ ### Reference: headers
1613
+
1614
+ | Header | Outbound (inject) | Inbound (extract) |
1615
+ |---|---|---|
1616
+ | `traceact-trace-id` | Active `ActionTrace.trace_id` | Sets `upstream_trace_id` |
1617
+ | `traceact-correlation-id` | Active `ActionTrace.correlation_id`, if set | Sets `correlation_id` |
1618
+
1619
+ Accepted header collection types on the inbound side: any object with
1620
+ `.items()` (dict in any casing, Werkzeug/Django/Starlette header objects,
1621
+ `requests.CaseInsensitiveDict`), a list of `(name, value)` pairs, or raw ASGI
1622
+ `[(b"name", b"value")]` byte pairs. Matching is case-insensitive regardless of
1623
+ which form you pass.
1624
+
1625
+ WSGI: the header arrives as `HTTP_TRACEACT_TRACE_ID` in the environ.
1626
+ ASGI: the header arrives as bytes in `scope["headers"]`.
1627
+
1628
+ ---
1629
+
1137
1630
  ## Quick reference
1138
1631
 
1139
1632
  ```python
@@ -1146,5 +1639,9 @@ from traceact import (
1146
1639
  traced_action, # decorator
1147
1640
  JsonlSink, # write traces to a .jsonl file
1148
1641
  ConsoleSink, # print traces to stdout
1642
+ propagate, # context manager for inbound propagation
1643
+ inject_headers, # stamp outbound request headers
1644
+ TraceActMiddleware, # WSGI auto-propagation (Flask, Django)
1645
+ TraceActASGIMiddleware, # ASGI auto-propagation (FastAPI, Starlette)
1149
1646
  )
1150
1647
  ```
@@ -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.5.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"
@@ -35,6 +35,22 @@ dependencies = []
35
35
  dev = [
36
36
  "pytest>=7.0",
37
37
  "pytest-asyncio>=0.21",
38
+ # Test-order randomisation. Activates automatically once installed — no
39
+ # flag needed. Kept on deliberately: an order-dependent failure means
40
+ # shared/global state is leaking between tests, which is a real bug.
41
+ # Never pin --randomly-seed in CI to "stabilise" a run; that hides exactly
42
+ # the failure mode this plugin exists to catch.
43
+ "pytest-randomly>=3.15",
44
+ # Real framework objects for propagation tests (extract_trace_id,
45
+ # TraceActMiddleware, TraceActASGIMiddleware). These are test-only —
46
+ # traceact itself has zero runtime dependencies. Testing against a
47
+ # hand-built dict instead of an actual Flask/Django/Starlette request
48
+ # object was exactly the gap that let a header-parsing bug through
49
+ # 43 green tests; these are here so that never happens again.
50
+ "flask>=3.0",
51
+ "starlette>=0.36",
52
+ "django>=4.2",
53
+ "requests>=2.31",
38
54
  ]
39
55
 
40
56
  [project.urls]