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.
Files changed (26) hide show
  1. fastapi_watchtower-1.0.0/LICENSE +21 -0
  2. fastapi_watchtower-1.0.0/PKG-INFO +51 -0
  3. fastapi_watchtower-1.0.0/README.md +36 -0
  4. fastapi_watchtower-1.0.0/pyproject.toml +18 -0
  5. fastapi_watchtower-1.0.0/setup.cfg +4 -0
  6. fastapi_watchtower-1.0.0/src/fastapi_watchtower.egg-info/PKG-INFO +51 -0
  7. fastapi_watchtower-1.0.0/src/fastapi_watchtower.egg-info/SOURCES.txt +24 -0
  8. fastapi_watchtower-1.0.0/src/fastapi_watchtower.egg-info/dependency_links.txt +1 -0
  9. fastapi_watchtower-1.0.0/src/fastapi_watchtower.egg-info/requires.txt +5 -0
  10. fastapi_watchtower-1.0.0/src/fastapi_watchtower.egg-info/top_level.txt +1 -0
  11. fastapi_watchtower-1.0.0/src/watchtower/__init__.py +4 -0
  12. fastapi_watchtower-1.0.0/src/watchtower/api.py +72 -0
  13. fastapi_watchtower-1.0.0/src/watchtower/artifacts.py +11 -0
  14. fastapi_watchtower-1.0.0/src/watchtower/expansion.py +23 -0
  15. fastapi_watchtower-1.0.0/src/watchtower/filtering.py +125 -0
  16. fastapi_watchtower-1.0.0/src/watchtower/frame_utils.py +92 -0
  17. fastapi_watchtower-1.0.0/src/watchtower/graph_builder.py +54 -0
  18. fastapi_watchtower-1.0.0/src/watchtower/indexer.py +225 -0
  19. fastapi_watchtower-1.0.0/src/watchtower/middleware.py +121 -0
  20. fastapi_watchtower-1.0.0/src/watchtower/models.py +61 -0
  21. fastapi_watchtower-1.0.0/src/watchtower/pipeline.py +94 -0
  22. fastapi_watchtower-1.0.0/src/watchtower/profiler.py +125 -0
  23. fastapi_watchtower-1.0.0/src/watchtower/serializer.py +85 -0
  24. fastapi_watchtower-1.0.0/src/watchtower/setup.py +76 -0
  25. fastapi_watchtower-1.0.0/src/watchtower/trace_parser.py +58 -0
  26. 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
+ ![Request Flow Animation](assets/request-animation-flow.gif)
@@ -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
+ ![Request Flow Animation](assets/request-animation-flow.gif)
@@ -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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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
+ ![Request Flow Animation](assets/request-animation-flow.gif)
@@ -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,5 @@
1
+ fastapi[all]>=0.135.3
2
+ numpy>=2.4.4
3
+ requests>=2.33.1
4
+ twine>=6.2.0
5
+ viztracer>=1.1.1
@@ -0,0 +1,4 @@
1
+ from .middleware import WatchTowerMiddleware
2
+ from .setup import setup_watchtower
3
+
4
+ __all__ = ["WatchTowerMiddleware", "setup_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}