rule-engine-core 0.0.4__tar.gz → 0.1.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 (26) hide show
  1. {rule_engine_core-0.0.4 → rule_engine_core-0.1.0}/PKG-INFO +1 -1
  2. {rule_engine_core-0.0.4 → rule_engine_core-0.1.0}/rule_engine_core/metadata_store.py +6 -1
  3. rule_engine_core-0.1.0/rule_engine_core/tracing.py +300 -0
  4. {rule_engine_core-0.0.4 → rule_engine_core-0.1.0}/rule_engine_core.egg-info/PKG-INFO +1 -1
  5. {rule_engine_core-0.0.4 → rule_engine_core-0.1.0}/rule_engine_core.egg-info/SOURCES.txt +3 -1
  6. {rule_engine_core-0.0.4 → rule_engine_core-0.1.0}/setup.py +2 -2
  7. rule_engine_core-0.1.0/tests/test_tracing.py +219 -0
  8. {rule_engine_core-0.0.4 → rule_engine_core-0.1.0}/README.md +0 -0
  9. {rule_engine_core-0.0.4 → rule_engine_core-0.1.0}/pyproject.toml +0 -0
  10. {rule_engine_core-0.0.4 → rule_engine_core-0.1.0}/rule_engine_core/__init__.py +0 -0
  11. {rule_engine_core-0.0.4 → rule_engine_core-0.1.0}/rule_engine_core/db_pool.py +0 -0
  12. {rule_engine_core-0.0.4 → rule_engine_core-0.1.0}/rule_engine_core/entity_base.py +0 -0
  13. {rule_engine_core-0.0.4 → rule_engine_core-0.1.0}/rule_engine_core/entity_dao.py +0 -0
  14. {rule_engine_core-0.0.4 → rule_engine_core-0.1.0}/rule_engine_core/entity_types.py +0 -0
  15. {rule_engine_core-0.0.4 → rule_engine_core-0.1.0}/rule_engine_core/filter.py +0 -0
  16. {rule_engine_core-0.0.4 → rule_engine_core-0.1.0}/rule_engine_core/metadata_dao.py +0 -0
  17. {rule_engine_core-0.0.4 → rule_engine_core-0.1.0}/rule_engine_core/parser.py +0 -0
  18. {rule_engine_core-0.0.4 → rule_engine_core-0.1.0}/rule_engine_core/rule.py +0 -0
  19. {rule_engine_core-0.0.4 → rule_engine_core-0.1.0}/rule_engine_core.egg-info/dependency_links.txt +0 -0
  20. {rule_engine_core-0.0.4 → rule_engine_core-0.1.0}/rule_engine_core.egg-info/requires.txt +0 -0
  21. {rule_engine_core-0.0.4 → rule_engine_core-0.1.0}/rule_engine_core.egg-info/top_level.txt +0 -0
  22. {rule_engine_core-0.0.4 → rule_engine_core-0.1.0}/setup.cfg +0 -0
  23. {rule_engine_core-0.0.4 → rule_engine_core-0.1.0}/tests/test_metadata_dao.py +0 -0
  24. {rule_engine_core-0.0.4 → rule_engine_core-0.1.0}/tests/test_metadata_store_api_fetch.py +0 -0
  25. {rule_engine_core-0.0.4 → rule_engine_core-0.1.0}/tests/test_rule_creation_storage.py +0 -0
  26. {rule_engine_core-0.0.4 → rule_engine_core-0.1.0}/tests/test_rule_evaluation.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: rule-engine-core
3
- Version: 0.0.4
3
+ Version: 0.1.0
4
4
  Summary: A rule engine core library for Python.
5
5
  Home-page: https://github.com/temp-noob/rule-engine
6
6
  Author: Mohit Tripathi
@@ -13,6 +13,8 @@ import logging
13
13
  import os
14
14
  from urllib import error, request
15
15
 
16
+ from rule_engine_core.tracing import trace_headers
17
+
16
18
  DEFAULT_METADATA_SERVICE_URL = "http://localhost:8000"
17
19
  DEFAULT_METADATA_SERVICE_TIMEOUT_SECONDS = 3
18
20
 
@@ -40,8 +42,11 @@ class MetadataStore:
40
42
  os.getenv("METADATA_SERVICE_TIMEOUT_SECONDS", str(DEFAULT_METADATA_SERVICE_TIMEOUT_SECONDS))
41
43
  )
42
44
  get_all_metadata_url = f"{metadata_service_base_url}/metadata"
45
+ # Forward the trace id so metadata_service logs this fetch under the
46
+ # same trace as the recalc/eval cycle that triggered it.
47
+ metadata_req = request.Request(get_all_metadata_url, headers=trace_headers())
43
48
  try:
44
- with request.urlopen(get_all_metadata_url, timeout=metadata_service_timeout_seconds) as response:
49
+ with request.urlopen(metadata_req, timeout=metadata_service_timeout_seconds) as response:
45
50
  if response.status != 200:
46
51
  raise RuntimeError(
47
52
  f"Failed to fetch metadata from service: {get_all_metadata_url} returned status {response.status}"
@@ -0,0 +1,300 @@
1
+ """
2
+ Distributed tracing primitives shared by every service in the monorepo.
3
+
4
+ The model is deliberately lightweight (stdlib only, no OpenTelemetry): a single
5
+ ``trace_id`` string that
6
+
7
+ * is read from / written to the ``X-Trace-Id`` HTTP header at service edges,
8
+ * lives inside a process in a :class:`contextvars.ContextVar`,
9
+ * is stamped onto every log line via a log-record factory, and
10
+ * is explicitly re-bound whenever work crosses a thread / pool / event-loop
11
+ boundary (a ``ContextVar`` does not cross those on its own).
12
+
13
+ Why ``contextvars`` rather than ``threading.local`` or an argument threaded
14
+ through every call: it is the one primitive that works for FastAPI's "sync
15
+ endpoints run in an anyio worker thread" model *and* for async code, and it
16
+ exposes ``copy_context()`` — the snapshot we hand to child threads/pools.
17
+
18
+ Typical wiring per service:
19
+
20
+ # main.py — before uvicorn.run
21
+ configure_logging(os.getenv("...LOG_LEVEL", "INFO"), filename=...)
22
+
23
+ # api.py — inside generate_fastapi_app(), after the app is built
24
+ install_tracing(app)
25
+
26
+ # background loop body — one fresh trace per unit of work
27
+ with new_trace_scope():
28
+ do_one_cycle()
29
+
30
+ # fan-out — workers inherit the submitter's trace
31
+ with TracedThreadPoolExecutor(max_workers=n) as pool:
32
+ pool.submit(work, ...)
33
+
34
+ # outbound HTTP — carry the trace to the next hop
35
+ requests.get(url, headers=trace_headers())
36
+ """
37
+ from __future__ import annotations
38
+
39
+ import asyncio
40
+ import concurrent.futures
41
+ import contextvars
42
+ import logging
43
+ import uuid
44
+ import threading
45
+ from contextlib import contextmanager
46
+ from typing import Any, Callable, Iterable, Iterator, Optional
47
+
48
+ __all__ = [
49
+ "TRACE_ID_HEADER",
50
+ "LOG_FORMAT",
51
+ "get_trace_id",
52
+ "new_trace_id",
53
+ "set_trace_id",
54
+ "reset_trace_id",
55
+ "bind_trace_id",
56
+ "new_trace_scope",
57
+ "install_trace_log_factory",
58
+ "configure_logging",
59
+ "TraceContextMiddleware",
60
+ "install_tracing",
61
+ "TracedThread",
62
+ "TracedThreadPoolExecutor",
63
+ "run_coro_threadsafe_with_trace",
64
+ "trace_headers",
65
+ ]
66
+
67
+ # The header the trace id rides in across service boundaries. Mirrors the
68
+ # existing X-User-Id convention platform_service already uses.
69
+ TRACE_ID_HEADER = "X-Trace-Id"
70
+
71
+ # When no trace is bound (e.g. a log line emitted at import time) records still
72
+ # need a value so a "%(trace_id)s" format can never KeyError.
73
+ _NO_TRACE = "-"
74
+
75
+ _trace_id_var: contextvars.ContextVar[Optional[str]] = contextvars.ContextVar(
76
+ "trace_id", default=None
77
+ )
78
+
79
+
80
+ # ─── context primitives ──────────────────────────────────────────────────────
81
+
82
+ def get_trace_id() -> Optional[str]:
83
+ """The trace id bound to the current context, or ``None`` if none is bound."""
84
+ return _trace_id_var.get()
85
+
86
+
87
+ def new_trace_id() -> str:
88
+ """A fresh, opaque trace id (uuid4 hex — 32 chars, no dashes)."""
89
+ return uuid.uuid4().hex
90
+
91
+
92
+ def set_trace_id(trace_id: Optional[str]) -> contextvars.Token:
93
+ """Low-level: bind ``trace_id`` and return the token needed to restore the
94
+ previous value. Prefer :func:`bind_trace_id` / :func:`new_trace_scope`."""
95
+ return _trace_id_var.set(trace_id)
96
+
97
+
98
+ def reset_trace_id(token: contextvars.Token) -> None:
99
+ """Restore the trace id captured in ``token`` (from :func:`set_trace_id`)."""
100
+ _trace_id_var.reset(token)
101
+
102
+
103
+ @contextmanager
104
+ def bind_trace_id(trace_id: Optional[str] = None) -> Iterator[str]:
105
+ """Bind ``trace_id`` for the duration of the block, restoring the previous
106
+ value on exit. A new id is generated when ``trace_id`` is falsy, so this
107
+ doubles as "adopt the incoming id, or start one if there is none"."""
108
+ resolved = trace_id or new_trace_id()
109
+ token = _trace_id_var.set(resolved)
110
+ try:
111
+ yield resolved
112
+ finally:
113
+ _trace_id_var.reset(token)
114
+
115
+
116
+ @contextmanager
117
+ def new_trace_scope() -> Iterator[str]:
118
+ """Start a *fresh* trace for a self-contained unit of work — the right tool
119
+ for background loops (a recalc cycle, a poll tick, an evaluation round) whose
120
+ work is not tied to any inbound request."""
121
+ with bind_trace_id(new_trace_id()) as trace_id:
122
+ yield trace_id
123
+
124
+
125
+ # ─── logging integration ─────────────────────────────────────────────────────
126
+
127
+ # Shared format so all four services emit the trace id identically. A service may
128
+ # pass its own format to configure_logging as long as it references
129
+ # "%(trace_id)s".
130
+ LOG_FORMAT = "%(asctime)s %(levelname)s [trace=%(trace_id)s] %(name)s:%(lineno)d %(message)s"
131
+
132
+ _log_factory_installed = False
133
+
134
+
135
+ def install_trace_log_factory() -> None:
136
+ """Wrap the global log-record factory so *every* LogRecord carries a
137
+ ``trace_id`` attribute (the bound id, or "-").
138
+
139
+ Using the record factory rather than a handler/logger ``Filter`` means
140
+ records from third-party loggers (uvicorn, etc.) also get the attribute, so a
141
+ format string containing "%(trace_id)s" can never KeyError regardless of
142
+ which logger emitted the record. Idempotent."""
143
+ global _log_factory_installed
144
+ if _log_factory_installed:
145
+ return
146
+ previous_factory = logging.getLogRecordFactory()
147
+
148
+ def factory(*args: Any, **kwargs: Any) -> logging.LogRecord:
149
+ record = previous_factory(*args, **kwargs)
150
+ record.trace_id = get_trace_id() or _NO_TRACE
151
+ return record
152
+
153
+ logging.setLogRecordFactory(factory)
154
+ _log_factory_installed = True
155
+
156
+
157
+ def configure_logging(
158
+ level: Any = None,
159
+ *,
160
+ filename: Optional[str] = None,
161
+ format: str = LOG_FORMAT,
162
+ force: bool = False,
163
+ ) -> None:
164
+ """Install the trace record factory and configure root logging in one call —
165
+ a drop-in for the per-service ``logging.basicConfig(...)`` that also adds the
166
+ trace id to the format. ``level`` accepts a name ("INFO") or an int; ``None``
167
+ means INFO."""
168
+ install_trace_log_factory()
169
+ kwargs: dict[str, Any] = {
170
+ "format": format,
171
+ "level": level if level is not None else logging.INFO,
172
+ }
173
+ if filename:
174
+ kwargs["filename"] = filename
175
+ if force:
176
+ kwargs["force"] = True
177
+ logging.basicConfig(**kwargs)
178
+
179
+
180
+ # ─── FastAPI / ASGI ingress ──────────────────────────────────────────────────
181
+
182
+ def _header_value(headers: Iterable[tuple[bytes, bytes]], name: str) -> Optional[str]:
183
+ """Case-insensitive lookup in an ASGI raw-header list (list of (name, value)
184
+ byte tuples; names are already lowercased by the server)."""
185
+ target = name.lower().encode("latin-1")
186
+ for key, value in headers:
187
+ if key.lower() == target:
188
+ return value.decode("latin-1")
189
+ return None
190
+
191
+
192
+ class TraceContextMiddleware:
193
+ """Pure-ASGI middleware that establishes the trace context for each request.
194
+
195
+ On every HTTP request it adopts an incoming ``X-Trace-Id`` header (or mints a
196
+ fresh id when absent), binds it into the context for the life of the request
197
+ — so all handler logs and any downstream calls carry it — and echoes it on
198
+ the response so the caller / next hop can correlate.
199
+
200
+ Deliberately pure ASGI rather than Starlette's ``BaseHTTPMiddleware``, which
201
+ has known ContextVar-scoping quirks (a var set in ``dispatch`` is not
202
+ reliably visible back out). Here we ``set`` the var before awaiting the app
203
+ and ``reset`` it after, which also reaches sync endpoints: FastAPI runs
204
+ ``def`` handlers via anyio's ``to_thread.run_sync``, which copies the current
205
+ context into the worker thread."""
206
+
207
+ def __init__(self, app: Any) -> None:
208
+ self.app = app
209
+
210
+ async def __call__(self, scope: dict, receive: Callable, send: Callable) -> None:
211
+ if scope.get("type") != "http":
212
+ await self.app(scope, receive, send)
213
+ return
214
+
215
+ incoming = _header_value(scope.get("headers") or [], TRACE_ID_HEADER)
216
+ trace_id = incoming or new_trace_id()
217
+ token = set_trace_id(trace_id)
218
+
219
+ header_pair = (TRACE_ID_HEADER.encode("latin-1"), trace_id.encode("latin-1"))
220
+
221
+ async def send_with_trace_header(message: dict) -> None:
222
+ if message["type"] == "http.response.start":
223
+ message.setdefault("headers", []).append(header_pair)
224
+ await send(message)
225
+
226
+ try:
227
+ await self.app(scope, receive, send_with_trace_header)
228
+ finally:
229
+ reset_trace_id(token)
230
+
231
+
232
+ def install_tracing(app: Any) -> None:
233
+ """Add :class:`TraceContextMiddleware` to a FastAPI/Starlette app. Call once,
234
+ inside the app factory, after the app has been created."""
235
+ app.add_middleware(TraceContextMiddleware)
236
+
237
+
238
+ # ─── thread / pool / event-loop propagation ──────────────────────────────────
239
+
240
+ class TracedThread(threading.Thread):
241
+ """``threading.Thread`` that runs its target inside a snapshot of the
242
+ spawning thread's context, so the trace id (and any other ContextVars) cross
243
+ the thread boundary. Drop-in replacement — the snapshot is taken at
244
+ construction, the moment the parent's context is meaningful for a thread
245
+ spawned to continue the parent's work."""
246
+
247
+ def __init__(self, *args: Any, **kwargs: Any) -> None:
248
+ super().__init__(*args, **kwargs)
249
+ self._trace_context = contextvars.copy_context()
250
+
251
+ def run(self) -> None:
252
+ self._trace_context.run(super().run)
253
+
254
+
255
+ class TracedThreadPoolExecutor(concurrent.futures.ThreadPoolExecutor):
256
+ """``ThreadPoolExecutor`` whose workers run each task inside a snapshot of
257
+ the submitter's context. The snapshot is taken at ``submit`` time — not at
258
+ construction — because pools are typically built once at boot (no trace
259
+ bound) and only used later, from within a traced scope."""
260
+
261
+ def submit(self, fn: Callable, *args: Any, **kwargs: Any): # type: ignore[override]
262
+ ctx = contextvars.copy_context()
263
+ return super().submit(lambda: ctx.run(fn, *args, **kwargs))
264
+
265
+
266
+ def run_coro_threadsafe_with_trace(coro, loop: asyncio.AbstractEventLoop):
267
+ """Like :func:`asyncio.run_coroutine_threadsafe`, but re-binds the *current*
268
+ trace id inside the coroutine — which runs on ``loop``'s own thread.
269
+
270
+ ``run_coroutine_threadsafe`` does not copy the caller's context into the loop
271
+ thread, so a coroutine scheduled onto a background event loop (e.g.
272
+ position_server's security-update loop) would otherwise log with no trace. We
273
+ capture the id here and re-set it inside a wrapper coroutine, so the id — and
274
+ anything the coroutine awaits or spawns — is traced."""
275
+ trace_id = get_trace_id()
276
+
277
+ async def _traced():
278
+ token = set_trace_id(trace_id)
279
+ try:
280
+ return await coro
281
+ finally:
282
+ reset_trace_id(token)
283
+
284
+ return asyncio.run_coroutine_threadsafe(_traced(), loop)
285
+
286
+
287
+ # ─── outbound HTTP egress ────────────────────────────────────────────────────
288
+
289
+ def trace_headers(existing: Optional[dict] = None) -> dict:
290
+ """Return request headers with ``X-Trace-Id`` added when a trace is bound.
291
+
292
+ Merges onto ``existing`` (copied, never mutated). Works for ``requests``
293
+ (``headers=trace_headers()``), ``aiohttp`` (same), and ``urllib``
294
+ (``request.Request(url, headers=trace_headers())``). When no trace is bound
295
+ the header is omitted, so it is safe to call unconditionally."""
296
+ headers = dict(existing) if existing else {}
297
+ trace_id = get_trace_id()
298
+ if trace_id:
299
+ headers[TRACE_ID_HEADER] = trace_id
300
+ return headers
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: rule-engine-core
3
- Version: 0.0.4
3
+ Version: 0.1.0
4
4
  Summary: A rule engine core library for Python.
5
5
  Home-page: https://github.com/temp-noob/rule-engine
6
6
  Author: Mohit Tripathi
@@ -11,6 +11,7 @@ rule_engine_core/metadata_dao.py
11
11
  rule_engine_core/metadata_store.py
12
12
  rule_engine_core/parser.py
13
13
  rule_engine_core/rule.py
14
+ rule_engine_core/tracing.py
14
15
  rule_engine_core.egg-info/PKG-INFO
15
16
  rule_engine_core.egg-info/SOURCES.txt
16
17
  rule_engine_core.egg-info/dependency_links.txt
@@ -19,4 +20,5 @@ rule_engine_core.egg-info/top_level.txt
19
20
  tests/test_metadata_dao.py
20
21
  tests/test_metadata_store_api_fetch.py
21
22
  tests/test_rule_creation_storage.py
22
- tests/test_rule_evaluation.py
23
+ tests/test_rule_evaluation.py
24
+ tests/test_tracing.py
@@ -13,8 +13,8 @@ def parse_requirements(filename):
13
13
  return requirements
14
14
  path = os.path.join(os.getenv('BUILD_PATH'), "requirements.txt")
15
15
  setup(
16
- name="rule-engine-core",
17
- version="0.0.4",
16
+ name="rule-engine-core",
17
+ version="0.1.0",
18
18
  author="Mohit Tripathi",
19
19
  author_email="tripathimohit051@gmail.com",
20
20
  description="A rule engine core library for Python.",
@@ -0,0 +1,219 @@
1
+ """
2
+ Unit tests for rule_engine_core.tracing.
3
+
4
+ Deliberately stdlib-only (no DB, no starlette/httpx) so they run fast and in
5
+ isolation — the ASGI middleware is driven directly with a fake scope/send.
6
+ """
7
+ import asyncio
8
+ import logging
9
+ import threading
10
+
11
+ from rule_engine_core.tracing import (
12
+ TRACE_ID_HEADER,
13
+ TraceContextMiddleware,
14
+ TracedThread,
15
+ TracedThreadPoolExecutor,
16
+ bind_trace_id,
17
+ get_trace_id,
18
+ install_trace_log_factory,
19
+ new_trace_id,
20
+ new_trace_scope,
21
+ run_coro_threadsafe_with_trace,
22
+ trace_headers,
23
+ )
24
+
25
+
26
+ # ─── context primitives ──────────────────────────────────────────────────────
27
+
28
+ def test_no_trace_bound_by_default():
29
+ assert get_trace_id() is None
30
+
31
+
32
+ def test_bind_trace_id_sets_and_restores():
33
+ with bind_trace_id("abc"):
34
+ assert get_trace_id() == "abc"
35
+ assert get_trace_id() is None
36
+
37
+
38
+ def test_bind_trace_id_generates_when_none():
39
+ with bind_trace_id() as tid:
40
+ assert tid and get_trace_id() == tid
41
+ assert len(tid) == 32 # uuid4 hex
42
+ assert get_trace_id() is None
43
+
44
+
45
+ def test_new_trace_scope_is_fresh_each_time():
46
+ with new_trace_scope() as a:
47
+ pass
48
+ with new_trace_scope() as b:
49
+ pass
50
+ assert a != b
51
+
52
+
53
+ def test_new_trace_id_unique():
54
+ assert new_trace_id() != new_trace_id()
55
+
56
+
57
+ # ─── logging ─────────────────────────────────────────────────────────────────
58
+
59
+ def test_log_record_carries_trace_id():
60
+ install_trace_log_factory()
61
+ factory = logging.getLogRecordFactory()
62
+
63
+ def make_record():
64
+ return factory("logger", logging.INFO, "path", 10, "msg", None, None)
65
+
66
+ with bind_trace_id("log-trace"):
67
+ assert make_record().trace_id == "log-trace"
68
+ # Outside any scope the placeholder keeps "%(trace_id)s" from KeyError-ing.
69
+ assert make_record().trace_id == "-"
70
+
71
+
72
+ # ─── outbound egress ─────────────────────────────────────────────────────────
73
+
74
+ def test_trace_headers_absent_when_unbound():
75
+ assert trace_headers() == {}
76
+ assert trace_headers({"A": "b"}) == {"A": "b"}
77
+
78
+
79
+ def test_trace_headers_present_and_merged_when_bound():
80
+ with bind_trace_id("h"):
81
+ assert trace_headers()[TRACE_ID_HEADER] == "h"
82
+ merged = trace_headers({"A": "b"})
83
+ assert merged == {"A": "b", TRACE_ID_HEADER: "h"}
84
+
85
+
86
+ def test_trace_headers_does_not_mutate_input():
87
+ original = {"A": "b"}
88
+ with bind_trace_id("h"):
89
+ trace_headers(original)
90
+ assert original == {"A": "b"}
91
+
92
+
93
+ # ─── thread / pool propagation ───────────────────────────────────────────────
94
+
95
+ def test_traced_thread_propagates_trace():
96
+ seen = {}
97
+ with bind_trace_id("thread-trace"):
98
+ t = TracedThread(target=lambda: seen.__setitem__("v", get_trace_id()))
99
+ t.start()
100
+ t.join()
101
+ assert seen["v"] == "thread-trace"
102
+
103
+
104
+ def test_plain_thread_does_not_propagate():
105
+ # Documents *why* TracedThread exists: a vanilla thread starts with a fresh
106
+ # context and sees no trace.
107
+ seen = {}
108
+ with bind_trace_id("x"):
109
+ t = threading.Thread(target=lambda: seen.__setitem__("v", get_trace_id()))
110
+ t.start()
111
+ t.join()
112
+ assert seen["v"] is None
113
+
114
+
115
+ def test_pool_propagates_submitter_trace():
116
+ with bind_trace_id("pool-trace"):
117
+ with TracedThreadPoolExecutor(max_workers=2) as pool:
118
+ fut = pool.submit(get_trace_id)
119
+ assert fut.result() == "pool-trace"
120
+
121
+
122
+ def test_pool_captures_at_submit_not_construction():
123
+ # Pool built with no trace bound (as at boot); submit happens later inside a
124
+ # scope. Worker must see the submit-time trace, not the (empty) build-time one.
125
+ pool = TracedThreadPoolExecutor(max_workers=1)
126
+ try:
127
+ with bind_trace_id("later"):
128
+ fut = pool.submit(get_trace_id)
129
+ assert fut.result() == "later"
130
+ finally:
131
+ pool.shutdown()
132
+
133
+
134
+ def test_pool_passes_through_args_and_result():
135
+ with bind_trace_id("t"):
136
+ with TracedThreadPoolExecutor(max_workers=1) as pool:
137
+ fut = pool.submit(lambda a, b: (a + b, get_trace_id()), 2, 3)
138
+ assert fut.result() == (5, "t")
139
+
140
+
141
+ # ─── asyncio loop-in-a-thread ────────────────────────────────────────────────
142
+
143
+ def test_run_coro_threadsafe_with_trace_propagates():
144
+ loop = asyncio.new_event_loop()
145
+ loop_thread = threading.Thread(target=loop.run_forever, daemon=True)
146
+ loop_thread.start()
147
+ try:
148
+ async def capture():
149
+ return get_trace_id()
150
+
151
+ with bind_trace_id("coro-trace"):
152
+ fut = run_coro_threadsafe_with_trace(capture(), loop)
153
+ assert fut.result(timeout=5) == "coro-trace"
154
+ finally:
155
+ loop.call_soon_threadsafe(loop.stop)
156
+ loop_thread.join(timeout=5)
157
+ loop.close()
158
+
159
+
160
+ # ─── ASGI middleware ─────────────────────────────────────────────────────────
161
+
162
+ def _drive_middleware(scope_headers):
163
+ """Run TraceContextMiddleware over a fake ASGI request; return
164
+ (trace seen inside the app, response messages sent)."""
165
+ seen = {}
166
+ sent = []
167
+
168
+ async def app(scope, receive, send):
169
+ seen["trace"] = get_trace_id()
170
+ await send({"type": "http.response.start", "status": 200, "headers": []})
171
+ await send({"type": "http.response.body", "body": b""})
172
+
173
+ async def receive():
174
+ return {"type": "http.request", "body": b"", "more_body": False}
175
+
176
+ async def send(message):
177
+ sent.append(message)
178
+
179
+ scope = {"type": "http", "headers": scope_headers}
180
+ asyncio.run(TraceContextMiddleware(app)(scope, receive, send))
181
+ return seen["trace"], sent
182
+
183
+
184
+ def _response_headers(sent):
185
+ start = next(m for m in sent if m["type"] == "http.response.start")
186
+ return start["headers"]
187
+
188
+
189
+ def test_middleware_adopts_incoming_header():
190
+ trace, sent = _drive_middleware([(b"x-trace-id", b"incoming-123")])
191
+ assert trace == "incoming-123"
192
+ assert (TRACE_ID_HEADER.encode(), b"incoming-123") in _response_headers(sent)
193
+ assert get_trace_id() is None # reset after the request
194
+
195
+
196
+ def test_middleware_generates_when_absent():
197
+ trace, sent = _drive_middleware([])
198
+ assert trace is not None and len(trace) == 32
199
+ assert (TRACE_ID_HEADER.encode(), trace.encode()) in _response_headers(sent)
200
+
201
+
202
+ def test_middleware_ignores_non_http_scope():
203
+ # A lifespan/websocket scope should pass straight through untouched.
204
+ async def app(scope, receive, send):
205
+ send.append_marker = True
206
+
207
+ async def receive():
208
+ return {}
209
+
210
+ class _Send:
211
+ def __init__(self):
212
+ self.append_marker = False
213
+
214
+ async def __call__(self, message):
215
+ pass
216
+
217
+ send = _Send()
218
+ asyncio.run(TraceContextMiddleware(app)({"type": "lifespan"}, receive, send))
219
+ assert send.append_marker is True