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.
- tracelink-0.5.0/LICENSE +21 -0
- tracelink-0.5.0/PKG-INFO +121 -0
- tracelink-0.5.0/README.md +88 -0
- tracelink-0.5.0/pyproject.toml +78 -0
- tracelink-0.5.0/setup.cfg +4 -0
- tracelink-0.5.0/tests/test_sinks.py +211 -0
- tracelink-0.5.0/tests/test_tracer.py +264 -0
- tracelink-0.5.0/tracelink/__init__.py +58 -0
- tracelink-0.5.0/tracelink/_context.py +42 -0
- tracelink-0.5.0/tracelink/_paths.py +41 -0
- tracelink-0.5.0/tracelink/_time.py +27 -0
- tracelink-0.5.0/tracelink/cli.py +74 -0
- tracelink-0.5.0/tracelink/dashboard/__init__.py +38 -0
- tracelink-0.5.0/tracelink/dashboard/_paths_resolve.py +102 -0
- tracelink-0.5.0/tracelink/dashboard/api.py +107 -0
- tracelink-0.5.0/tracelink/dashboard/server.py +248 -0
- tracelink-0.5.0/tracelink/dashboard/static/app.js +460 -0
- tracelink-0.5.0/tracelink/dashboard/static/index.html +104 -0
- tracelink-0.5.0/tracelink/dashboard/static/style.css +448 -0
- tracelink-0.5.0/tracelink/dev_endpoint.py +276 -0
- tracelink-0.5.0/tracelink/middleware.py +102 -0
- tracelink-0.5.0/tracelink/py.typed +0 -0
- tracelink-0.5.0/tracelink/sanitize.py +37 -0
- tracelink-0.5.0/tracelink/sinks/__init__.py +11 -0
- tracelink-0.5.0/tracelink/sinks/http.py +199 -0
- tracelink-0.5.0/tracelink/tracer.py +748 -0
- tracelink-0.5.0/tracelink/types.py +135 -0
- tracelink-0.5.0/tracelink.egg-info/PKG-INFO +121 -0
- tracelink-0.5.0/tracelink.egg-info/SOURCES.txt +51 -0
- tracelink-0.5.0/tracelink.egg-info/dependency_links.txt +1 -0
- tracelink-0.5.0/tracelink.egg-info/entry_points.txt +2 -0
- tracelink-0.5.0/tracelink.egg-info/requires.txt +9 -0
- tracelink-0.5.0/tracelink.egg-info/top_level.txt +1 -0
tracelink-0.5.0/LICENSE
ADDED
|
@@ -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.
|
tracelink-0.5.0/PKG-INFO
ADDED
|
@@ -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,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
|