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.
- {stacklink-0.1.0 → stacklink-0.2.0}/.claude/settings.local.json +2 -1
- {stacklink-0.1.0 → stacklink-0.2.0}/.codacy/logs/codacy-cli.log +42 -0
- {stacklink-0.1.0 → stacklink-0.2.0}/PKG-INFO +1 -1
- {stacklink-0.1.0 → stacklink-0.2.0}/pyproject.toml +1 -1
- {stacklink-0.1.0 → stacklink-0.2.0}/stacklink/__init__.py +4 -1
- stacklink-0.2.0/stacklink/metrics.py +192 -0
- {stacklink-0.1.0 → stacklink-0.2.0}/stacklink-practices.md +2 -1
- stacklink-0.2.0/tests/test_metrics.py +207 -0
- {stacklink-0.1.0 → stacklink-0.2.0}/.codacy/.gitignore +0 -0
- {stacklink-0.1.0 → stacklink-0.2.0}/.codacy/cli-config.yaml +0 -0
- {stacklink-0.1.0 → stacklink-0.2.0}/.codacy/cli.sh +0 -0
- {stacklink-0.1.0 → stacklink-0.2.0}/.codacy/codacy.yaml +0 -0
- {stacklink-0.1.0 → stacklink-0.2.0}/.codacy/tools-configs/analysis_options.yaml +0 -0
- {stacklink-0.1.0 → stacklink-0.2.0}/.codacy/tools-configs/eslint.config.mjs +0 -0
- {stacklink-0.1.0 → stacklink-0.2.0}/.codacy/tools-configs/languages-config.yaml +0 -0
- {stacklink-0.1.0 → stacklink-0.2.0}/.codacy/tools-configs/lizard.yaml +0 -0
- {stacklink-0.1.0 → stacklink-0.2.0}/.codacy/tools-configs/pylint.rc +0 -0
- {stacklink-0.1.0 → stacklink-0.2.0}/.codacy/tools-configs/revive.toml +0 -0
- {stacklink-0.1.0 → stacklink-0.2.0}/.codacy/tools-configs/ruleset.xml +0 -0
- {stacklink-0.1.0 → stacklink-0.2.0}/.codacy/tools-configs/semgrep.yaml +0 -0
- {stacklink-0.1.0 → stacklink-0.2.0}/.codacy/tools-configs/trivy.yaml +0 -0
- {stacklink-0.1.0 → stacklink-0.2.0}/.env.example +0 -0
- {stacklink-0.1.0 → stacklink-0.2.0}/.gitignore +0 -0
- {stacklink-0.1.0 → stacklink-0.2.0}/LICENSE +0 -0
- {stacklink-0.1.0 → stacklink-0.2.0}/README.md +0 -0
- {stacklink-0.1.0 → stacklink-0.2.0}/docs/config.md +0 -0
- {stacklink-0.1.0 → stacklink-0.2.0}/docs/health.md +0 -0
- {stacklink-0.1.0 → stacklink-0.2.0}/docs/logger.md +0 -0
- {stacklink-0.1.0 → stacklink-0.2.0}/examples/basic_fastapi/.env.example +0 -0
- {stacklink-0.1.0 → stacklink-0.2.0}/examples/basic_fastapi/main.py +0 -0
- {stacklink-0.1.0 → stacklink-0.2.0}/stacklink/config.py +0 -0
- {stacklink-0.1.0 → stacklink-0.2.0}/stacklink/health.py +0 -0
- {stacklink-0.1.0 → stacklink-0.2.0}/stacklink/logger.py +0 -0
- {stacklink-0.1.0 → stacklink-0.2.0}/stacklink-plan.md +0 -0
- {stacklink-0.1.0 → stacklink-0.2.0}/tests/__init__.py +0 -0
- {stacklink-0.1.0 → stacklink-0.2.0}/tests/test_config.py +0 -0
- {stacklink-0.1.0 → stacklink-0.2.0}/tests/test_health.py +0 -0
- {stacklink-0.1.0 → stacklink-0.2.0}/tests/test_logger.py +0 -0
|
@@ -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.
|
|
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
|
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
"""stacklink — health
|
|
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
|
-
|
|
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
|
|
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
|