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.
- guardianhub/__init__.py +29 -0
- guardianhub/_version.py +1 -0
- guardianhub/agents/runtime.py +12 -0
- guardianhub/auth/token_provider.py +22 -0
- guardianhub/clients/__init__.py +2 -0
- guardianhub/clients/classification_client.py +52 -0
- guardianhub/clients/graph_db_client.py +161 -0
- guardianhub/clients/langfuse/dataset_client.py +157 -0
- guardianhub/clients/langfuse/manager.py +118 -0
- guardianhub/clients/langfuse/prompt_client.py +68 -0
- guardianhub/clients/langfuse/score_evaluation_client.py +92 -0
- guardianhub/clients/langfuse/tracing_client.py +250 -0
- guardianhub/clients/langfuse_client.py +63 -0
- guardianhub/clients/llm_client.py +144 -0
- guardianhub/clients/llm_service.py +295 -0
- guardianhub/clients/metadata_extractor_client.py +53 -0
- guardianhub/clients/ocr_client.py +81 -0
- guardianhub/clients/paperless_client.py +515 -0
- guardianhub/clients/registry_client.py +18 -0
- guardianhub/clients/text_cleaner_client.py +58 -0
- guardianhub/clients/vector_client.py +344 -0
- guardianhub/config/__init__.py +0 -0
- guardianhub/config/config_development.json +84 -0
- guardianhub/config/config_prod.json +39 -0
- guardianhub/config/settings.py +221 -0
- guardianhub/http/http_client.py +26 -0
- guardianhub/logging/__init__.py +2 -0
- guardianhub/logging/logging.py +168 -0
- guardianhub/logging/logging_filters.py +35 -0
- guardianhub/models/__init__.py +0 -0
- guardianhub/models/agent_models.py +153 -0
- guardianhub/models/base.py +2 -0
- guardianhub/models/registry/client.py +16 -0
- guardianhub/models/registry/dynamic_loader.py +73 -0
- guardianhub/models/registry/loader.py +37 -0
- guardianhub/models/registry/registry.py +17 -0
- guardianhub/models/registry/signing.py +70 -0
- guardianhub/models/template/__init__.py +0 -0
- guardianhub/models/template/agent_plan.py +65 -0
- guardianhub/models/template/agent_response_evaluation.py +67 -0
- guardianhub/models/template/extraction.py +29 -0
- guardianhub/models/template/reflection_critique.py +206 -0
- guardianhub/models/template/suggestion.py +42 -0
- guardianhub/observability/__init__.py +1 -0
- guardianhub/observability/instrumentation.py +271 -0
- guardianhub/observability/otel_helper.py +43 -0
- guardianhub/observability/otel_middlewares.py +73 -0
- guardianhub/prompts/base.py +7 -0
- guardianhub/prompts/providers/langfuse_provider.py +13 -0
- guardianhub/prompts/providers/local_provider.py +22 -0
- guardianhub/prompts/registry.py +14 -0
- guardianhub/scripts/script.sh +31 -0
- guardianhub/services/base.py +15 -0
- guardianhub/template/__init__.py +0 -0
- guardianhub/tools/gh_registry_cli.py +171 -0
- guardianhub/utils/__init__.py +0 -0
- guardianhub/utils/app_state.py +74 -0
- guardianhub/utils/fastapi_utils.py +152 -0
- guardianhub/utils/json_utils.py +137 -0
- guardianhub/utils/metrics.py +60 -0
- guardianhub-0.1.88.dist-info/METADATA +240 -0
- guardianhub-0.1.88.dist-info/RECORD +64 -0
- guardianhub-0.1.88.dist-info/WHEEL +4 -0
- 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,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
|
+
)
|