tracelink 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 (33) hide show
  1. tracelink-0.5.0/LICENSE +21 -0
  2. tracelink-0.5.0/PKG-INFO +121 -0
  3. tracelink-0.5.0/README.md +88 -0
  4. tracelink-0.5.0/pyproject.toml +78 -0
  5. tracelink-0.5.0/setup.cfg +4 -0
  6. tracelink-0.5.0/tests/test_sinks.py +211 -0
  7. tracelink-0.5.0/tests/test_tracer.py +264 -0
  8. tracelink-0.5.0/tracelink/__init__.py +58 -0
  9. tracelink-0.5.0/tracelink/_context.py +42 -0
  10. tracelink-0.5.0/tracelink/_paths.py +41 -0
  11. tracelink-0.5.0/tracelink/_time.py +27 -0
  12. tracelink-0.5.0/tracelink/cli.py +74 -0
  13. tracelink-0.5.0/tracelink/dashboard/__init__.py +38 -0
  14. tracelink-0.5.0/tracelink/dashboard/_paths_resolve.py +102 -0
  15. tracelink-0.5.0/tracelink/dashboard/api.py +107 -0
  16. tracelink-0.5.0/tracelink/dashboard/server.py +248 -0
  17. tracelink-0.5.0/tracelink/dashboard/static/app.js +460 -0
  18. tracelink-0.5.0/tracelink/dashboard/static/index.html +104 -0
  19. tracelink-0.5.0/tracelink/dashboard/static/style.css +448 -0
  20. tracelink-0.5.0/tracelink/dev_endpoint.py +276 -0
  21. tracelink-0.5.0/tracelink/middleware.py +102 -0
  22. tracelink-0.5.0/tracelink/py.typed +0 -0
  23. tracelink-0.5.0/tracelink/sanitize.py +37 -0
  24. tracelink-0.5.0/tracelink/sinks/__init__.py +11 -0
  25. tracelink-0.5.0/tracelink/sinks/http.py +199 -0
  26. tracelink-0.5.0/tracelink/tracer.py +748 -0
  27. tracelink-0.5.0/tracelink/types.py +135 -0
  28. tracelink-0.5.0/tracelink.egg-info/PKG-INFO +121 -0
  29. tracelink-0.5.0/tracelink.egg-info/SOURCES.txt +51 -0
  30. tracelink-0.5.0/tracelink.egg-info/dependency_links.txt +1 -0
  31. tracelink-0.5.0/tracelink.egg-info/entry_points.txt +2 -0
  32. tracelink-0.5.0/tracelink.egg-info/requires.txt +9 -0
  33. tracelink-0.5.0/tracelink.egg-info/top_level.txt +1 -0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 秦权 (Qin Quan)
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,121 @@
1
+ Metadata-Version: 2.4
2
+ Name: tracelink
3
+ Version: 0.5.0
4
+ Summary: Full-stack cross-end debug tracer — NDJSON + readable, scope-filtered, AI-friendly
5
+ Author-email: qinquan-ai <qin16778@gmail.com>
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/qinquan-ai/Trace_Link
8
+ Project-URL: Documentation, https://github.com/qinquan-ai/Trace_Link#readme
9
+ Project-URL: Issues, https://github.com/qinquan-ai/Trace_Link/issues
10
+ Project-URL: Changelog, https://github.com/qinquan-ai/Trace_Link/blob/main/CHANGELOG.md
11
+ Keywords: tracing,debug,logging,ndjson,skill,ai-agent,scope,fastapi,starlette
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.9
17
+ Classifier: Programming Language :: Python :: 3.10
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
21
+ Classifier: Topic :: System :: Logging
22
+ Requires-Python: >=3.9
23
+ Description-Content-Type: text/markdown
24
+ License-File: LICENSE
25
+ Provides-Extra: fastapi
26
+ Requires-Dist: starlette>=0.27; extra == "fastapi"
27
+ Provides-Extra: dev
28
+ Requires-Dist: pytest>=7.4; extra == "dev"
29
+ Requires-Dist: pytest-asyncio>=0.21; extra == "dev"
30
+ Requires-Dist: ruff>=0.1.0; extra == "dev"
31
+ Requires-Dist: mypy>=1.7; extra == "dev"
32
+ Dynamic: license-file
33
+
34
+ # tracelink
35
+
36
+ Full-stack cross-end debug tracer — NDJSON + readable, scope-filtered, AI-friendly.
37
+
38
+ ## Install
39
+
40
+ ```bash
41
+ pip install tracelink
42
+ ```
43
+
44
+ With FastAPI/Starlette support:
45
+
46
+ ```bash
47
+ pip install tracelink[fastapi]
48
+ ```
49
+
50
+ ## Quick Start
51
+
52
+ ```python
53
+ from tracelink import debug_tracer
54
+
55
+ debug_tracer.start_scope('delete-work')
56
+ debug_tracer.entry('router.py:delete', 'user clicked delete', {'user_id': 123}, scope='delete-work')
57
+ debug_tracer.end_scope('delete-work')
58
+ ```
59
+
60
+ ## Send to the TraceLink board (0.5.0)
61
+
62
+ The Python sender has HTTP parity with the Node sender: point it at the same
63
+ standalone receiver + dashboard (started from the JS package via
64
+ `npx @qin16778/tracelink board`, default port `5174`) and Python and Node traces
65
+ land on one board, correlated by `traceId`.
66
+
67
+ ```python
68
+ from tracelink import debug_tracer
69
+
70
+ # non-blocking, fail-safe HTTP sink -> the board
71
+ debug_tracer.configure(http_endpoint="http://127.0.0.1:5174/__debug_log")
72
+ ```
73
+
74
+ First-run checklist (or you'll see nothing on the board):
75
+
76
+ - **Start the board first.** The receiver lives in the JS package; run
77
+ `npx @qin16778/tracelink board` before your Python process emits.
78
+ - **Enable tracing.** It's on when a dev/debug env is set; otherwise force it
79
+ with `TRACELINK_ENABLED=1` or `debug_tracer.set_enabled(True)`.
80
+ - **Flush short scripts.** Delivery is fire-and-forget on a daemon thread; for a
81
+ short-lived script call `sink.flush()` before exit (the sink returned by
82
+ `configure()`), so the last events land.
83
+
84
+ ## AI-agent spans (0.5.0)
85
+
86
+ ```python
87
+ from tracelink import debug_tracer
88
+
89
+ async def body():
90
+ debug_tracer.blocked('agent.py:sub', 'write outside sandbox denied',
91
+ reason='permission denied', data={'path': '/etc/passwd'})
92
+
93
+ await debug_tracer.span('X-AGENT', 'agent.py:run', 'agent.run', body, scope='agent-run-py')
94
+ ```
95
+
96
+ Spans auto-nest via `contextvars` (correct across `await`); the close event
97
+ carries real `durationMs` + `async`, and `blocked` / `intent` set `outcome`. See
98
+ the cross-language flagship demo:
99
+ [`examples/ai-agent`](https://github.com/qinquan-ai/Trace_Link/tree/main/examples/ai-agent).
100
+
101
+ ## Features
102
+
103
+ - **AI-agent native**: `debug_tracer.span()` with auto `traceId`/`parentSpanId` nesting (`contextvars`), `durationMs`, `async`, and `outcome` (`call` / `blocked` / `intent`).
104
+ - **Severity levels**: `level` (`debug` / `info` / `warn` / `error`) for board filtering + coloring.
105
+ - **Scope-filtered**: group related log entries by scope name
106
+ - **AI-friendly NDJSON**: each entry is one JSON line, easy for AI agents to parse
107
+ - **Zero dependencies**: core uses only Python stdlib
108
+ - **FastAPI/Starlette middleware**: plug-and-play HTTP middleware
109
+ - **Shared board**: POST to the JS receiver so Python + Node traces render on one dashboard
110
+
111
+ ## CLI
112
+
113
+ ```bash
114
+ tracelink --port 8080
115
+ ```
116
+
117
+ ## Links
118
+
119
+ - [Homepage](https://github.com/qinquan-ai/Trace_Link)
120
+ - [Documentation](https://github.com/qinquan-ai/Trace_Link#readme)
121
+ - [Issues](https://github.com/qinquan-ai/Trace_Link/issues)
@@ -0,0 +1,88 @@
1
+ # tracelink
2
+
3
+ Full-stack cross-end debug tracer — NDJSON + readable, scope-filtered, AI-friendly.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ pip install tracelink
9
+ ```
10
+
11
+ With FastAPI/Starlette support:
12
+
13
+ ```bash
14
+ pip install tracelink[fastapi]
15
+ ```
16
+
17
+ ## Quick Start
18
+
19
+ ```python
20
+ from tracelink import debug_tracer
21
+
22
+ debug_tracer.start_scope('delete-work')
23
+ debug_tracer.entry('router.py:delete', 'user clicked delete', {'user_id': 123}, scope='delete-work')
24
+ debug_tracer.end_scope('delete-work')
25
+ ```
26
+
27
+ ## Send to the TraceLink board (0.5.0)
28
+
29
+ The Python sender has HTTP parity with the Node sender: point it at the same
30
+ standalone receiver + dashboard (started from the JS package via
31
+ `npx @qin16778/tracelink board`, default port `5174`) and Python and Node traces
32
+ land on one board, correlated by `traceId`.
33
+
34
+ ```python
35
+ from tracelink import debug_tracer
36
+
37
+ # non-blocking, fail-safe HTTP sink -> the board
38
+ debug_tracer.configure(http_endpoint="http://127.0.0.1:5174/__debug_log")
39
+ ```
40
+
41
+ First-run checklist (or you'll see nothing on the board):
42
+
43
+ - **Start the board first.** The receiver lives in the JS package; run
44
+ `npx @qin16778/tracelink board` before your Python process emits.
45
+ - **Enable tracing.** It's on when a dev/debug env is set; otherwise force it
46
+ with `TRACELINK_ENABLED=1` or `debug_tracer.set_enabled(True)`.
47
+ - **Flush short scripts.** Delivery is fire-and-forget on a daemon thread; for a
48
+ short-lived script call `sink.flush()` before exit (the sink returned by
49
+ `configure()`), so the last events land.
50
+
51
+ ## AI-agent spans (0.5.0)
52
+
53
+ ```python
54
+ from tracelink import debug_tracer
55
+
56
+ async def body():
57
+ debug_tracer.blocked('agent.py:sub', 'write outside sandbox denied',
58
+ reason='permission denied', data={'path': '/etc/passwd'})
59
+
60
+ await debug_tracer.span('X-AGENT', 'agent.py:run', 'agent.run', body, scope='agent-run-py')
61
+ ```
62
+
63
+ Spans auto-nest via `contextvars` (correct across `await`); the close event
64
+ carries real `durationMs` + `async`, and `blocked` / `intent` set `outcome`. See
65
+ the cross-language flagship demo:
66
+ [`examples/ai-agent`](https://github.com/qinquan-ai/Trace_Link/tree/main/examples/ai-agent).
67
+
68
+ ## Features
69
+
70
+ - **AI-agent native**: `debug_tracer.span()` with auto `traceId`/`parentSpanId` nesting (`contextvars`), `durationMs`, `async`, and `outcome` (`call` / `blocked` / `intent`).
71
+ - **Severity levels**: `level` (`debug` / `info` / `warn` / `error`) for board filtering + coloring.
72
+ - **Scope-filtered**: group related log entries by scope name
73
+ - **AI-friendly NDJSON**: each entry is one JSON line, easy for AI agents to parse
74
+ - **Zero dependencies**: core uses only Python stdlib
75
+ - **FastAPI/Starlette middleware**: plug-and-play HTTP middleware
76
+ - **Shared board**: POST to the JS receiver so Python + Node traces render on one dashboard
77
+
78
+ ## CLI
79
+
80
+ ```bash
81
+ tracelink --port 8080
82
+ ```
83
+
84
+ ## Links
85
+
86
+ - [Homepage](https://github.com/qinquan-ai/Trace_Link)
87
+ - [Documentation](https://github.com/qinquan-ai/Trace_Link#readme)
88
+ - [Issues](https://github.com/qinquan-ai/Trace_Link/issues)
@@ -0,0 +1,78 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "tracelink"
7
+ version = "0.5.0"
8
+ description = "Full-stack cross-end debug tracer — NDJSON + readable, scope-filtered, AI-friendly"
9
+ readme = "README.md"
10
+ license = { text = "MIT" }
11
+ requires-python = ">=3.9"
12
+ authors = [
13
+ { name = "qinquan-ai", email = "qin16778@gmail.com" },
14
+ ]
15
+ keywords = ["tracing", "debug", "logging", "ndjson", "skill", "ai-agent", "scope", "fastapi", "starlette"]
16
+ classifiers = [
17
+ "Development Status :: 4 - Beta",
18
+ "Intended Audience :: Developers",
19
+ "License :: OSI Approved :: MIT License",
20
+ "Programming Language :: Python :: 3",
21
+ "Programming Language :: Python :: 3.9",
22
+ "Programming Language :: Python :: 3.10",
23
+ "Programming Language :: Python :: 3.11",
24
+ "Programming Language :: Python :: 3.12",
25
+ "Topic :: Software Development :: Libraries :: Python Modules",
26
+ "Topic :: System :: Logging",
27
+ ]
28
+ dependencies = [
29
+ # Intentionally empty — tracelink core uses only stdlib.
30
+ # FastAPI/Starlette are peer deps so users can choose their version.
31
+ ]
32
+
33
+ [project.optional-dependencies]
34
+ fastapi = ["starlette>=0.27"]
35
+ dev = [
36
+ "pytest>=7.4",
37
+ "pytest-asyncio>=0.21",
38
+ "ruff>=0.1.0",
39
+ "mypy>=1.7",
40
+ ]
41
+
42
+ [project.scripts]
43
+ tracelink = "tracelink.cli:main"
44
+
45
+ [project.urls]
46
+ Homepage = "https://github.com/qinquan-ai/Trace_Link"
47
+ Documentation = "https://github.com/qinquan-ai/Trace_Link#readme"
48
+ Issues = "https://github.com/qinquan-ai/Trace_Link/issues"
49
+ Changelog = "https://github.com/qinquan-ai/Trace_Link/blob/main/CHANGELOG.md"
50
+
51
+ [tool.setuptools]
52
+ package-dir = { "" = "." }
53
+
54
+ [tool.setuptools.packages.find]
55
+ where = ["."]
56
+ include = ["tracelink*"]
57
+ exclude = ["tests*", "examples*"]
58
+
59
+ [tool.setuptools.package-data]
60
+ tracelink = ["py.typed"]
61
+ "tracelink.dashboard" = ["static/*"]
62
+
63
+ [tool.ruff]
64
+ line-length = 110
65
+ target-version = "py39"
66
+
67
+ [tool.ruff.lint]
68
+ select = ["E", "F", "I", "B", "UP", "N", "ASYNC"]
69
+ ignore = ["E501"] # line-length handled by formatter
70
+
71
+ [tool.mypy]
72
+ python_version = "3.9"
73
+ strict = true
74
+ ignore_missing_imports = true
75
+
76
+ [tool.pytest.ini_options]
77
+ asyncio_mode = "auto"
78
+ testpaths = ["tests"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,211 @@
1
+ """Sink fan-out + HTTP sink tests (0.5.0).
2
+
3
+ Covers the parity work with the JS tracer:
4
+ - `add_sink` fan-out (every built log reaches every registered sink),
5
+ unsubscribe, and fail-safe isolation (a raising sink never breaks tracing);
6
+ - the `HttpSink` transport: POSTs each `TraceLog` as JSON to `/__debug_log`
7
+ with `x-trace-id` / `x-debug-scopes` headers, carrying the 0.5.0 fields
8
+ (`level` / `outcome` / `durationMs` / `async` / `data.reason`).
9
+ """
10
+
11
+ import json
12
+ import threading
13
+ from http.server import BaseHTTPRequestHandler, HTTPServer
14
+
15
+ import pytest
16
+
17
+ from tracelink.sinks.http import HttpSink
18
+
19
+
20
+ @pytest.fixture
21
+ def isolated_tracer(tmp_path, monkeypatch):
22
+ """Redirect log paths to a temp dir and reset the live singleton state,
23
+ including the new sink registry / HTTP-sink slot."""
24
+ monkeypatch.chdir(tmp_path)
25
+ monkeypatch.setenv("TRACELINK_DIR", str(tmp_path))
26
+ monkeypatch.setenv("TRACELINK_ENABLED", "true")
27
+ import tracelink.tracer as tracer_mod
28
+
29
+ inst = tracer_mod.debug_tracer
30
+ inst._enabled = True
31
+ inst._enabled_scopes = {"*"}
32
+ inst._logs = []
33
+ inst._active_traces = {}
34
+ inst._trace_id = None
35
+ inst._span_counter = 0
36
+ inst._sinks = []
37
+ inst._http_sink = None
38
+ yield inst
39
+ # Teardown — drop any HTTP sink thread and custom sinks.
40
+ if inst._http_sink is not None and hasattr(inst._http_sink, "close"):
41
+ inst._http_sink.close(timeout=1.0)
42
+ inst._http_sink = None
43
+ inst._sinks = []
44
+ inst._logs = []
45
+ inst._active_traces = {}
46
+ inst._enabled_scopes = {"*"}
47
+
48
+
49
+ # ---------------------------------------------------------------------------
50
+ # add_sink fan-out
51
+ # ---------------------------------------------------------------------------
52
+
53
+
54
+ def test_add_sink_receives_built_log(isolated_tracer):
55
+ received = []
56
+ isolated_tracer.add_sink(received.append)
57
+
58
+ isolated_tracer.entry("router.py:x", "hi", {"a": 1}, level="info", scope="s")
59
+
60
+ assert len(received) == 1
61
+ log = received[0]
62
+ assert log["layer"] == "BE-ENTRY"
63
+ assert log["msg"] == "hi"
64
+ assert log["level"] == "info"
65
+ assert log["data"]["a"] == 1
66
+ # Same dict that hit the memory buffer (built once, fanned out).
67
+ assert isolated_tracer.get_logs()[-1] is log
68
+
69
+
70
+ def test_add_sink_unsubscribe_stops_delivery(isolated_tracer):
71
+ received = []
72
+ off = isolated_tracer.add_sink(received.append)
73
+
74
+ isolated_tracer.entry("x", "one", scope="s")
75
+ off()
76
+ isolated_tracer.entry("x", "two", scope="s")
77
+
78
+ assert [log["msg"] for log in received] == ["one"]
79
+
80
+
81
+ def test_sink_raising_does_not_break_tracing(isolated_tracer):
82
+ calls = {"good": 0}
83
+
84
+ def boom(_log):
85
+ raise RuntimeError("sink blew up")
86
+
87
+ def good(_log):
88
+ calls["good"] += 1
89
+
90
+ isolated_tracer.add_sink(boom)
91
+ isolated_tracer.add_sink(good)
92
+
93
+ # Must not raise into user code, and the healthy sink still runs.
94
+ isolated_tracer.entry("x", "resilient", scope="s")
95
+
96
+ assert calls["good"] == 1
97
+ assert isolated_tracer.get_logs()[-1]["msg"] == "resilient"
98
+
99
+
100
+ def test_fan_out_covers_http_slot_and_custom_sinks(isolated_tracer):
101
+ http_seen = []
102
+ custom_seen = []
103
+ isolated_tracer.configure(http_sink=http_seen.append)
104
+ isolated_tracer.add_sink(custom_seen.append)
105
+
106
+ isolated_tracer.entry("x", "both", scope="s")
107
+
108
+ assert len(http_seen) == 1
109
+ assert len(custom_seen) == 1
110
+ assert http_seen[0]["msg"] == "both"
111
+
112
+
113
+ # ---------------------------------------------------------------------------
114
+ # HttpSink transport
115
+ # ---------------------------------------------------------------------------
116
+
117
+
118
+ class _CaptureHandler(BaseHTTPRequestHandler):
119
+ received = [] # class-level; reset per server in the fixture
120
+
121
+ def do_POST(self): # noqa: N802 - http.server naming
122
+ length = int(self.headers.get("Content-Length", 0))
123
+ body = self.rfile.read(length).decode("utf-8")
124
+ type(self).received.append(
125
+ {
126
+ "path": self.path,
127
+ "headers": {k.lower(): v for k, v in self.headers.items()},
128
+ "body": body,
129
+ }
130
+ )
131
+ self.send_response(204)
132
+ self.end_headers()
133
+
134
+ def log_message(self, *_args): # silence the default stderr spam
135
+ pass
136
+
137
+
138
+ @pytest.fixture
139
+ def capture_server():
140
+ _CaptureHandler.received = []
141
+ server = HTTPServer(("127.0.0.1", 0), _CaptureHandler)
142
+ thread = threading.Thread(target=server.serve_forever, daemon=True)
143
+ thread.start()
144
+ host, port = server.server_address
145
+ yield f"http://{host}:{port}/__debug_log", _CaptureHandler.received
146
+ server.shutdown()
147
+ server.server_close()
148
+ thread.join(timeout=2)
149
+
150
+
151
+ def test_http_sink_posts_all_050_fields(isolated_tracer, capture_server):
152
+ endpoint, received = capture_server
153
+
154
+ sink = isolated_tracer.configure(http_endpoint=endpoint)
155
+
156
+ # A blocked outcome (outcome + level=warn + data.reason).
157
+ isolated_tracer.blocked(
158
+ "routes/work:delete", "DELETE rejected",
159
+ reason="insufficient permission", data={"workId": "w_1"}, scope="s",
160
+ )
161
+ # An error-level normal log (level variety).
162
+ isolated_tracer.entry("router.py:boom", "non-fatal", level="error", scope="s")
163
+ # A span → open + close; close carries durationMs + async.
164
+ isolated_tracer.span("BE-ENTRY", "router.py:h", "handle", lambda: None, scope="s")
165
+
166
+ # Wait for the background daemon thread to drain.
167
+ assert sink.flush(timeout=3) is True
168
+
169
+ # 1 blocked + 1 entry + 2 span events (open, close) = 4 POSTs.
170
+ assert len(received) == 4
171
+
172
+ for req in received:
173
+ assert req["path"] == "/__debug_log"
174
+ assert req["headers"]["content-type"] == "application/json"
175
+ assert "x-trace-id" in req["headers"]
176
+ assert json.loads(req["headers"]["x-debug-scopes"]) == ["*"]
177
+
178
+ bodies = [json.loads(r["body"]) for r in received]
179
+ by_msg = {b["msg"]: b for b in bodies}
180
+
181
+ blocked_log = by_msg["DELETE rejected"]
182
+ assert blocked_log["outcome"] == "blocked"
183
+ assert blocked_log["level"] == "warn"
184
+ assert blocked_log["data"]["reason"] == "insufficient permission"
185
+
186
+ assert by_msg["non-fatal"]["level"] == "error"
187
+
188
+ # The span-close body carries durationMs + async (reserved key serialized).
189
+ close_logs = [b for b in bodies if "durationMs" in b]
190
+ assert len(close_logs) == 1
191
+ assert isinstance(close_logs[0]["durationMs"], int)
192
+ assert close_logs[0]["async"] is False
193
+
194
+
195
+ def test_http_sink_send_is_failsafe_when_receiver_down(isolated_tracer):
196
+ # Point at a closed port; send() must not block or raise, tracing continues.
197
+ sink = HttpSink(endpoint="http://127.0.0.1:59999/__debug_log", timeout_ms=200)
198
+ isolated_tracer.add_sink(sink)
199
+
200
+ isolated_tracer.entry("x", "no receiver", scope="s") # must not raise
201
+ sink.flush(timeout=1) # background delivery fails silently
202
+
203
+ assert isolated_tracer.get_logs()[-1]["msg"] == "no receiver"
204
+ sink.close(timeout=1)
205
+
206
+
207
+ def test_http_sink_disabled_is_noop(isolated_tracer):
208
+ sink = HttpSink(endpoint="http://127.0.0.1:59999/__debug_log", disabled=True)
209
+ # No worker thread should be spawned while disabled.
210
+ sink.send({"traceId": "t", "msg": "x"})
211
+ assert sink._worker is None