guardianhub 0.1.88__py3-none-any.whl

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 (64) hide show
  1. guardianhub/__init__.py +29 -0
  2. guardianhub/_version.py +1 -0
  3. guardianhub/agents/runtime.py +12 -0
  4. guardianhub/auth/token_provider.py +22 -0
  5. guardianhub/clients/__init__.py +2 -0
  6. guardianhub/clients/classification_client.py +52 -0
  7. guardianhub/clients/graph_db_client.py +161 -0
  8. guardianhub/clients/langfuse/dataset_client.py +157 -0
  9. guardianhub/clients/langfuse/manager.py +118 -0
  10. guardianhub/clients/langfuse/prompt_client.py +68 -0
  11. guardianhub/clients/langfuse/score_evaluation_client.py +92 -0
  12. guardianhub/clients/langfuse/tracing_client.py +250 -0
  13. guardianhub/clients/langfuse_client.py +63 -0
  14. guardianhub/clients/llm_client.py +144 -0
  15. guardianhub/clients/llm_service.py +295 -0
  16. guardianhub/clients/metadata_extractor_client.py +53 -0
  17. guardianhub/clients/ocr_client.py +81 -0
  18. guardianhub/clients/paperless_client.py +515 -0
  19. guardianhub/clients/registry_client.py +18 -0
  20. guardianhub/clients/text_cleaner_client.py +58 -0
  21. guardianhub/clients/vector_client.py +344 -0
  22. guardianhub/config/__init__.py +0 -0
  23. guardianhub/config/config_development.json +84 -0
  24. guardianhub/config/config_prod.json +39 -0
  25. guardianhub/config/settings.py +221 -0
  26. guardianhub/http/http_client.py +26 -0
  27. guardianhub/logging/__init__.py +2 -0
  28. guardianhub/logging/logging.py +168 -0
  29. guardianhub/logging/logging_filters.py +35 -0
  30. guardianhub/models/__init__.py +0 -0
  31. guardianhub/models/agent_models.py +153 -0
  32. guardianhub/models/base.py +2 -0
  33. guardianhub/models/registry/client.py +16 -0
  34. guardianhub/models/registry/dynamic_loader.py +73 -0
  35. guardianhub/models/registry/loader.py +37 -0
  36. guardianhub/models/registry/registry.py +17 -0
  37. guardianhub/models/registry/signing.py +70 -0
  38. guardianhub/models/template/__init__.py +0 -0
  39. guardianhub/models/template/agent_plan.py +65 -0
  40. guardianhub/models/template/agent_response_evaluation.py +67 -0
  41. guardianhub/models/template/extraction.py +29 -0
  42. guardianhub/models/template/reflection_critique.py +206 -0
  43. guardianhub/models/template/suggestion.py +42 -0
  44. guardianhub/observability/__init__.py +1 -0
  45. guardianhub/observability/instrumentation.py +271 -0
  46. guardianhub/observability/otel_helper.py +43 -0
  47. guardianhub/observability/otel_middlewares.py +73 -0
  48. guardianhub/prompts/base.py +7 -0
  49. guardianhub/prompts/providers/langfuse_provider.py +13 -0
  50. guardianhub/prompts/providers/local_provider.py +22 -0
  51. guardianhub/prompts/registry.py +14 -0
  52. guardianhub/scripts/script.sh +31 -0
  53. guardianhub/services/base.py +15 -0
  54. guardianhub/template/__init__.py +0 -0
  55. guardianhub/tools/gh_registry_cli.py +171 -0
  56. guardianhub/utils/__init__.py +0 -0
  57. guardianhub/utils/app_state.py +74 -0
  58. guardianhub/utils/fastapi_utils.py +152 -0
  59. guardianhub/utils/json_utils.py +137 -0
  60. guardianhub/utils/metrics.py +60 -0
  61. guardianhub-0.1.88.dist-info/METADATA +240 -0
  62. guardianhub-0.1.88.dist-info/RECORD +64 -0
  63. guardianhub-0.1.88.dist-info/WHEEL +4 -0
  64. guardianhub-0.1.88.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,73 @@
1
+ from typing import Optional
2
+
3
+ from fastapi import Request
4
+ from opentelemetry import baggage
5
+ from opentelemetry import trace
6
+ from opentelemetry.context import Context
7
+ from opentelemetry.propagate import extract
8
+ from opentelemetry.sdk.trace import SpanProcessor
9
+ from opentelemetry.sdk.trace.sampling import (
10
+ Sampler,
11
+ SamplingResult,
12
+ Decision,
13
+ )
14
+ from opentelemetry.trace import Span
15
+
16
+
17
+ async def bind_otel_context(request: Request, call_next):
18
+ # 1️⃣ Extract OTEL context from headers
19
+ ctx = extract(request.headers)
20
+
21
+ # 2️⃣ Attach context so downstream sees it
22
+ with trace.use_span(trace.get_current_span(ctx), end_on_exit=False):
23
+ return await call_next(request)
24
+
25
+
26
+ class GuardianHubSampler(Sampler):
27
+ """
28
+ Drops low-level ASGI spans (http receive/send)
29
+ Keeps route, agent, and custom spans.
30
+ """
31
+
32
+ def should_sample(
33
+ self,
34
+ parent_context,
35
+ trace_id,
36
+ name,
37
+ kind=None,
38
+ attributes=None,
39
+ links=None,
40
+ ) -> SamplingResult:
41
+
42
+ lname = (name or "").lower()
43
+
44
+ if "http receive" in lname or "http send" in lname:
45
+ return SamplingResult(Decision.DROP)
46
+
47
+ return SamplingResult(Decision.RECORD_AND_SAMPLE)
48
+
49
+ def get_description(self) -> str:
50
+ return "GuardianHub Sampler (drop ASGI receive/send)"
51
+
52
+ class BaggageToSpanProcessor(SpanProcessor):
53
+ """
54
+ Copies selected baggage values into span attributes
55
+ so they appear on every span.
56
+ """
57
+
58
+ BAGGAGE_KEYS = (
59
+ "user_id",
60
+ "session_id",
61
+ )
62
+
63
+ def on_start( self,
64
+ span: Span,
65
+ parent_context: Optional[Context] = None,
66
+ ) -> None:
67
+ for key in self.BAGGAGE_KEYS:
68
+ value = baggage.get_baggage(key, parent_context)
69
+ if value is not None:
70
+ span.set_attribute(key, str(value))
71
+
72
+ def on_end(self, span):
73
+ pass
@@ -0,0 +1,7 @@
1
+ from typing import Optional, Dict, Any
2
+ from abc import ABC, abstractmethod
3
+
4
+ class PromptProvider(ABC):
5
+ @abstractmethod
6
+ async def get_prompt(self, name: str, version: Optional[str] = None) -> Dict[str, Any]:
7
+ pass
@@ -0,0 +1,13 @@
1
+ class Langfuse:
2
+ def __init__(self, secret_key: str, host: str):
3
+ self.secret_key = secret_key
4
+ self.host = host
5
+ from typing import Optional, Dict, Any
6
+ from ..base import PromptProvider
7
+ class LangfusePromptProvider(PromptProvider):
8
+ def __init__(self, api_key: str, host: str):
9
+ self.client = Langfuse(secret_key=api_key, host=host)
10
+
11
+ async def get_prompt(self, name: str, version: Optional[str] = None) -> Dict[str, Any]:
12
+ prompt = self.client.get_prompt(name=name, version=version)
13
+ return prompt.to_dict()
@@ -0,0 +1,22 @@
1
+ import yaml, os
2
+ from typing import Optional, Dict, Any
3
+
4
+
5
+ class LocalPromptProvider(PromptProvider):
6
+ def __init__(self, directory: str):
7
+ self.directory = directory
8
+
9
+ async def get_prompt(self, name: str, version: Optional[str] = None) -> Dict[str, Any]:
10
+ filename = f"{name}.yaml"
11
+ path = os.path.join(self.directory, filename)
12
+
13
+ if not os.path.exists(path):
14
+ raise FileNotFoundError(f"Prompt '{name}' not found locally")
15
+
16
+ with open(path, "r") as f:
17
+ data = yaml.safe_load(f)
18
+
19
+ if version and "versions" in data:
20
+ return data["versions"].get(version)
21
+
22
+ return data
@@ -0,0 +1,14 @@
1
+ from typing import Optional, List
2
+ from .base import PromptProvider
3
+
4
+ class PromptRegistry:
5
+ def __init__(self, providers: List[PromptProvider]):
6
+ self.providers = providers
7
+
8
+ async def get_prompt(self, name: str, version: Optional[str] = None):
9
+ for provider in self.providers:
10
+ try:
11
+ return await provider.get_prompt(name, version)
12
+ except:
13
+ continue
14
+ raise ValueError(f"Prompt '{name}' not found in any provider")
@@ -0,0 +1,31 @@
1
+ pip install hatch
2
+
3
+ rm -rf dist
4
+ poetry version 0.1.15
5
+ hatch build
6
+ git tag v0.1.15
7
+ git push --tags
8
+
9
+
10
+
11
+ poetry env remove python3.13
12
+ rm -rf ~/.cache/pypoetry
13
+ rm -rf ~/.local/share/pypoetry
14
+
15
+
16
+ curl -sSL https://install.python-poetry.org | python3 -
17
+
18
+
19
+ poetry config virtualenvs.in-project true
20
+ poetry config virtualenvs.create true
21
+ poetry config virtualenvs.prefer-active-python true
22
+
23
+ python3.13 -m venv .venv
24
+ source .venv/bin/activate
25
+
26
+ poetry install
27
+
28
+
29
+
30
+
31
+ ./scripts/bump_version.sh patch
@@ -0,0 +1,15 @@
1
+ # guardianhub_sdk/services/base.py
2
+ from typing import Optional
3
+ from guardianhub import get_logger
4
+
5
+ class BaseServiceClient:
6
+ def __init__(self, base_url: str, token_provider=None, logger_name: Optional[str] = None):
7
+ self.base_url = base_url.rstrip('/')
8
+ self.token_provider = token_provider
9
+ self.logger = get_logger(logger_name or __name__)
10
+
11
+ async def _auth_headers(self) -> dict:
12
+ if not self.token_provider:
13
+ return {}
14
+ token = await self.token_provider.get_token()
15
+ return {"Authorization": f"Bearer {token}"}
File without changes
@@ -0,0 +1,171 @@
1
+ # guardianhub_sdk/tools/gh_registry_cli.py
2
+ import argparse
3
+ import json
4
+ import os
5
+ import sys
6
+ import tempfile
7
+ import shutil
8
+ import hashlib
9
+ from pathlib import Path
10
+ from typing import Optional, Dict, Any
11
+
12
+ import httpx
13
+
14
+ from ..models.registry.signing import generate_rsa_keypair, sign_metadata_dict
15
+ from guardianhub import get_logger
16
+
17
+ logger = get_logger(__name__)
18
+
19
+ def compute_sha256(path: Path) -> str:
20
+ h = hashlib.sha256()
21
+ with open(path, "rb") as fh:
22
+ for chunk in iter(lambda: fh.read(8192), b""):
23
+ h.update(chunk)
24
+ return h.hexdigest()
25
+
26
+
27
+ def pack_directory_to_zip(src: Path, dest: Path) -> None:
28
+ """
29
+ Create a zip artifact containing the src directory contents.
30
+ This is a lightweight 'wheel-like' artifact (not a proper wheel) but suitable
31
+ for the registry loader that accepts zipped source.
32
+ """
33
+ import zipfile
34
+ with zipfile.ZipFile(dest, "w", compression=zipfile.ZIP_DEFLATED) as zf:
35
+ base = src.resolve()
36
+ for p in sorted(src.rglob("*")):
37
+ arcname = p.relative_to(base)
38
+ zf.write(p, arcname)
39
+
40
+
41
+ def pack_module_to_archive(source_path: Path, artifact_path: Path) -> None:
42
+ """
43
+ If source_path is a .py file -> zip it; if directory -> zip contents; if tar/wheel requested, you can extend.
44
+ """
45
+ if source_path.is_file() and source_path.suffix == ".py":
46
+ # create zip with single file at top-level
47
+ import zipfile
48
+ with zipfile.ZipFile(artifact_path, "w", compression=zipfile.ZIP_DEFLATED) as zf:
49
+ zf.write(source_path, source_path.name)
50
+ elif source_path.is_dir():
51
+ pack_directory_to_zip(source_path, artifact_path)
52
+ else:
53
+ raise ValueError("Unsupported source path for packing: %s" % source_path)
54
+
55
+
56
+ def create_metadata(name: str, version: str, artifact_filename: str, sha256: str, module: Optional[str] = None, class_name: Optional[str] = None) -> Dict[str, Any]:
57
+ meta = {
58
+ "name": name,
59
+ "version": version,
60
+ "artifact_filename": artifact_filename,
61
+ "sha256": sha256,
62
+ }
63
+ if module:
64
+ meta["module"] = module
65
+ if class_name:
66
+ meta["class"] = class_name
67
+ return meta
68
+
69
+
70
+ def upload_artifact(registry_base: str, artifact_path: Path, metadata_signed: Dict[str, Any], api_key: Optional[str] = None) -> httpx.Response:
71
+ """
72
+ POST artifact and metadata to registry. Assumes registry has an endpoint:
73
+ POST {registry_base}/models/{name}/{version}/upload
74
+ which accepts multipart/form-data:
75
+ - file -> artifact
76
+ - metadata -> metadata json
77
+ """
78
+ name = metadata_signed["name"]
79
+ version = metadata_signed["version"]
80
+ url = registry_base.rstrip("/") + f"/models/{name}/{version}/upload"
81
+ headers = {}
82
+ if api_key:
83
+ headers["Authorization"] = f"Bearer {api_key}"
84
+
85
+ with open(artifact_path, "rb") as af:
86
+ files = {
87
+ "file": (artifact_path.name, af, "application/octet-stream"),
88
+ "metadata": (None, json.dumps(metadata_signed), "application/json"),
89
+ }
90
+ resp = httpx.post(url, files=files, headers=headers, timeout=60)
91
+ resp.raise_for_status()
92
+ return resp
93
+
94
+
95
+ def cli_pack_sign_publish(args):
96
+ src = Path(args.source).resolve()
97
+ if not src.exists():
98
+ logger.error("Source path does not exist: %s", src)
99
+ sys.exit(2)
100
+
101
+ tmpdir = Path(tempfile.mkdtemp(prefix="gh_pack_"))
102
+ try:
103
+ artifact_name = f"{args.name}-{args.version}.zip"
104
+ artifact_path = tmpdir / artifact_name
105
+ pack_module_to_archive(src, artifact_path)
106
+ sha256 = compute_sha256(artifact_path)
107
+ logger.info("Packed artifact at %s (sha256=%s)", artifact_path, sha256)
108
+
109
+ # load/generate signing key
110
+ if args.private_key:
111
+ private_key_path = Path(args.private_key)
112
+ private_pem = private_key_path.read_bytes()
113
+ else:
114
+ # generate ephemeral keys (not recommended for production)
115
+ private_pem, public_pem = generate_rsa_keypair()
116
+ logger.warning("Generated ephemeral RSA keypair (use persistent KMS key in prod)")
117
+ metadata = create_metadata(args.name, args.version, artifact_name, sha256, module=args.module, class_name=args.class_name)
118
+ metadata_signed = sign_metadata_dict(private_pem, metadata)
119
+
120
+ if args.registry:
121
+ resp = upload_artifact(args.registry, artifact_path, metadata_signed, api_key=args.api_key)
122
+ logger.info("Upload response: %s", resp.text)
123
+ else:
124
+ # local output: write artifact + metadata
125
+ out_dir = Path(args.out_dir or ".").resolve()
126
+ out_dir.mkdir(parents=True, exist_ok=True)
127
+ final_artifact = out_dir / artifact_name
128
+ shutil.copy(str(artifact_path), str(final_artifact))
129
+ meta_file = out_dir / f"{args.name}-{args.version}.metadata.json"
130
+ meta_file.write_text(json.dumps(metadata_signed, indent=2))
131
+ logger.info("Wrote artifact %s and metadata %s", final_artifact, meta_file)
132
+
133
+ finally:
134
+ shutil.rmtree(tmpdir, ignore_errors=True)
135
+
136
+
137
+ def main(argv=None):
138
+ parser = argparse.ArgumentParser(prog="gh-registry-cli")
139
+ sub = parser.add_subparsers(dest="cmd", required=True)
140
+
141
+ pack = sub.add_parser("publish", help="Pack, sign and publish artifact")
142
+ pack.add_argument("--source", required=True, help="Path to module file (.py) or package directory")
143
+ pack.add_argument("--name", required=True, help="Model logical name")
144
+ pack.add_argument("--version", required=True, help="Model version (semver recommended)")
145
+ pack.add_argument("--module", required=False, help="Module path inside artifact (optional)")
146
+ pack.add_argument("--class-name", dest="class_name", required=False, help="Class name exported (optional)")
147
+ pack.add_argument("--private-key", required=False, help="Path to private PEM to sign metadata (optional). If absent, ephemeral key is generated (unsafe).")
148
+ pack.add_argument("--registry", required=False, help="Registry base url to upload to. If omitted, write to --out-dir")
149
+ pack.add_argument("--api-key", required=False, help="Registry API key")
150
+ pack.add_argument("--out-dir", required=False, help="Local output directory when --registry omitted")
151
+
152
+ genkey = sub.add_parser("gen-keys", help="Generate RSA keypair for signing")
153
+ genkey.add_argument("--out-private", required=True)
154
+ genkey.add_argument("--out-public", required=True)
155
+ genkey.add_argument("--bits", type=int, default=4096)
156
+
157
+ args = parser.parse_args(argv)
158
+
159
+ if args.cmd == "publish":
160
+ cli_pack_sign_publish(args)
161
+ elif args.cmd == "gen-keys":
162
+ priv, pub = generate_rsa_keypair(bits=args.bits)
163
+ Path(args.out_private).write_bytes(priv)
164
+ Path(args.out_public).write_bytes(pub)
165
+ logger.info("Wrote keypair to %s / %s", args.out_private, args.out_public)
166
+ else:
167
+ parser.print_help()
168
+
169
+
170
+ if __name__ == "__main__":
171
+ main()
File without changes
@@ -0,0 +1,74 @@
1
+ # guardian/app_state.py
2
+ #
3
+ # A simple singleton class for managing application-wide state.
4
+ # This ensures that all parts of the application can access a single,
5
+ # consistent state object.
6
+
7
+ class AppState:
8
+ """
9
+ Manages a global, application-wide state using the singleton pattern.
10
+ """
11
+ _instance = None
12
+ _state = {}
13
+
14
+ def __new__(cls):
15
+ """
16
+ Ensures only a single instance of AppState exists.
17
+ """
18
+ if cls._instance is None:
19
+ cls._instance = super(AppState, cls).__new__(cls)
20
+ return cls._instance
21
+
22
+ def set(self, key, value):
23
+ """
24
+ Sets a key-value pair in the global state.
25
+ """
26
+ self._state[key] = value
27
+
28
+ def get(self, key, default=None):
29
+ """
30
+ Retrieves a value from the global state.
31
+ """
32
+ return self._state.get(key, default)
33
+
34
+ def increment(self, key, value=1):
35
+ """
36
+ Increments a numeric value in the global state.
37
+
38
+ Args:
39
+ key: The key of the value to increment
40
+ value: The amount to increment by (default: 1)
41
+
42
+ Returns:
43
+ The new value after incrementing
44
+ """
45
+ current = self._state.get(key, 0)
46
+ if not isinstance(current, (int, float)):
47
+ current = 0
48
+ new_value = current + value
49
+ self._state[key] = new_value
50
+ return new_value
51
+
52
+ def decrement(self, key, value=1):
53
+ """
54
+ Decrements a numeric value in the global state.
55
+
56
+ Args:
57
+ key: The key of the value to decrement
58
+ value: The amount to decrement by (default: 1)
59
+
60
+ Returns:
61
+ The new value after decrementing
62
+ """
63
+ current = self._state.get(key, 0)
64
+ if not isinstance(current, (int, float)):
65
+ current = 0
66
+ new_value = current - value
67
+ self._state[key] = new_value
68
+ return new_value
69
+
70
+ def __repr__(self):
71
+ """
72
+ Provides a string representation of the current state.
73
+ """
74
+ return f"AppState(state={self._state})"
@@ -0,0 +1,152 @@
1
+ """Standardized FastAPI utilities for GuardianHub Microservices."""
2
+ import uuid
3
+ import time
4
+ from datetime import datetime
5
+ from typing import Any, Dict
6
+
7
+ from fastapi import FastAPI, Request, Response
8
+ from fastapi.middleware.cors import CORSMiddleware
9
+ from prometheus_client import CONTENT_TYPE_LATEST, generate_latest
10
+
11
+ from guardianhub.config.settings import settings
12
+ from guardianhub.logging.logging import get_logger
13
+ from guardianhub.observability.instrumentation import configure_instrumentation
14
+ from .app_state import AppState
15
+ from .metrics import setup_metrics, get_metrics_registry
16
+
17
+ logger = get_logger(__name__)
18
+
19
+ def initialize_guardian_app(app: FastAPI) -> None:
20
+ """
21
+ The 'Golden Path' for service initialization.
22
+
23
+ This single call handles:
24
+ 1. Settings & Env discovery
25
+ 2. OpenTelemetry Tracing (Incoming & Outgoing)
26
+ 3. Prometheus Metrics setup
27
+ 4. Custom GuardianHub Middleware (Tracing, Timing, Logging)
28
+ 5. Standardized Health & Metrics endpoints
29
+ """
30
+ # 1. Initialize App State
31
+ app_state = AppState()
32
+ app_state.set("startup_time", datetime.now())
33
+ app.state.app_state = app_state
34
+
35
+ # Add this "Gold Standard" Startup Log
36
+ banner = f"""
37
+ ╔════════════════════════════════════════════════════════════════╗
38
+ ║ GUARDIANHUB SDK INITIALIZED ║
39
+ ╠════════════════════════════════════════════════════════════════╣
40
+ ║ SERVICE: {settings.service.name:<49} ║
41
+ ║ ENVIRONMENT: {settings.endpoints.ENVIRONMENT:<49} ║
42
+ ║ OTEL ENDPT: {settings.endpoints.OTEL_EXPORTER_OTLP_ENDPOINT:<49} ║
43
+ ║ LANGFUSE_OTLP_TRACES_ENDPOINT: {settings.endpoints.LANGFUSE_OTLP_TRACES_ENDPOINT:<49} ║
44
+ ║ LANGFUSE_HOST: {settings.endpoints.LANGFUSE_HOST:<49} ║
45
+ ╚════════════════════════════════════════════════════════════════╝
46
+ """
47
+ for line in banner.strip().split('\n'):
48
+ logger.info(line)
49
+
50
+ # 2. Configure OpenTelemetry (OTEL)
51
+ # Automatically uses settings.endpoints.OTEL_EXPORTER_OTLP_ENDPOINT
52
+ configure_instrumentation(app)
53
+
54
+ # 3. Setup Prometheus Metrics
55
+ metrics_map = setup_metrics(settings.service.name)
56
+
57
+ # 4. Add Standard Middleware
58
+ _add_standard_middleware(app, metrics_map, app_state)
59
+
60
+ # 5. Attach System Endpoints (/health, /metrics)
61
+ _attach_system_endpoints(app, app_state)
62
+
63
+ logger.info(f"GuardianHub Service [{settings.service.name}] successfully initialized.")
64
+
65
+ def _add_standard_middleware(app: FastAPI, metrics: Dict[str, Any], app_state: AppState):
66
+ """Internal: Configures CORS and Observability logic."""
67
+
68
+ # CORS Logic - Pulling from our dynamic settings
69
+ app.add_middleware(
70
+ CORSMiddleware,
71
+ allow_origins=["*"], # Can be refined in settings.py later
72
+ allow_credentials=True,
73
+ allow_methods=["*"],
74
+ allow_headers=["*"],
75
+ )
76
+
77
+ @app.middleware("http")
78
+ async def observability_middleware(request: Request, call_next):
79
+ # Skip logic for internal paths
80
+ if request.url.path in ["/health", "/metrics"]:
81
+ return await call_next(request)
82
+
83
+ # Start tracking
84
+ request_id = request.headers.get("X-Request-ID", str(uuid.uuid4()))
85
+ start_time = time.time()
86
+
87
+ metrics['active_requests'].inc()
88
+ app_state.increment("active_requests")
89
+ app_state.increment("total_requests")
90
+
91
+ try:
92
+ response = await call_next(request)
93
+
94
+ # Calculate Duration
95
+ duration_ms = (time.time() - start_time) * 1000
96
+
97
+ # Update Prometheus
98
+ metrics['request_latency'].labels(
99
+ method=request.method,
100
+ endpoint=request.url.path
101
+ ).observe(duration_ms / 1000)
102
+
103
+ metrics['request_count'].labels(
104
+ method=request.method,
105
+ endpoint=request.url.path,
106
+ status_code=response.status_code
107
+ ).inc()
108
+
109
+ # Add Standard Headers
110
+ response.headers["X-Process-Time"] = f"{duration_ms:.2f}ms"
111
+ response.headers["X-Request-ID"] = request_id
112
+
113
+ logger.info(
114
+ f"{request.method} {request.url.path} | "
115
+ f"Status: {response.status_code} | "
116
+ f"Time: {duration_ms:.2f}ms"
117
+ )
118
+ return response
119
+
120
+ except Exception as e:
121
+ logger.error(f"Uncaught Exception in {request.url.path}: {str(e)}", exc_info=True)
122
+ raise
123
+ finally:
124
+ metrics['active_requests'].dec()
125
+ app_state.decrement("active_requests")
126
+
127
+ def _attach_system_endpoints(app: FastAPI, app_state: AppState):
128
+ """Internal: Sets up the /health and /metrics routes."""
129
+
130
+ @app.get("/health", tags=["System"])
131
+ async def health():
132
+ startup_time = app_state.get("startup_time")
133
+ uptime = (datetime.now() - startup_time).total_seconds()
134
+ return {
135
+ "status": "healthy",
136
+ "service": settings.service.name,
137
+ "environment": settings.endpoints.ENVIRONMENT,
138
+ "version": settings.service.id.split('-')[-1], # Example version extraction
139
+ "uptime_seconds": int(uptime),
140
+ "stats": {
141
+ "active_requests": app_state.get("active_requests", 0),
142
+ "total_requests": app_state.get("total_requests", 0)
143
+ }
144
+ }
145
+
146
+ @app.get("/metrics", tags=["System"])
147
+ async def metrics():
148
+ registry = get_metrics_registry()
149
+ return Response(
150
+ content=generate_latest(registry),
151
+ media_type=CONTENT_TYPE_LATEST
152
+ )