fastapi-watchtower 1.0.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.
- fastapi_watchtower-1.0.0/LICENSE +21 -0
- fastapi_watchtower-1.0.0/PKG-INFO +51 -0
- fastapi_watchtower-1.0.0/README.md +36 -0
- fastapi_watchtower-1.0.0/pyproject.toml +18 -0
- fastapi_watchtower-1.0.0/setup.cfg +4 -0
- fastapi_watchtower-1.0.0/src/fastapi_watchtower.egg-info/PKG-INFO +51 -0
- fastapi_watchtower-1.0.0/src/fastapi_watchtower.egg-info/SOURCES.txt +24 -0
- fastapi_watchtower-1.0.0/src/fastapi_watchtower.egg-info/dependency_links.txt +1 -0
- fastapi_watchtower-1.0.0/src/fastapi_watchtower.egg-info/requires.txt +5 -0
- fastapi_watchtower-1.0.0/src/fastapi_watchtower.egg-info/top_level.txt +1 -0
- fastapi_watchtower-1.0.0/src/watchtower/__init__.py +4 -0
- fastapi_watchtower-1.0.0/src/watchtower/api.py +72 -0
- fastapi_watchtower-1.0.0/src/watchtower/artifacts.py +11 -0
- fastapi_watchtower-1.0.0/src/watchtower/expansion.py +23 -0
- fastapi_watchtower-1.0.0/src/watchtower/filtering.py +125 -0
- fastapi_watchtower-1.0.0/src/watchtower/frame_utils.py +92 -0
- fastapi_watchtower-1.0.0/src/watchtower/graph_builder.py +54 -0
- fastapi_watchtower-1.0.0/src/watchtower/indexer.py +225 -0
- fastapi_watchtower-1.0.0/src/watchtower/middleware.py +121 -0
- fastapi_watchtower-1.0.0/src/watchtower/models.py +61 -0
- fastapi_watchtower-1.0.0/src/watchtower/pipeline.py +94 -0
- fastapi_watchtower-1.0.0/src/watchtower/profiler.py +125 -0
- fastapi_watchtower-1.0.0/src/watchtower/serializer.py +85 -0
- fastapi_watchtower-1.0.0/src/watchtower/setup.py +76 -0
- fastapi_watchtower-1.0.0/src/watchtower/trace_parser.py +58 -0
- fastapi_watchtower-1.0.0/src/watchtower/tree_builder.py +63 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2024 HOTSONHONET
|
|
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,51 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: fastapi-watchtower
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Request tracing and visualization for FastAPI applications.
|
|
5
|
+
Author-email: Hotson Honett <hotsonhonet@gmail.com>
|
|
6
|
+
Requires-Python: >=3.13
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Requires-Dist: fastapi[all]>=0.135.3
|
|
10
|
+
Requires-Dist: numpy>=2.4.4
|
|
11
|
+
Requires-Dist: requests>=2.33.1
|
|
12
|
+
Requires-Dist: twine>=6.2.0
|
|
13
|
+
Requires-Dist: viztracer>=1.1.1
|
|
14
|
+
Dynamic: license-file
|
|
15
|
+
|
|
16
|
+
# WatchTower
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
WatchTower is a project-aware FastAPI runtime observability tool that maps requests to user-defined classes and functions, helping developers understand request flow and inspect profiling data without framework noise.
|
|
20
|
+
|
|
21
|
+
### Requirements
|
|
22
|
+
- Use `async def` endpoints for best tracing accuracy
|
|
23
|
+
- Sync endpoints (`def`) may lead to incomplete or unstable traces
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
### Example usage
|
|
27
|
+
|
|
28
|
+
```python
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
from fastapi import FastAPI
|
|
32
|
+
from .api.routes import router
|
|
33
|
+
from watchtower import setup_watchtower
|
|
34
|
+
|
|
35
|
+
app = FastAPI(title="Complex Profiling Demo")
|
|
36
|
+
|
|
37
|
+
setup_watchtower(
|
|
38
|
+
app,
|
|
39
|
+
source_root="examples",
|
|
40
|
+
output_dir=".watchtower-complex_server",
|
|
41
|
+
enable_ui=True,
|
|
42
|
+
ui_dist_dir="frontend/watchtower-ui/dist",
|
|
43
|
+
)
|
|
44
|
+
app.include_router(router)
|
|
45
|
+
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
### Example of Request Flow animation of a complex server
|
|
50
|
+
|
|
51
|
+

|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# WatchTower
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
WatchTower is a project-aware FastAPI runtime observability tool that maps requests to user-defined classes and functions, helping developers understand request flow and inspect profiling data without framework noise.
|
|
5
|
+
|
|
6
|
+
### Requirements
|
|
7
|
+
- Use `async def` endpoints for best tracing accuracy
|
|
8
|
+
- Sync endpoints (`def`) may lead to incomplete or unstable traces
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Example usage
|
|
12
|
+
|
|
13
|
+
```python
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
from fastapi import FastAPI
|
|
17
|
+
from .api.routes import router
|
|
18
|
+
from watchtower import setup_watchtower
|
|
19
|
+
|
|
20
|
+
app = FastAPI(title="Complex Profiling Demo")
|
|
21
|
+
|
|
22
|
+
setup_watchtower(
|
|
23
|
+
app,
|
|
24
|
+
source_root="examples",
|
|
25
|
+
output_dir=".watchtower-complex_server",
|
|
26
|
+
enable_ui=True,
|
|
27
|
+
ui_dist_dir="frontend/watchtower-ui/dist",
|
|
28
|
+
)
|
|
29
|
+
app.include_router(router)
|
|
30
|
+
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
### Example of Request Flow animation of a complex server
|
|
35
|
+
|
|
36
|
+

|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "fastapi-watchtower"
|
|
3
|
+
version = "1.0.0"
|
|
4
|
+
description = "Request tracing and visualization for FastAPI applications."
|
|
5
|
+
authors = [{name = "Hotson Honett", email = "hotsonhonet@gmail.com"}]
|
|
6
|
+
readme = "README.md"
|
|
7
|
+
requires-python = ">=3.13"
|
|
8
|
+
dependencies = [
|
|
9
|
+
"fastapi[all]>=0.135.3",
|
|
10
|
+
"numpy>=2.4.4",
|
|
11
|
+
"requests>=2.33.1",
|
|
12
|
+
"twine>=6.2.0",
|
|
13
|
+
"viztracer>=1.1.1",
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
[build-system]
|
|
17
|
+
requires = ["setuptools", "wheel"]
|
|
18
|
+
build-backend = "setuptools.build_meta"
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: fastapi-watchtower
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Request tracing and visualization for FastAPI applications.
|
|
5
|
+
Author-email: Hotson Honett <hotsonhonet@gmail.com>
|
|
6
|
+
Requires-Python: >=3.13
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Requires-Dist: fastapi[all]>=0.135.3
|
|
10
|
+
Requires-Dist: numpy>=2.4.4
|
|
11
|
+
Requires-Dist: requests>=2.33.1
|
|
12
|
+
Requires-Dist: twine>=6.2.0
|
|
13
|
+
Requires-Dist: viztracer>=1.1.1
|
|
14
|
+
Dynamic: license-file
|
|
15
|
+
|
|
16
|
+
# WatchTower
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
WatchTower is a project-aware FastAPI runtime observability tool that maps requests to user-defined classes and functions, helping developers understand request flow and inspect profiling data without framework noise.
|
|
20
|
+
|
|
21
|
+
### Requirements
|
|
22
|
+
- Use `async def` endpoints for best tracing accuracy
|
|
23
|
+
- Sync endpoints (`def`) may lead to incomplete or unstable traces
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
### Example usage
|
|
27
|
+
|
|
28
|
+
```python
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
from fastapi import FastAPI
|
|
32
|
+
from .api.routes import router
|
|
33
|
+
from watchtower import setup_watchtower
|
|
34
|
+
|
|
35
|
+
app = FastAPI(title="Complex Profiling Demo")
|
|
36
|
+
|
|
37
|
+
setup_watchtower(
|
|
38
|
+
app,
|
|
39
|
+
source_root="examples",
|
|
40
|
+
output_dir=".watchtower-complex_server",
|
|
41
|
+
enable_ui=True,
|
|
42
|
+
ui_dist_dir="frontend/watchtower-ui/dist",
|
|
43
|
+
)
|
|
44
|
+
app.include_router(router)
|
|
45
|
+
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
### Example of Request Flow animation of a complex server
|
|
50
|
+
|
|
51
|
+

|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
src/fastapi_watchtower.egg-info/PKG-INFO
|
|
5
|
+
src/fastapi_watchtower.egg-info/SOURCES.txt
|
|
6
|
+
src/fastapi_watchtower.egg-info/dependency_links.txt
|
|
7
|
+
src/fastapi_watchtower.egg-info/requires.txt
|
|
8
|
+
src/fastapi_watchtower.egg-info/top_level.txt
|
|
9
|
+
src/watchtower/__init__.py
|
|
10
|
+
src/watchtower/api.py
|
|
11
|
+
src/watchtower/artifacts.py
|
|
12
|
+
src/watchtower/expansion.py
|
|
13
|
+
src/watchtower/filtering.py
|
|
14
|
+
src/watchtower/frame_utils.py
|
|
15
|
+
src/watchtower/graph_builder.py
|
|
16
|
+
src/watchtower/indexer.py
|
|
17
|
+
src/watchtower/middleware.py
|
|
18
|
+
src/watchtower/models.py
|
|
19
|
+
src/watchtower/pipeline.py
|
|
20
|
+
src/watchtower/profiler.py
|
|
21
|
+
src/watchtower/serializer.py
|
|
22
|
+
src/watchtower/setup.py
|
|
23
|
+
src/watchtower/trace_parser.py
|
|
24
|
+
src/watchtower/tree_builder.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
watchtower
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from fastapi import APIRouter, HTTPException
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def create_watchtower_router(output_dir: str = ".watchtower") -> APIRouter:
|
|
10
|
+
router = APIRouter()
|
|
11
|
+
watchtower_dir = Path(output_dir)
|
|
12
|
+
|
|
13
|
+
@router.get("/requests")
|
|
14
|
+
async def list_requests():
|
|
15
|
+
if not watchtower_dir.exists():
|
|
16
|
+
return {"requests": []}
|
|
17
|
+
|
|
18
|
+
results = []
|
|
19
|
+
|
|
20
|
+
for request_dir in sorted(
|
|
21
|
+
[p for p in watchtower_dir.iterdir() if p.is_dir()],
|
|
22
|
+
key=lambda p: p.stat().st_mtime,
|
|
23
|
+
reverse=True,
|
|
24
|
+
):
|
|
25
|
+
memory_file = request_dir / "memory.json"
|
|
26
|
+
|
|
27
|
+
entry = {
|
|
28
|
+
"request_id": request_dir.name,
|
|
29
|
+
"created_at": request_dir.stat().st_mtime,
|
|
30
|
+
"method": None,
|
|
31
|
+
"path": None,
|
|
32
|
+
"duration_ms": None,
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if memory_file.exists():
|
|
36
|
+
data = json.loads(memory_file.read_text(encoding="utf-8"))
|
|
37
|
+
meta = data.get("meta", {})
|
|
38
|
+
entry["method"] = meta.get("method")
|
|
39
|
+
entry["path"] = meta.get("path")
|
|
40
|
+
entry["duration_ms"] = meta.get("duration_ms")
|
|
41
|
+
|
|
42
|
+
results.append(entry)
|
|
43
|
+
|
|
44
|
+
return {"requests": results}
|
|
45
|
+
|
|
46
|
+
@router.get("/requests/latest/graph")
|
|
47
|
+
async def latest_graph():
|
|
48
|
+
if not watchtower_dir.exists():
|
|
49
|
+
raise HTTPException(status_code=404, detail="No WatchTower artifacts found")
|
|
50
|
+
|
|
51
|
+
request_dirs = sorted(
|
|
52
|
+
[p for p in watchtower_dir.iterdir() if p.is_dir()],
|
|
53
|
+
reverse=True,
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
for request_dir in request_dirs:
|
|
57
|
+
graph_file = request_dir / "request_graph.json"
|
|
58
|
+
if graph_file.exists():
|
|
59
|
+
return json.loads(graph_file.read_text(encoding="utf-8"))
|
|
60
|
+
|
|
61
|
+
raise HTTPException(status_code=404, detail="No completed request_graph.json found")
|
|
62
|
+
|
|
63
|
+
@router.get("/requests/{request_id}/graph")
|
|
64
|
+
async def request_graph(request_id: str):
|
|
65
|
+
graph_file = watchtower_dir / request_id / "request_graph.json"
|
|
66
|
+
|
|
67
|
+
if not graph_file.exists():
|
|
68
|
+
raise HTTPException(status_code=404, detail="request_graph.json not found")
|
|
69
|
+
|
|
70
|
+
return json.loads(graph_file.read_text(encoding="utf-8"))
|
|
71
|
+
|
|
72
|
+
return router
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def save_json(data: dict[str, Any], output_file: str) -> None:
|
|
9
|
+
path = Path(output_file)
|
|
10
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
11
|
+
path.write_text(json.dumps(data, indent=2), encoding="utf-8")
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def get_child_events_within_range(
|
|
7
|
+
all_events: list[dict[str, Any]],
|
|
8
|
+
parent_event: dict[str, Any],
|
|
9
|
+
) -> list[dict[str, Any]]:
|
|
10
|
+
parent_start = parent_event["ts"]
|
|
11
|
+
parent_end = parent_event["end_ts"]
|
|
12
|
+
|
|
13
|
+
children = []
|
|
14
|
+
for event in all_events:
|
|
15
|
+
if event["ts"] < parent_start:
|
|
16
|
+
continue
|
|
17
|
+
if (event["ts"] + event["duration_us"]) > parent_end:
|
|
18
|
+
continue
|
|
19
|
+
if event["raw_name"] == parent_event.get("raw_name"):
|
|
20
|
+
continue
|
|
21
|
+
children.append(event)
|
|
22
|
+
|
|
23
|
+
return children
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def _normalize_path(path: str | None) -> str | None:
|
|
8
|
+
if not path:
|
|
9
|
+
return None
|
|
10
|
+
try:
|
|
11
|
+
return str(Path(path).resolve())
|
|
12
|
+
except Exception:
|
|
13
|
+
return str(Path(path))
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def get_indexed_files(code_index: dict[str, Any]) -> set[str]:
|
|
17
|
+
files: set[str] = set()
|
|
18
|
+
|
|
19
|
+
for module in code_index.get("modules", []):
|
|
20
|
+
normalized = _normalize_path(module.get("file_path"))
|
|
21
|
+
if normalized:
|
|
22
|
+
files.add(normalized)
|
|
23
|
+
|
|
24
|
+
return files
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def filter_user_events(
|
|
28
|
+
normalized_events: list[dict[str, Any]],
|
|
29
|
+
indexed_files: set[str],
|
|
30
|
+
) -> list[dict[str, Any]]:
|
|
31
|
+
filtered: list[dict[str, Any]] = []
|
|
32
|
+
|
|
33
|
+
for event in normalized_events:
|
|
34
|
+
event_file = _normalize_path(event.get("file_path"))
|
|
35
|
+
if event_file and event_file in indexed_files:
|
|
36
|
+
filtered.append(
|
|
37
|
+
{
|
|
38
|
+
**event,
|
|
39
|
+
"file_path": event_file,
|
|
40
|
+
}
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
return filtered
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _build_module_lookup(code_index: dict[str, Any]) -> dict[str, dict[str, Any]]:
|
|
47
|
+
lookup: dict[str, dict[str, Any]] = {}
|
|
48
|
+
|
|
49
|
+
for module in code_index.get("modules", []):
|
|
50
|
+
normalized = _normalize_path(module.get("file_path"))
|
|
51
|
+
if normalized:
|
|
52
|
+
lookup[normalized] = module
|
|
53
|
+
|
|
54
|
+
return lookup
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def find_matching_function(
|
|
58
|
+
file_path: str,
|
|
59
|
+
line_no: int,
|
|
60
|
+
code_index: dict[str, Any],
|
|
61
|
+
) -> dict[str, Any] | None:
|
|
62
|
+
normalized_file = _normalize_path(file_path)
|
|
63
|
+
if not normalized_file:
|
|
64
|
+
return None
|
|
65
|
+
|
|
66
|
+
module_lookup = _build_module_lookup(code_index)
|
|
67
|
+
module = module_lookup.get(normalized_file)
|
|
68
|
+
if module is None:
|
|
69
|
+
return None
|
|
70
|
+
|
|
71
|
+
best_match: dict[str, Any] | None = None
|
|
72
|
+
best_span: int | None = None
|
|
73
|
+
|
|
74
|
+
for fn in module.get("functions", []):
|
|
75
|
+
start = fn["lineno"]
|
|
76
|
+
end = fn.get("end_lineno") or start
|
|
77
|
+
if start <= line_no <= end:
|
|
78
|
+
span = end - start
|
|
79
|
+
if best_span is None or span < best_span:
|
|
80
|
+
best_match = fn
|
|
81
|
+
best_span = span
|
|
82
|
+
|
|
83
|
+
for cls in module.get("classes", []):
|
|
84
|
+
for method in cls.get("methods", []):
|
|
85
|
+
start = method["lineno"]
|
|
86
|
+
end = method.get("end_lineno") or start
|
|
87
|
+
if start <= line_no <= end:
|
|
88
|
+
span = end - start
|
|
89
|
+
if best_span is None or span < best_span:
|
|
90
|
+
best_match = method
|
|
91
|
+
best_span = span
|
|
92
|
+
|
|
93
|
+
return best_match
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def attach_index_metadata(
|
|
97
|
+
events: list[dict[str, Any]],
|
|
98
|
+
code_index: dict[str, Any],
|
|
99
|
+
) -> list[dict[str, Any]]:
|
|
100
|
+
enriched: list[dict[str, Any]] = []
|
|
101
|
+
|
|
102
|
+
sorted_events = sorted(events, key=lambda e: (e["ts"], e.get("duration_us", 0)))
|
|
103
|
+
|
|
104
|
+
for idx, event in enumerate(sorted_events, start=1):
|
|
105
|
+
match = find_matching_function(
|
|
106
|
+
file_path=event["file_path"],
|
|
107
|
+
line_no=event["line_no"],
|
|
108
|
+
code_index=code_index,
|
|
109
|
+
)
|
|
110
|
+
if match is None:
|
|
111
|
+
continue
|
|
112
|
+
|
|
113
|
+
enriched.append(
|
|
114
|
+
{
|
|
115
|
+
**event,
|
|
116
|
+
"file_path": _normalize_path(event["file_path"]),
|
|
117
|
+
"qualified_name": match["qualified_name"],
|
|
118
|
+
"route_path": match.get("route_path"),
|
|
119
|
+
"route_methods": match.get("route_methods", []),
|
|
120
|
+
"parent_class": match.get("parent_class"),
|
|
121
|
+
"call_index": idx,
|
|
122
|
+
}
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
return enriched
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import inspect
|
|
4
|
+
|
|
5
|
+
from .serializer import safe_serialize
|
|
6
|
+
|
|
7
|
+
DEFAULT_SKIPPED_ARG_NAMES = {"request", "response", "app", "db"}
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def extract_inputs_from_frame(
|
|
11
|
+
frame,
|
|
12
|
+
*,
|
|
13
|
+
include_self: bool = False,
|
|
14
|
+
max_depth: int = 2,
|
|
15
|
+
max_string_length: int = 200,
|
|
16
|
+
max_collection_items: int = 10,
|
|
17
|
+
) -> dict:
|
|
18
|
+
arg_info = inspect.getargvalues(frame)
|
|
19
|
+
|
|
20
|
+
args_out = []
|
|
21
|
+
kwargs_out = {}
|
|
22
|
+
|
|
23
|
+
skipped = set(DEFAULT_SKIPPED_ARG_NAMES)
|
|
24
|
+
if not include_self:
|
|
25
|
+
skipped.update({"self", "cls"})
|
|
26
|
+
|
|
27
|
+
for name in arg_info.args:
|
|
28
|
+
if name in skipped:
|
|
29
|
+
continue
|
|
30
|
+
|
|
31
|
+
value = arg_info.locals.get(name)
|
|
32
|
+
args_out.append(
|
|
33
|
+
{
|
|
34
|
+
"name": name,
|
|
35
|
+
"value": safe_serialize(
|
|
36
|
+
value,
|
|
37
|
+
max_depth=max_depth,
|
|
38
|
+
max_string_length=max_string_length,
|
|
39
|
+
max_collection_items=max_collection_items,
|
|
40
|
+
),
|
|
41
|
+
}
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
if arg_info.varargs:
|
|
45
|
+
varargs_value = arg_info.locals.get(arg_info.varargs, ())
|
|
46
|
+
args_out.append(
|
|
47
|
+
{
|
|
48
|
+
"name": f"*{arg_info.varargs}",
|
|
49
|
+
"value": safe_serialize(
|
|
50
|
+
varargs_value,
|
|
51
|
+
max_depth=max_depth,
|
|
52
|
+
max_string_length=max_string_length,
|
|
53
|
+
max_collection_items=max_collection_items,
|
|
54
|
+
),
|
|
55
|
+
}
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
if arg_info.keywords:
|
|
59
|
+
kw_value = arg_info.locals.get(arg_info.keywords, {})
|
|
60
|
+
if isinstance(kw_value, dict):
|
|
61
|
+
kwargs_out = {
|
|
62
|
+
str(k): safe_serialize(
|
|
63
|
+
v,
|
|
64
|
+
max_depth=max_depth,
|
|
65
|
+
max_string_length=max_string_length,
|
|
66
|
+
max_collection_items=max_collection_items,
|
|
67
|
+
)
|
|
68
|
+
for k, v in kw_value.items()
|
|
69
|
+
}
|
|
70
|
+
else:
|
|
71
|
+
kwargs_out = {
|
|
72
|
+
f"**{arg_info.keywords}": safe_serialize(
|
|
73
|
+
kw_value,
|
|
74
|
+
max_depth=max_depth,
|
|
75
|
+
max_string_length=max_string_length,
|
|
76
|
+
max_collection_items=max_collection_items,
|
|
77
|
+
)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
class_name = None
|
|
81
|
+
if "self" in frame.f_locals:
|
|
82
|
+
class_name = type(frame.f_locals["self"]).__name__
|
|
83
|
+
elif "cls" in frame.f_locals and hasattr(frame.f_locals["cls"], "__name__"):
|
|
84
|
+
class_name = frame.f_locals["cls"].__name__
|
|
85
|
+
|
|
86
|
+
return {
|
|
87
|
+
"class_name": class_name,
|
|
88
|
+
"inputs": {
|
|
89
|
+
"args": args_out,
|
|
90
|
+
"kwargs": kwargs_out,
|
|
91
|
+
},
|
|
92
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def _short_label(name: str) -> str:
|
|
7
|
+
if "." in name:
|
|
8
|
+
return name.split(".")[-1]
|
|
9
|
+
return name
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def tree_to_graph(tree: dict[str, Any]) -> dict[str, list[dict[str, Any]]]:
|
|
13
|
+
nodes: list[dict[str, Any]] = []
|
|
14
|
+
edges: list[dict[str, Any]] = []
|
|
15
|
+
|
|
16
|
+
def visit(node: dict[str, Any], parent_id: str | None = None, depth: int = 0) -> None:
|
|
17
|
+
node_id = node["id"]
|
|
18
|
+
node_type = "request" if depth == 0 else "function"
|
|
19
|
+
|
|
20
|
+
nodes.append(
|
|
21
|
+
{
|
|
22
|
+
"id": node_id,
|
|
23
|
+
"label": _short_label(node["name"]),
|
|
24
|
+
"full_name": node["name"],
|
|
25
|
+
"type": node_type,
|
|
26
|
+
"duration": node.get("dur", node.get("duration_ms")),
|
|
27
|
+
"file_path": node.get("file_path"),
|
|
28
|
+
"line_no": node.get("line_no"),
|
|
29
|
+
"route_path": node.get("route_path"),
|
|
30
|
+
"route_methods": node.get("route_methods", []),
|
|
31
|
+
"parent_class": node.get("parent_class"),
|
|
32
|
+
"call_index": node.get("call_index"),
|
|
33
|
+
"inputs": node.get("inputs"),
|
|
34
|
+
"raw_args": node.get("raw_args"),
|
|
35
|
+
"request_id": node.get("request_id"),
|
|
36
|
+
"created_at": node.get("created_at"),
|
|
37
|
+
"expandable": len(node.get("children", [])) > 0,
|
|
38
|
+
}
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
if parent_id is not None:
|
|
42
|
+
edges.append(
|
|
43
|
+
{
|
|
44
|
+
"id": f"{parent_id}->{node_id}",
|
|
45
|
+
"source": parent_id,
|
|
46
|
+
"target": node_id,
|
|
47
|
+
}
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
for child in node.get("children", []):
|
|
51
|
+
visit(child, node_id, depth + 1)
|
|
52
|
+
|
|
53
|
+
visit(tree)
|
|
54
|
+
return {"nodes": nodes, "edges": edges}
|