stacklink 0.1.0__tar.gz → 0.2.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 (38) hide show
  1. {stacklink-0.1.0 → stacklink-0.2.0}/.claude/settings.local.json +2 -1
  2. {stacklink-0.1.0 → stacklink-0.2.0}/.codacy/logs/codacy-cli.log +42 -0
  3. {stacklink-0.1.0 → stacklink-0.2.0}/PKG-INFO +1 -1
  4. {stacklink-0.1.0 → stacklink-0.2.0}/pyproject.toml +1 -1
  5. {stacklink-0.1.0 → stacklink-0.2.0}/stacklink/__init__.py +4 -1
  6. stacklink-0.2.0/stacklink/metrics.py +192 -0
  7. {stacklink-0.1.0 → stacklink-0.2.0}/stacklink-practices.md +2 -1
  8. stacklink-0.2.0/tests/test_metrics.py +207 -0
  9. {stacklink-0.1.0 → stacklink-0.2.0}/.codacy/.gitignore +0 -0
  10. {stacklink-0.1.0 → stacklink-0.2.0}/.codacy/cli-config.yaml +0 -0
  11. {stacklink-0.1.0 → stacklink-0.2.0}/.codacy/cli.sh +0 -0
  12. {stacklink-0.1.0 → stacklink-0.2.0}/.codacy/codacy.yaml +0 -0
  13. {stacklink-0.1.0 → stacklink-0.2.0}/.codacy/tools-configs/analysis_options.yaml +0 -0
  14. {stacklink-0.1.0 → stacklink-0.2.0}/.codacy/tools-configs/eslint.config.mjs +0 -0
  15. {stacklink-0.1.0 → stacklink-0.2.0}/.codacy/tools-configs/languages-config.yaml +0 -0
  16. {stacklink-0.1.0 → stacklink-0.2.0}/.codacy/tools-configs/lizard.yaml +0 -0
  17. {stacklink-0.1.0 → stacklink-0.2.0}/.codacy/tools-configs/pylint.rc +0 -0
  18. {stacklink-0.1.0 → stacklink-0.2.0}/.codacy/tools-configs/revive.toml +0 -0
  19. {stacklink-0.1.0 → stacklink-0.2.0}/.codacy/tools-configs/ruleset.xml +0 -0
  20. {stacklink-0.1.0 → stacklink-0.2.0}/.codacy/tools-configs/semgrep.yaml +0 -0
  21. {stacklink-0.1.0 → stacklink-0.2.0}/.codacy/tools-configs/trivy.yaml +0 -0
  22. {stacklink-0.1.0 → stacklink-0.2.0}/.env.example +0 -0
  23. {stacklink-0.1.0 → stacklink-0.2.0}/.gitignore +0 -0
  24. {stacklink-0.1.0 → stacklink-0.2.0}/LICENSE +0 -0
  25. {stacklink-0.1.0 → stacklink-0.2.0}/README.md +0 -0
  26. {stacklink-0.1.0 → stacklink-0.2.0}/docs/config.md +0 -0
  27. {stacklink-0.1.0 → stacklink-0.2.0}/docs/health.md +0 -0
  28. {stacklink-0.1.0 → stacklink-0.2.0}/docs/logger.md +0 -0
  29. {stacklink-0.1.0 → stacklink-0.2.0}/examples/basic_fastapi/.env.example +0 -0
  30. {stacklink-0.1.0 → stacklink-0.2.0}/examples/basic_fastapi/main.py +0 -0
  31. {stacklink-0.1.0 → stacklink-0.2.0}/stacklink/config.py +0 -0
  32. {stacklink-0.1.0 → stacklink-0.2.0}/stacklink/health.py +0 -0
  33. {stacklink-0.1.0 → stacklink-0.2.0}/stacklink/logger.py +0 -0
  34. {stacklink-0.1.0 → stacklink-0.2.0}/stacklink-plan.md +0 -0
  35. {stacklink-0.1.0 → stacklink-0.2.0}/tests/__init__.py +0 -0
  36. {stacklink-0.1.0 → stacklink-0.2.0}/tests/test_config.py +0 -0
  37. {stacklink-0.1.0 → stacklink-0.2.0}/tests/test_health.py +0 -0
  38. {stacklink-0.1.0 → stacklink-0.2.0}/tests/test_logger.py +0 -0
@@ -7,7 +7,8 @@
7
7
  "Bash(pip3 install:*)",
8
8
  "Bash(PYTHONPATH=. python3:*)",
9
9
  "Bash(.venv/bin/python:*)",
10
- "Bash(git:*)"
10
+ "Bash(git:*)",
11
+ "Bash(awk length > 88:*)"
11
12
  ]
12
13
  }
13
14
  }
@@ -666,3 +666,45 @@
666
666
  2026-04-12T21:25:02+01:00 [INFO] (codacy-cli-v2/cmd/root.go:34) Executing CLI command command=analyze full_command=[/Users/yevgenyokun/Library/Caches/Codacy/codacy-cli-v2/1.0.0-main.375.sha.df55ffa/codacy-cli-v2 analyze /Users/yevgenyokun/stacklink/stacklink/pyproject.toml --format sarif] args=[/Users/yevgenyokun/stacklink/stacklink/pyproject.toml]
667
667
  2026-04-12T21:25:54+01:00 [INFO] (codacy-cli-v2/cli-v2.go:27) Starting Codacy CLI version=1.0.0-main.375.sha.df55ffa (df55ffa) built at 2026-04-08T11:10:45Z
668
668
  2026-04-12T21:25:54+01:00 [INFO] (codacy-cli-v2/cmd/root.go:34) Executing CLI command command=analyze full_command=[/Users/yevgenyokun/Library/Caches/Codacy/codacy-cli-v2/1.0.0-main.375.sha.df55ffa/codacy-cli-v2 analyze /Users/yevgenyokun/stacklink/stacklink/pyproject.toml --format sarif] args=[/Users/yevgenyokun/stacklink/stacklink/pyproject.toml]
669
+ 2026-04-12T21:42:18+01:00 [INFO] (codacy-cli-v2/cli-v2.go:27) Starting Codacy CLI version=1.0.0-main.375.sha.df55ffa (df55ffa) built at 2026-04-08T11:10:45Z
670
+ 2026-04-12T21:42:18+01:00 [INFO] (codacy-cli-v2/cmd/root.go:34) Executing CLI command args=[/Users/yevgenyokun/stacklink/stacklink/pyproject.toml] command=analyze full_command=[/Users/yevgenyokun/Library/Caches/Codacy/codacy-cli-v2/1.0.0-main.375.sha.df55ffa/codacy-cli-v2 analyze /Users/yevgenyokun/stacklink/stacklink/pyproject.toml --format sarif]
671
+ 2026-04-12T21:42:25+01:00 [INFO] (codacy-cli-v2/cli-v2.go:27) Starting Codacy CLI version=1.0.0-main.375.sha.df55ffa (df55ffa) built at 2026-04-08T11:10:45Z
672
+ 2026-04-12T21:42:25+01:00 [INFO] (codacy-cli-v2/cmd/root.go:34) Executing CLI command command=analyze full_command=[/Users/yevgenyokun/Library/Caches/Codacy/codacy-cli-v2/1.0.0-main.375.sha.df55ffa/codacy-cli-v2 analyze /Users/yevgenyokun/stacklink/stacklink/docs/health.md --format sarif] args=[/Users/yevgenyokun/stacklink/stacklink/docs/health.md]
673
+ 2026-04-12T21:42:51+01:00 [INFO] (codacy-cli-v2/cli-v2.go:27) Starting Codacy CLI version=1.0.0-main.375.sha.df55ffa (df55ffa) built at 2026-04-08T11:10:45Z
674
+ 2026-04-12T21:42:51+01:00 [INFO] (codacy-cli-v2/cmd/root.go:34) Executing CLI command command=analyze full_command=[/Users/yevgenyokun/Library/Caches/Codacy/codacy-cli-v2/1.0.0-main.375.sha.df55ffa/codacy-cli-v2 analyze /Users/yevgenyokun/stacklink/stacklink/README.md --format sarif] args=[/Users/yevgenyokun/stacklink/stacklink/README.md]
675
+ 2026-04-12T21:42:56+01:00 [INFO] (codacy-cli-v2/cli-v2.go:27) Starting Codacy CLI version=1.0.0-main.375.sha.df55ffa (df55ffa) built at 2026-04-08T11:10:45Z
676
+ 2026-04-12T21:42:56+01:00 [INFO] (codacy-cli-v2/cmd/root.go:34) Executing CLI command command=analyze full_command=[/Users/yevgenyokun/Library/Caches/Codacy/codacy-cli-v2/1.0.0-main.375.sha.df55ffa/codacy-cli-v2 analyze /Users/yevgenyokun/stacklink/stacklink/README.md --format sarif] args=[/Users/yevgenyokun/stacklink/stacklink/README.md]
677
+ 2026-04-12T21:43:17+01:00 [INFO] (codacy-cli-v2/cli-v2.go:27) Starting Codacy CLI version=1.0.0-main.375.sha.df55ffa (df55ffa) built at 2026-04-08T11:10:45Z
678
+ 2026-04-12T21:43:17+01:00 [INFO] (codacy-cli-v2/cmd/root.go:34) Executing CLI command command=analyze full_command=[/Users/yevgenyokun/Library/Caches/Codacy/codacy-cli-v2/1.0.0-main.375.sha.df55ffa/codacy-cli-v2 analyze /Users/yevgenyokun/stacklink/stacklink/README.md --format sarif] args=[/Users/yevgenyokun/stacklink/stacklink/README.md]
679
+ 2026-04-12T21:43:26+01:00 [INFO] (codacy-cli-v2/cli-v2.go:27) Starting Codacy CLI version=1.0.0-main.375.sha.df55ffa (df55ffa) built at 2026-04-08T11:10:45Z
680
+ 2026-04-12T21:43:26+01:00 [INFO] (codacy-cli-v2/cmd/root.go:34) Executing CLI command command=analyze full_command=[/Users/yevgenyokun/Library/Caches/Codacy/codacy-cli-v2/1.0.0-main.375.sha.df55ffa/codacy-cli-v2 analyze /Users/yevgenyokun/stacklink/stacklink/README.md --format sarif] args=[/Users/yevgenyokun/stacklink/stacklink/README.md]
681
+ 2026-04-12T21:43:39+01:00 [INFO] (codacy-cli-v2/cli-v2.go:27) Starting Codacy CLI version=1.0.0-main.375.sha.df55ffa (df55ffa) built at 2026-04-08T11:10:45Z
682
+ 2026-04-12T21:43:39+01:00 [INFO] (codacy-cli-v2/cmd/root.go:34) Executing CLI command args=[/Users/yevgenyokun/stacklink/stacklink/README.md] command=analyze full_command=[/Users/yevgenyokun/Library/Caches/Codacy/codacy-cli-v2/1.0.0-main.375.sha.df55ffa/codacy-cli-v2 analyze /Users/yevgenyokun/stacklink/stacklink/README.md --format sarif]
683
+ 2026-04-12T21:45:14+01:00 [INFO] (codacy-cli-v2/cli-v2.go:27) Starting Codacy CLI version=1.0.0-main.375.sha.df55ffa (df55ffa) built at 2026-04-08T11:10:45Z
684
+ 2026-04-12T21:45:14+01:00 [INFO] (codacy-cli-v2/cmd/root.go:34) Executing CLI command command=analyze full_command=[/Users/yevgenyokun/Library/Caches/Codacy/codacy-cli-v2/1.0.0-main.375.sha.df55ffa/codacy-cli-v2 analyze /Users/yevgenyokun/stacklink/stacklink/README.md --format sarif] args=[/Users/yevgenyokun/stacklink/stacklink/README.md]
685
+ 2026-04-12T21:54:30+01:00 [INFO] (codacy-cli-v2/cli-v2.go:27) Starting Codacy CLI version=1.0.0-main.375.sha.df55ffa (df55ffa) built at 2026-04-08T11:10:45Z
686
+ 2026-04-12T21:54:30+01:00 [INFO] (codacy-cli-v2/cmd/root.go:34) Executing CLI command args=[/Users/yevgenyokun/stacklink/stacklink/stacklink/metrics.py] command=analyze full_command=[/Users/yevgenyokun/Library/Caches/Codacy/codacy-cli-v2/1.0.0-main.375.sha.df55ffa/codacy-cli-v2 analyze /Users/yevgenyokun/stacklink/stacklink/stacklink/metrics.py --format sarif]
687
+ 2026-04-12T21:54:30+01:00 [INFO] (codacy-cli-v2/cmd/analyze.go:360) Config file found for tool tool=trivy toolConfigPath=.codacy/tools-configs/trivy.yaml
688
+ 2026-04-12T21:54:31+01:00 [INFO] (codacy-cli-v2/cmd/analyze.go:360) Config file found for tool tool=lizard toolConfigPath=.codacy/tools-configs/lizard.yaml
689
+ 2026-04-12T21:54:31+01:00 [INFO] (codacy-cli-v2/cmd/analyze.go:360) Config file found for tool tool=opengrep toolConfigPath=.codacy/tools-configs/semgrep.yaml
690
+ 2026-04-12T21:54:32+01:00 [INFO] (codacy-cli-v2/cmd/analyze.go:360) Config file found for tool tool=pylint toolConfigPath=.codacy/tools-configs/pylint.rc
691
+ 2026-04-12T21:54:58+01:00 [INFO] (codacy-cli-v2/cli-v2.go:27) Starting Codacy CLI version=1.0.0-main.375.sha.df55ffa (df55ffa) built at 2026-04-08T11:10:45Z
692
+ 2026-04-12T21:54:58+01:00 [INFO] (codacy-cli-v2/cmd/root.go:34) Executing CLI command command=analyze full_command=[/Users/yevgenyokun/Library/Caches/Codacy/codacy-cli-v2/1.0.0-main.375.sha.df55ffa/codacy-cli-v2 analyze /Users/yevgenyokun/stacklink/stacklink/stacklink/metrics.py --format sarif] args=[/Users/yevgenyokun/stacklink/stacklink/stacklink/metrics.py]
693
+ 2026-04-12T21:54:58+01:00 [INFO] (codacy-cli-v2/cmd/analyze.go:360) Config file found for tool tool=pylint toolConfigPath=.codacy/tools-configs/pylint.rc
694
+ 2026-04-12T21:54:58+01:00 [INFO] (codacy-cli-v2/cmd/analyze.go:360) Config file found for tool tool=trivy toolConfigPath=.codacy/tools-configs/trivy.yaml
695
+ 2026-04-12T21:54:58+01:00 [INFO] (codacy-cli-v2/cmd/analyze.go:360) Config file found for tool tool=lizard toolConfigPath=.codacy/tools-configs/lizard.yaml
696
+ 2026-04-12T21:54:59+01:00 [INFO] (codacy-cli-v2/cmd/analyze.go:360) Config file found for tool tool=opengrep toolConfigPath=.codacy/tools-configs/semgrep.yaml
697
+ 2026-04-12T21:56:03+01:00 [INFO] (codacy-cli-v2/cli-v2.go:27) Starting Codacy CLI version=1.0.0-main.375.sha.df55ffa (df55ffa) built at 2026-04-08T11:10:45Z
698
+ 2026-04-12T21:56:03+01:00 [INFO] (codacy-cli-v2/cmd/root.go:34) Executing CLI command command=analyze full_command=[/Users/yevgenyokun/Library/Caches/Codacy/codacy-cli-v2/1.0.0-main.375.sha.df55ffa/codacy-cli-v2 analyze /Users/yevgenyokun/stacklink/stacklink/pyproject.toml --format sarif] args=[/Users/yevgenyokun/stacklink/stacklink/pyproject.toml]
699
+ 2026-04-12T21:56:06+01:00 [INFO] (codacy-cli-v2/cli-v2.go:27) Starting Codacy CLI version=1.0.0-main.375.sha.df55ffa (df55ffa) built at 2026-04-08T11:10:45Z
700
+ 2026-04-12T21:56:06+01:00 [INFO] (codacy-cli-v2/cmd/root.go:34) Executing CLI command command=analyze full_command=[/Users/yevgenyokun/Library/Caches/Codacy/codacy-cli-v2/1.0.0-main.375.sha.df55ffa/codacy-cli-v2 analyze /Users/yevgenyokun/stacklink/stacklink/stacklink/__init__.py --format sarif] args=[/Users/yevgenyokun/stacklink/stacklink/stacklink/__init__.py]
701
+ 2026-04-12T21:56:06+01:00 [INFO] (codacy-cli-v2/cmd/analyze.go:360) Config file found for tool tool=pylint toolConfigPath=.codacy/tools-configs/pylint.rc
702
+ 2026-04-12T21:56:07+01:00 [INFO] (codacy-cli-v2/cmd/analyze.go:360) Config file found for tool tool=trivy toolConfigPath=.codacy/tools-configs/trivy.yaml
703
+ 2026-04-12T21:56:07+01:00 [INFO] (codacy-cli-v2/cmd/analyze.go:360) Config file found for tool tool=lizard toolConfigPath=.codacy/tools-configs/lizard.yaml
704
+ 2026-04-12T21:56:07+01:00 [INFO] (codacy-cli-v2/cmd/analyze.go:360) Config file found for tool tool=opengrep toolConfigPath=.codacy/tools-configs/semgrep.yaml
705
+ 2026-04-12T21:56:11+01:00 [INFO] (codacy-cli-v2/cli-v2.go:27) Starting Codacy CLI version=1.0.0-main.375.sha.df55ffa (df55ffa) built at 2026-04-08T11:10:45Z
706
+ 2026-04-12T21:56:11+01:00 [INFO] (codacy-cli-v2/cmd/root.go:34) Executing CLI command args=[/Users/yevgenyokun/stacklink/stacklink/tests/test_metrics.py] command=analyze full_command=[/Users/yevgenyokun/Library/Caches/Codacy/codacy-cli-v2/1.0.0-main.375.sha.df55ffa/codacy-cli-v2 analyze /Users/yevgenyokun/stacklink/stacklink/tests/test_metrics.py --format sarif]
707
+ 2026-04-12T21:56:11+01:00 [INFO] (codacy-cli-v2/cmd/analyze.go:360) Config file found for tool tool=trivy toolConfigPath=.codacy/tools-configs/trivy.yaml
708
+ 2026-04-12T21:56:11+01:00 [INFO] (codacy-cli-v2/cmd/analyze.go:360) Config file found for tool tool=lizard toolConfigPath=.codacy/tools-configs/lizard.yaml
709
+ 2026-04-12T21:56:11+01:00 [INFO] (codacy-cli-v2/cmd/analyze.go:360) Config file found for tool toolConfigPath=.codacy/tools-configs/semgrep.yaml tool=opengrep
710
+ 2026-04-12T21:56:12+01:00 [INFO] (codacy-cli-v2/cmd/analyze.go:360) Config file found for tool toolConfigPath=.codacy/tools-configs/pylint.rc tool=pylint
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: stacklink
3
- Version: 0.1.0
3
+ Version: 0.2.0
4
4
  Summary: Health checks, config validation, and structured logging for FastAPI
5
5
  Project-URL: Homepage, https://github.com/dressupdarling/stacklink
6
6
  Project-URL: Repository, https://github.com/dressupdarling/stacklink
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "stacklink"
7
- version = "0.1.0"
7
+ version = "0.2.0"
8
8
  description = "Health checks, config validation, and structured logging for FastAPI"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -1,8 +1,9 @@
1
- """stacklink — health checks, config validation, and structured logging for FastAPI."""
1
+ """stacklink — health, config, logging, and metrics for FastAPI."""
2
2
 
3
3
  from stacklink.config import Config, ConfigError, Field, StacklinkError
4
4
  from stacklink.health import HealthError, health
5
5
  from stacklink.logger import LoggerError, logger
6
+ from stacklink.metrics import MetricsError, metrics
6
7
 
7
8
  __all__ = [
8
9
  "Config",
@@ -10,7 +11,9 @@ __all__ = [
10
11
  "Field",
11
12
  "HealthError",
12
13
  "LoggerError",
14
+ "MetricsError",
13
15
  "StacklinkError",
14
16
  "health",
15
17
  "logger",
18
+ "metrics",
16
19
  ]
@@ -0,0 +1,192 @@
1
+ """Request metrics tracking and reporting for FastAPI applications."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+ from collections.abc import Awaitable, Callable
7
+ from http import HTTPStatus
8
+
9
+ from fastapi import FastAPI
10
+ from fastapi.requests import Request
11
+ from fastapi.responses import JSONResponse, Response
12
+
13
+ from stacklink.config import StacklinkError
14
+
15
+ CallNextFn = Callable[[Request], Awaitable[Response]]
16
+
17
+ _ERROR_INFO: dict[int, dict[str, str]] = {
18
+ 400: {
19
+ "meaning": "Bad Request",
20
+ "likely_cause": "Malformed request syntax or invalid parameters",
21
+ "quick_fix": "Validate request payload and query parameters before sending",
22
+ },
23
+ 401: {
24
+ "meaning": "Unauthorized",
25
+ "likely_cause": "Missing or invalid authentication credentials",
26
+ "quick_fix": "Ensure a valid Authorization header or token is provided",
27
+ },
28
+ 403: {
29
+ "meaning": "Forbidden",
30
+ "likely_cause": "Authenticated user lacks permission for this resource",
31
+ "quick_fix": "Check role and permission settings for the requesting user",
32
+ },
33
+ 404: {
34
+ "meaning": "Not Found",
35
+ "likely_cause": "Client requested a URL that does not exist",
36
+ "quick_fix": "Check your route definitions and client request URLs",
37
+ },
38
+ 405: {
39
+ "meaning": "Method Not Allowed",
40
+ "likely_cause": "HTTP method is not supported for this endpoint",
41
+ "quick_fix": "Verify the HTTP method matches the route definition",
42
+ },
43
+ 408: {
44
+ "meaning": "Request Timeout",
45
+ "likely_cause": "The server timed out waiting for the client request",
46
+ "quick_fix": "Check network latency and increase client timeout settings",
47
+ },
48
+ 422: {
49
+ "meaning": "Unprocessable Entity",
50
+ "likely_cause": "Request body failed validation rules",
51
+ "quick_fix": "Review the request schema and fix validation errors",
52
+ },
53
+ 429: {
54
+ "meaning": "Too Many Requests",
55
+ "likely_cause": "Client exceeded the allowed request rate",
56
+ "quick_fix": "Implement backoff/retry logic and respect rate limit headers",
57
+ },
58
+ 500: {
59
+ "meaning": "Internal Server Error",
60
+ "likely_cause": "Unhandled exception in a route handler",
61
+ "quick_fix": "Check application logs for traceback details",
62
+ },
63
+ 502: {
64
+ "meaning": "Bad Gateway",
65
+ "likely_cause": "Upstream service returned an invalid response",
66
+ "quick_fix": "Verify that upstream services are running and reachable",
67
+ },
68
+ 503: {
69
+ "meaning": "Service Unavailable",
70
+ "likely_cause": "Service is temporarily overloaded or under maintenance",
71
+ "quick_fix": "Check resource usage and consider scaling or restarting",
72
+ },
73
+ 504: {
74
+ "meaning": "Gateway Timeout",
75
+ "likely_cause": "Upstream service did not respond in time",
76
+ "quick_fix": "Increase upstream timeout or investigate upstream latency",
77
+ },
78
+ }
79
+
80
+
81
+ def _error_detail(status_code: int, count: int) -> dict[str, object]:
82
+ """Build an error detail dict for a given status code and count."""
83
+ if status_code in _ERROR_INFO:
84
+ info = _ERROR_INFO[status_code]
85
+ else:
86
+ try:
87
+ phrase = HTTPStatus(status_code).phrase
88
+ except ValueError:
89
+ phrase = "Unknown Error"
90
+ info = {
91
+ "meaning": phrase,
92
+ "likely_cause": "An uncommon HTTP error occurred",
93
+ "quick_fix": "Inspect application logs for more details",
94
+ }
95
+ result: dict[str, object] = {"count": count}
96
+ result.update(info)
97
+ return result
98
+
99
+
100
+ class MetricsError(StacklinkError):
101
+ """Raised when the metrics module encounters an error."""
102
+
103
+
104
+ class MetricsRegistry:
105
+ """Registry for request metrics collection and the /metrics endpoint."""
106
+
107
+ def __init__(self) -> None:
108
+ """Initialize an empty metrics registry."""
109
+ self._start_time: float | None = None
110
+ self._request_count: int = 0
111
+ self._error_count: int = 0
112
+ self._response_times: list[float] = []
113
+ self._error_breakdown: dict[int, int] = {}
114
+
115
+ def register(self, app: FastAPI) -> None:
116
+ """Attach /metrics endpoint and request-tracking middleware to a FastAPI app."""
117
+ self._start_time = time.monotonic()
118
+ app.add_api_route("/metrics", self._metrics_endpoint, methods=["GET"])
119
+ registry = self
120
+
121
+ @app.middleware("http")
122
+ async def _track_requests(
123
+ request: Request,
124
+ call_next: CallNextFn,
125
+ ) -> Response:
126
+ """Track request count, errors, and response time."""
127
+ if request.url.path == "/metrics":
128
+ return await call_next(request)
129
+
130
+ start = time.monotonic()
131
+ response = await call_next(request)
132
+ elapsed = time.monotonic() - start
133
+
134
+ registry._request_count += 1
135
+ registry._response_times.append(elapsed)
136
+
137
+ if response.status_code >= 400:
138
+ registry._error_count += 1
139
+ code = response.status_code
140
+ registry._error_breakdown[code] = (
141
+ registry._error_breakdown.get(code, 0) + 1
142
+ )
143
+
144
+ return response
145
+
146
+ def reset(self) -> None:
147
+ """Clear all collected metrics."""
148
+ self._request_count = 0
149
+ self._error_count = 0
150
+ self._response_times.clear()
151
+ self._error_breakdown.clear()
152
+
153
+ def _uptime(self) -> float:
154
+ """Calculate seconds since register() was called."""
155
+ if self._start_time is None:
156
+ return 0.0
157
+ return round(time.monotonic() - self._start_time, 1)
158
+
159
+ def _avg_response_time_ms(self) -> float:
160
+ """Calculate average response time in milliseconds."""
161
+ if not self._response_times:
162
+ return 0.0
163
+ avg_seconds = sum(self._response_times) / len(self._response_times)
164
+ return round(avg_seconds * 1000, 1)
165
+
166
+ def _error_rate(self) -> float:
167
+ """Calculate the ratio of error responses to total requests."""
168
+ if self._request_count == 0:
169
+ return 0.0
170
+ return round(self._error_count / self._request_count, 4)
171
+
172
+ def _build_error_breakdown(self) -> dict[str, dict[str, object]]:
173
+ """Build the error breakdown with details for each status code."""
174
+ breakdown: dict[str, dict[str, object]] = {}
175
+ for code, count in sorted(self._error_breakdown.items()):
176
+ breakdown[str(code)] = _error_detail(code, count)
177
+ return breakdown
178
+
179
+ async def _metrics_endpoint(self) -> JSONResponse:
180
+ """Handle GET /metrics -- return collected metrics."""
181
+ data: dict[str, object] = {
182
+ "uptime_seconds": self._uptime(),
183
+ "request_count": self._request_count,
184
+ "error_count": self._error_count,
185
+ "error_rate": self._error_rate(),
186
+ "avg_response_time_ms": self._avg_response_time_ms(),
187
+ "errors": self._build_error_breakdown(),
188
+ }
189
+ return JSONResponse(content=data, status_code=200)
190
+
191
+
192
+ metrics = MetricsRegistry()
@@ -82,7 +82,8 @@ bubble up to the user. Always catch and re-raise as a stacklink exception.
82
82
  StacklinkError # base — catch this to catch anything from the library
83
83
  ├── ConfigError # anything that goes wrong in the config module
84
84
  ├── LoggerError # anything that goes wrong in the logger module
85
- └── HealthError # anything that goes wrong in the health module
85
+ ├── HealthError # anything that goes wrong in the health module
86
+ └── MetricsError # anything that goes wrong in the metrics module
86
87
  ```
87
88
 
88
89
  This means a user can catch `StacklinkError` broadly or `ConfigError` specifically.
@@ -0,0 +1,207 @@
1
+ """Tests for the metrics module."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+
7
+ import pytest
8
+ from fastapi import FastAPI
9
+ from fastapi.responses import JSONResponse
10
+ from httpx import ASGITransport, AsyncClient
11
+
12
+ from stacklink.metrics import MetricsRegistry
13
+
14
+
15
+ @pytest.fixture
16
+ def registry() -> MetricsRegistry:
17
+ return MetricsRegistry()
18
+
19
+
20
+ @pytest.fixture
21
+ def app(registry: MetricsRegistry) -> FastAPI:
22
+ application = FastAPI()
23
+
24
+ @application.get("/ok")
25
+ async def ok_route() -> dict[str, str]:
26
+ return {"status": "ok"}
27
+
28
+ @application.get("/not-found")
29
+ async def not_found_route() -> JSONResponse:
30
+ return JSONResponse(status_code=404, content={"error": "not found"})
31
+
32
+ @application.get("/server-error")
33
+ async def server_error_route() -> JSONResponse:
34
+ return JSONResponse(status_code=500, content={"error": "boom"})
35
+
36
+ @application.get("/teapot")
37
+ async def teapot_route() -> JSONResponse:
38
+ return JSONResponse(status_code=418, content={"error": "teapot"})
39
+
40
+ registry.register(application)
41
+ return application
42
+
43
+
44
+ class TestMetrics:
45
+ @pytest.mark.asyncio
46
+ async def test_metrics_returns_200(
47
+ self, app: FastAPI, registry: MetricsRegistry
48
+ ) -> None:
49
+ """GET /metrics returns 200."""
50
+ transport = ASGITransport(app=app)
51
+ async with AsyncClient(transport=transport, base_url="http://test") as c:
52
+ resp = await c.get("/metrics")
53
+ assert resp.status_code == 200
54
+
55
+ @pytest.mark.asyncio
56
+ async def test_metrics_response_is_valid_json(
57
+ self, app: FastAPI, registry: MetricsRegistry
58
+ ) -> None:
59
+ """Response body parses as JSON with expected keys."""
60
+ transport = ASGITransport(app=app)
61
+ async with AsyncClient(transport=transport, base_url="http://test") as c:
62
+ resp = await c.get("/metrics")
63
+ data = resp.json()
64
+ expected_keys = {
65
+ "uptime_seconds",
66
+ "request_count",
67
+ "error_count",
68
+ "error_rate",
69
+ "avg_response_time_ms",
70
+ "errors",
71
+ }
72
+ assert expected_keys <= set(data.keys())
73
+
74
+ @pytest.mark.asyncio
75
+ async def test_request_count_increments(
76
+ self, app: FastAPI, registry: MetricsRegistry
77
+ ) -> None:
78
+ """Making 3 requests increments request_count to 3."""
79
+ transport = ASGITransport(app=app)
80
+ async with AsyncClient(transport=transport, base_url="http://test") as c:
81
+ await c.get("/ok")
82
+ await c.get("/ok")
83
+ await c.get("/ok")
84
+ resp = await c.get("/metrics")
85
+ assert resp.json()["request_count"] == 3
86
+
87
+ @pytest.mark.asyncio
88
+ async def test_error_count_tracks_4xx_and_5xx(
89
+ self, app: FastAPI, registry: MetricsRegistry
90
+ ) -> None:
91
+ """4xx and 5xx responses increment error_count."""
92
+ transport = ASGITransport(app=app)
93
+ async with AsyncClient(transport=transport, base_url="http://test") as c:
94
+ await c.get("/not-found")
95
+ await c.get("/server-error")
96
+ resp = await c.get("/metrics")
97
+ assert resp.json()["error_count"] == 2
98
+
99
+ @pytest.mark.asyncio
100
+ async def test_error_rate_calculated(
101
+ self, app: FastAPI, registry: MetricsRegistry
102
+ ) -> None:
103
+ """error_rate = error_count / request_count."""
104
+ transport = ASGITransport(app=app)
105
+ async with AsyncClient(transport=transport, base_url="http://test") as c:
106
+ await c.get("/ok")
107
+ await c.get("/ok")
108
+ await c.get("/not-found")
109
+ await c.get("/server-error")
110
+ resp = await c.get("/metrics")
111
+ data = resp.json()
112
+ assert data["error_rate"] == 2 / 4
113
+
114
+ @pytest.mark.asyncio
115
+ async def test_avg_response_time_is_positive(
116
+ self, app: FastAPI, registry: MetricsRegistry
117
+ ) -> None:
118
+ """avg_response_time_ms > 0 after at least one request."""
119
+ transport = ASGITransport(app=app)
120
+ async with AsyncClient(transport=transport, base_url="http://test") as c:
121
+ await c.get("/ok")
122
+ resp = await c.get("/metrics")
123
+ assert resp.json()["avg_response_time_ms"] > 0
124
+
125
+ @pytest.mark.asyncio
126
+ async def test_error_breakdown_by_status_code(
127
+ self, app: FastAPI, registry: MetricsRegistry
128
+ ) -> None:
129
+ """errors['404'] has count, meaning, likely_cause, quick_fix."""
130
+ transport = ASGITransport(app=app)
131
+ async with AsyncClient(transport=transport, base_url="http://test") as c:
132
+ await c.get("/not-found")
133
+ resp = await c.get("/metrics")
134
+ errors = resp.json()["errors"]
135
+ assert "404" in errors
136
+ entry = errors["404"]
137
+ assert entry["count"] == 1
138
+ assert "meaning" in entry
139
+ assert "likely_cause" in entry
140
+ assert "quick_fix" in entry
141
+
142
+ @pytest.mark.asyncio
143
+ async def test_metrics_endpoint_excludes_itself(
144
+ self, app: FastAPI, registry: MetricsRegistry
145
+ ) -> None:
146
+ """/metrics requests are not counted in metrics."""
147
+ transport = ASGITransport(app=app)
148
+ async with AsyncClient(transport=transport, base_url="http://test") as c:
149
+ await c.get("/metrics")
150
+ await c.get("/metrics")
151
+ resp = await c.get("/metrics")
152
+ assert resp.json()["request_count"] == 0
153
+
154
+ @pytest.mark.asyncio
155
+ async def test_uptime_increases(
156
+ self, app: FastAPI, registry: MetricsRegistry
157
+ ) -> None:
158
+ """uptime_seconds > 0 after a brief delay."""
159
+ await asyncio.sleep(0.15)
160
+ transport = ASGITransport(app=app)
161
+ async with AsyncClient(transport=transport, base_url="http://test") as c:
162
+ resp = await c.get("/metrics")
163
+ assert resp.json()["uptime_seconds"] > 0
164
+
165
+ @pytest.mark.asyncio
166
+ async def test_reset_clears_all_metrics(
167
+ self, app: FastAPI, registry: MetricsRegistry
168
+ ) -> None:
169
+ """reset() zeroes all counters."""
170
+ transport = ASGITransport(app=app)
171
+ async with AsyncClient(transport=transport, base_url="http://test") as c:
172
+ await c.get("/ok")
173
+ await c.get("/not-found")
174
+ registry.reset()
175
+ resp = await c.get("/metrics")
176
+ data = resp.json()
177
+ assert data["request_count"] == 0
178
+ assert data["error_count"] == 0
179
+ assert data["avg_response_time_ms"] == 0.0
180
+ assert data["errors"] == {}
181
+
182
+ @pytest.mark.asyncio
183
+ async def test_error_rate_zero_when_no_requests(
184
+ self, app: FastAPI, registry: MetricsRegistry
185
+ ) -> None:
186
+ """error_rate is 0.0 when no requests have been made."""
187
+ transport = ASGITransport(app=app)
188
+ async with AsyncClient(transport=transport, base_url="http://test") as c:
189
+ resp = await c.get("/metrics")
190
+ assert resp.json()["error_rate"] == 0.0
191
+
192
+ @pytest.mark.asyncio
193
+ async def test_unknown_error_code_has_generic_entry(
194
+ self, app: FastAPI, registry: MetricsRegistry
195
+ ) -> None:
196
+ """A 418 response gets a generic error entry."""
197
+ transport = ASGITransport(app=app)
198
+ async with AsyncClient(transport=transport, base_url="http://test") as c:
199
+ await c.get("/teapot")
200
+ resp = await c.get("/metrics")
201
+ errors = resp.json()["errors"]
202
+ assert "418" in errors
203
+ entry = errors["418"]
204
+ assert entry["count"] == 1
205
+ assert "meaning" in entry
206
+ assert "likely_cause" in entry
207
+ assert "quick_fix" in entry
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
File without changes
File without changes
File without changes
File without changes