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.
- {rule_engine_core-0.0.4 → rule_engine_core-0.1.0}/PKG-INFO +1 -1
- {rule_engine_core-0.0.4 → rule_engine_core-0.1.0}/rule_engine_core/metadata_store.py +6 -1
- rule_engine_core-0.1.0/rule_engine_core/tracing.py +300 -0
- {rule_engine_core-0.0.4 → rule_engine_core-0.1.0}/rule_engine_core.egg-info/PKG-INFO +1 -1
- {rule_engine_core-0.0.4 → rule_engine_core-0.1.0}/rule_engine_core.egg-info/SOURCES.txt +3 -1
- {rule_engine_core-0.0.4 → rule_engine_core-0.1.0}/setup.py +2 -2
- rule_engine_core-0.1.0/tests/test_tracing.py +219 -0
- {rule_engine_core-0.0.4 → rule_engine_core-0.1.0}/README.md +0 -0
- {rule_engine_core-0.0.4 → rule_engine_core-0.1.0}/pyproject.toml +0 -0
- {rule_engine_core-0.0.4 → rule_engine_core-0.1.0}/rule_engine_core/__init__.py +0 -0
- {rule_engine_core-0.0.4 → rule_engine_core-0.1.0}/rule_engine_core/db_pool.py +0 -0
- {rule_engine_core-0.0.4 → rule_engine_core-0.1.0}/rule_engine_core/entity_base.py +0 -0
- {rule_engine_core-0.0.4 → rule_engine_core-0.1.0}/rule_engine_core/entity_dao.py +0 -0
- {rule_engine_core-0.0.4 → rule_engine_core-0.1.0}/rule_engine_core/entity_types.py +0 -0
- {rule_engine_core-0.0.4 → rule_engine_core-0.1.0}/rule_engine_core/filter.py +0 -0
- {rule_engine_core-0.0.4 → rule_engine_core-0.1.0}/rule_engine_core/metadata_dao.py +0 -0
- {rule_engine_core-0.0.4 → rule_engine_core-0.1.0}/rule_engine_core/parser.py +0 -0
- {rule_engine_core-0.0.4 → rule_engine_core-0.1.0}/rule_engine_core/rule.py +0 -0
- {rule_engine_core-0.0.4 → rule_engine_core-0.1.0}/rule_engine_core.egg-info/dependency_links.txt +0 -0
- {rule_engine_core-0.0.4 → rule_engine_core-0.1.0}/rule_engine_core.egg-info/requires.txt +0 -0
- {rule_engine_core-0.0.4 → rule_engine_core-0.1.0}/rule_engine_core.egg-info/top_level.txt +0 -0
- {rule_engine_core-0.0.4 → rule_engine_core-0.1.0}/setup.cfg +0 -0
- {rule_engine_core-0.0.4 → rule_engine_core-0.1.0}/tests/test_metadata_dao.py +0 -0
- {rule_engine_core-0.0.4 → rule_engine_core-0.1.0}/tests/test_metadata_store_api_fetch.py +0 -0
- {rule_engine_core-0.0.4 → rule_engine_core-0.1.0}/tests/test_rule_creation_storage.py +0 -0
- {rule_engine_core-0.0.4 → rule_engine_core-0.1.0}/tests/test_rule_evaluation.py +0 -0
|
@@ -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(
|
|
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
|
|
@@ -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
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{rule_engine_core-0.0.4 → rule_engine_core-0.1.0}/rule_engine_core.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|