forgesight-fastapi 0.1.0__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.
- forgesight_fastapi/__init__.py +28 -0
- forgesight_fastapi/_config.py +98 -0
- forgesight_fastapi/_w3c.py +43 -0
- forgesight_fastapi/lifespan.py +47 -0
- forgesight_fastapi/middleware.py +187 -0
- forgesight_fastapi/py.typed +0 -0
- forgesight_fastapi-0.1.0.dist-info/METADATA +98 -0
- forgesight_fastapi-0.1.0.dist-info/RECORD +10 -0
- forgesight_fastapi-0.1.0.dist-info/WHEEL +4 -0
- forgesight_fastapi-0.1.0.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""ForgeSight FastAPI integration — request↔run correlation + flush-on-shutdown.
|
|
2
|
+
|
|
3
|
+
```python
|
|
4
|
+
from fastapi import FastAPI
|
|
5
|
+
from forgesight_fastapi import AgentForgeMiddleware, sdk_lifespan
|
|
6
|
+
|
|
7
|
+
app = FastAPI(lifespan=sdk_lifespan)
|
|
8
|
+
app.add_middleware(AgentForgeMiddleware)
|
|
9
|
+
```
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from ._config import DEFAULT_EXCLUDE_PATHS, SPAN_KINDS, install
|
|
15
|
+
from .lifespan import sdk_lifespan
|
|
16
|
+
from .middleware import AgentForgeMiddleware, HTTPServerError
|
|
17
|
+
|
|
18
|
+
__version__ = "0.1.0"
|
|
19
|
+
|
|
20
|
+
__all__ = [
|
|
21
|
+
"DEFAULT_EXCLUDE_PATHS",
|
|
22
|
+
"SPAN_KINDS",
|
|
23
|
+
"AgentForgeMiddleware",
|
|
24
|
+
"HTTPServerError",
|
|
25
|
+
"__version__",
|
|
26
|
+
"install",
|
|
27
|
+
"sdk_lifespan",
|
|
28
|
+
]
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Default resolution for the middleware: explicit kwarg → env → installed config → default.
|
|
2
|
+
|
|
3
|
+
``install`` (the ``forgesight.integrations`` entry point) stashes the ``integrations.fastapi``
|
|
4
|
+
config block so the middleware can read defaults from ``forgesight.yaml`` even though ASGI
|
|
5
|
+
middleware must be added explicitly in app code (it can't be auto-injected).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import logging
|
|
11
|
+
import os
|
|
12
|
+
from collections.abc import Sequence
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
_log = logging.getLogger("forgesight.fastapi")
|
|
16
|
+
|
|
17
|
+
SPAN_KINDS = ("agent_run", "workflow_run")
|
|
18
|
+
DEFAULT_SPAN_KIND = "agent_run"
|
|
19
|
+
DEFAULT_RUN_ID_HEADER = "x-agentforge-run-id"
|
|
20
|
+
DEFAULT_EXCLUDE_PATHS: tuple[str, ...] = (
|
|
21
|
+
"/health",
|
|
22
|
+
"/healthz",
|
|
23
|
+
"/metrics",
|
|
24
|
+
"/docs",
|
|
25
|
+
"/openapi.json",
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
_INSTALLED: dict[str, Any] = {}
|
|
29
|
+
_CONTENT_LOGGED = False
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def install(config: dict[str, Any] | None = None) -> bool:
|
|
33
|
+
"""Stash the ``integrations.fastapi`` config block as middleware defaults. Idempotent."""
|
|
34
|
+
cfg = dict(config or {})
|
|
35
|
+
if not cfg.get("enabled", True):
|
|
36
|
+
_INSTALLED.clear()
|
|
37
|
+
return False
|
|
38
|
+
_INSTALLED.clear()
|
|
39
|
+
_INSTALLED.update(cfg)
|
|
40
|
+
if cfg.get("capture_content"):
|
|
41
|
+
log_content_capture()
|
|
42
|
+
return True
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def log_content_capture() -> None:
|
|
46
|
+
"""INFO-once so request/response body capture is never silently on (P7)."""
|
|
47
|
+
global _CONTENT_LOGGED
|
|
48
|
+
if not _CONTENT_LOGGED:
|
|
49
|
+
_log.info("forgesight-fastapi: HTTP body capture is ON (request/response bodies)")
|
|
50
|
+
_CONTENT_LOGGED = True
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def resolve_span_kind(value: str | None) -> str:
|
|
54
|
+
resolved = (
|
|
55
|
+
value or os.environ.get("FORGESIGHT_FASTAPI_SPAN_KIND") or _INSTALLED.get("span_kind")
|
|
56
|
+
)
|
|
57
|
+
resolved = str(resolved) if resolved else DEFAULT_SPAN_KIND
|
|
58
|
+
if resolved not in SPAN_KINDS:
|
|
59
|
+
raise ValueError(f"span_kind must be one of {SPAN_KINDS}, got {resolved!r}")
|
|
60
|
+
return resolved
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def resolve_exclude_paths(value: Sequence[str] | None) -> tuple[str, ...]:
|
|
64
|
+
if value is not None:
|
|
65
|
+
return tuple(str(p) for p in value)
|
|
66
|
+
env = os.environ.get("FORGESIGHT_FASTAPI_EXCLUDE")
|
|
67
|
+
if env:
|
|
68
|
+
return tuple(p.strip() for p in env.split(",") if p.strip())
|
|
69
|
+
installed = _INSTALLED.get("exclude_paths")
|
|
70
|
+
if installed:
|
|
71
|
+
return tuple(str(p) for p in installed)
|
|
72
|
+
return DEFAULT_EXCLUDE_PATHS
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def resolve_include_routes(value: Sequence[str] | None) -> tuple[str, ...] | None:
|
|
76
|
+
if value is not None:
|
|
77
|
+
return tuple(str(p) for p in value)
|
|
78
|
+
installed = _INSTALLED.get("include_routes")
|
|
79
|
+
return tuple(str(p) for p in installed) if installed else None
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def resolve_capture_content(value: bool | None) -> bool | None:
|
|
83
|
+
if value is not None:
|
|
84
|
+
return value
|
|
85
|
+
env = os.environ.get("FORGESIGHT_FASTAPI_CAPTURE_CONTENT")
|
|
86
|
+
if env is not None:
|
|
87
|
+
return env.strip().lower() in ("1", "true", "yes", "on")
|
|
88
|
+
if "capture_content" in _INSTALLED:
|
|
89
|
+
return bool(_INSTALLED["capture_content"])
|
|
90
|
+
return None # inherit the global gate
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def resolve_run_id_header(value: str | None) -> str:
|
|
94
|
+
return (
|
|
95
|
+
value
|
|
96
|
+
or os.environ.get("FORGESIGHT_FASTAPI_RUN_ID_HEADER")
|
|
97
|
+
or str(_INSTALLED.get("run_id_header") or DEFAULT_RUN_ID_HEADER)
|
|
98
|
+
)
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""W3C ``traceparent`` extraction from ASGI request headers.
|
|
2
|
+
|
|
3
|
+
A FastAPI service is a hop in a distributed trace: when a gateway or upstream already
|
|
4
|
+
started a trace and sent ``traceparent``, the agent run must continue it (be a child),
|
|
5
|
+
not open a disconnected root. This tiny pure module is the one place that parses the
|
|
6
|
+
incoming header — malformed input degrades to ``None`` (a new local root), never raises.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from collections.abc import Iterable
|
|
12
|
+
|
|
13
|
+
_TRACEPARENT = b"traceparent"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def extract_parent(headers: Iterable[tuple[bytes, bytes]]) -> tuple[str, str] | None:
|
|
17
|
+
"""Return ``(trace_id, span_id)`` from the request's ``traceparent``, or ``None``."""
|
|
18
|
+
for name, value in headers:
|
|
19
|
+
if name.lower() == _TRACEPARENT:
|
|
20
|
+
return _parse(value.decode("latin-1"))
|
|
21
|
+
return None
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _parse(raw: str) -> tuple[str, str] | None:
|
|
25
|
+
parts = raw.split("-")
|
|
26
|
+
if len(parts) != 4:
|
|
27
|
+
return None
|
|
28
|
+
_version, trace_id, span_id, _flags = parts
|
|
29
|
+
if len(trace_id) != 32 or len(span_id) != 16:
|
|
30
|
+
return None
|
|
31
|
+
if not _is_hex(trace_id) or not _is_hex(span_id):
|
|
32
|
+
return None
|
|
33
|
+
if trace_id == "0" * 32 or span_id == "0" * 16:
|
|
34
|
+
return None
|
|
35
|
+
return trace_id, span_id
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _is_hex(value: str) -> bool:
|
|
39
|
+
try:
|
|
40
|
+
int(value, 16)
|
|
41
|
+
except ValueError:
|
|
42
|
+
return False
|
|
43
|
+
return True
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""``sdk_lifespan`` — configure the SDK on startup, flush cleanly on shutdown.
|
|
2
|
+
|
|
3
|
+
The SDK buffers records and flushes on a timer (feat-003); a rolling deploy / SIGTERM that
|
|
4
|
+
stops the process drops the in-flight batch unless someone flushes. ASGI servers run the
|
|
5
|
+
lifespan shutdown phase on SIGTERM, so wiring this guarantees "telemetry is not lost on a
|
|
6
|
+
clean deploy" by installation, not by discipline.
|
|
7
|
+
|
|
8
|
+
Usable directly (``FastAPI(lifespan=sdk_lifespan)``) or composed inside a user lifespan
|
|
9
|
+
(``async with sdk_lifespan(app): ...``). ``**configure_kwargs`` flow to ``configure()``.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from collections.abc import AsyncIterator
|
|
15
|
+
from contextlib import asynccontextmanager
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
from forgesight_core import configure, get_runtime
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@asynccontextmanager
|
|
22
|
+
async def sdk_lifespan(
|
|
23
|
+
app: Any = None,
|
|
24
|
+
*,
|
|
25
|
+
configure_sdk: bool = True,
|
|
26
|
+
flush_timeout_millis: int | None = None,
|
|
27
|
+
**configure_kwargs: Any,
|
|
28
|
+
) -> AsyncIterator[None]:
|
|
29
|
+
"""Lifespan: ``configure()`` on startup; ``force_flush()`` + ``shutdown()`` on shutdown.
|
|
30
|
+
|
|
31
|
+
``configure_sdk=False`` skips startup configuration (respect an already-configured SDK).
|
|
32
|
+
``flush_timeout_millis`` defaults to the runtime's bounded ``export_timeout_millis`` so a
|
|
33
|
+
wedged backend can't hang the shutdown.
|
|
34
|
+
"""
|
|
35
|
+
if configure_sdk:
|
|
36
|
+
configure(**configure_kwargs)
|
|
37
|
+
try:
|
|
38
|
+
yield
|
|
39
|
+
finally:
|
|
40
|
+
runtime = get_runtime()
|
|
41
|
+
timeout = (
|
|
42
|
+
flush_timeout_millis
|
|
43
|
+
if flush_timeout_millis is not None
|
|
44
|
+
else runtime.config.export_timeout_millis
|
|
45
|
+
)
|
|
46
|
+
runtime.force_flush(timeout)
|
|
47
|
+
runtime.shutdown(timeout)
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
"""``AgentForgeMiddleware`` — pure-ASGI middleware that correlates a request with an agent run.
|
|
2
|
+
|
|
3
|
+
Per request it: continues an incoming W3C trace (or starts a root), opens an
|
|
4
|
+
``agent_run`` / ``workflow_run`` span via the feat-002 runtime, binds it so the handler's
|
|
5
|
+
llm/tool/mcp calls nest under it, attaches ``http.route`` / ``http.method`` / status as
|
|
6
|
+
business metadata (FR-5), sets the ``run_id`` response header for correlation, and closes
|
|
7
|
+
the span with the response status (5xx ⇒ ERROR; 4xx ⇒ recorded; unhandled exception ⇒
|
|
8
|
+
ERROR + re-raise, FR-7).
|
|
9
|
+
|
|
10
|
+
Implemented as **pure ASGI** (not ``BaseHTTPMiddleware``) to avoid its streaming / lifespan
|
|
11
|
+
pitfalls (risk table §8). Request/response bodies are captured only when ``capture_content``
|
|
12
|
+
resolves true (P7).
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from collections.abc import Awaitable, Callable, Sequence
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
from forgesight_core import RunScope, WorkflowScope, get_runtime
|
|
21
|
+
from forgesight_core.context import (
|
|
22
|
+
TelemetryContext,
|
|
23
|
+
new_run_id,
|
|
24
|
+
reset_current_context,
|
|
25
|
+
set_current_context,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
from ._config import (
|
|
29
|
+
log_content_capture,
|
|
30
|
+
resolve_capture_content,
|
|
31
|
+
resolve_exclude_paths,
|
|
32
|
+
resolve_include_routes,
|
|
33
|
+
resolve_run_id_header,
|
|
34
|
+
resolve_span_kind,
|
|
35
|
+
)
|
|
36
|
+
from ._w3c import extract_parent
|
|
37
|
+
|
|
38
|
+
Scope = dict[str, Any]
|
|
39
|
+
Receive = Callable[[], Awaitable[dict[str, Any]]]
|
|
40
|
+
Send = Callable[[dict[str, Any]], Awaitable[None]]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class HTTPServerError(Exception):
|
|
44
|
+
"""Synthesised for a 5xx response so the run span records ``error.type`` (FR-7)."""
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class AgentForgeMiddleware:
|
|
48
|
+
"""ASGI middleware: one agent-run span per request, correlated and flushed cleanly."""
|
|
49
|
+
|
|
50
|
+
def __init__(
|
|
51
|
+
self,
|
|
52
|
+
app: Any,
|
|
53
|
+
*,
|
|
54
|
+
span_kind: str | None = None,
|
|
55
|
+
agent_name: str | Callable[[Any], str] = "fastapi-app",
|
|
56
|
+
exclude_paths: Sequence[str] | None = None,
|
|
57
|
+
include_routes: Sequence[str] | None = None,
|
|
58
|
+
capture_content: bool | None = None,
|
|
59
|
+
run_id_header: str | None = None,
|
|
60
|
+
) -> None:
|
|
61
|
+
self._app = app
|
|
62
|
+
self._span_kind = resolve_span_kind(span_kind)
|
|
63
|
+
self._agent_name = agent_name
|
|
64
|
+
self._exclude_paths = resolve_exclude_paths(exclude_paths)
|
|
65
|
+
self._include_routes = resolve_include_routes(include_routes)
|
|
66
|
+
self._capture_opt = resolve_capture_content(capture_content)
|
|
67
|
+
if self._capture_opt:
|
|
68
|
+
log_content_capture()
|
|
69
|
+
self._run_id_header = resolve_run_id_header(run_id_header).lower().encode("latin-1")
|
|
70
|
+
|
|
71
|
+
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
|
72
|
+
if scope.get("type") != "http" or not self._should_instrument(scope.get("path", "")):
|
|
73
|
+
await self._app(scope, receive, send)
|
|
74
|
+
return
|
|
75
|
+
|
|
76
|
+
token = self._bind_incoming_trace(scope)
|
|
77
|
+
try:
|
|
78
|
+
await self._handle(scope, receive, send)
|
|
79
|
+
finally:
|
|
80
|
+
if token is not None:
|
|
81
|
+
reset_current_context(token)
|
|
82
|
+
|
|
83
|
+
# --- internals --------------------------------------------------------
|
|
84
|
+
async def _handle(self, scope: Scope, receive: Receive, send: Send) -> None:
|
|
85
|
+
run_scope = self._open_scope(scope)
|
|
86
|
+
capture = self._resolve_capture()
|
|
87
|
+
status: dict[str, int] = {}
|
|
88
|
+
body_chunks: list[bytes] = []
|
|
89
|
+
|
|
90
|
+
async def send_wrapper(message: dict[str, Any]) -> None:
|
|
91
|
+
if message["type"] == "http.response.start":
|
|
92
|
+
status["code"] = message["status"]
|
|
93
|
+
headers = list(message.get("headers") or [])
|
|
94
|
+
headers.append((self._run_id_header, run_scope.run_id.encode("latin-1")))
|
|
95
|
+
message = {**message, "headers": headers}
|
|
96
|
+
await send(message)
|
|
97
|
+
|
|
98
|
+
async def receive_wrapper() -> dict[str, Any]:
|
|
99
|
+
message = await receive()
|
|
100
|
+
if message.get("type") == "http.request":
|
|
101
|
+
body_chunks.append(message.get("body", b""))
|
|
102
|
+
return message
|
|
103
|
+
|
|
104
|
+
downstream_receive = receive_wrapper if capture else receive
|
|
105
|
+
async with run_scope:
|
|
106
|
+
self._set_request_metadata(run_scope, scope)
|
|
107
|
+
try:
|
|
108
|
+
await self._app(scope, downstream_receive, send_wrapper)
|
|
109
|
+
finally:
|
|
110
|
+
self._finalize(run_scope, scope, status, capture, body_chunks)
|
|
111
|
+
|
|
112
|
+
def _bind_incoming_trace(self, scope: Scope) -> Any:
|
|
113
|
+
parent = extract_parent(scope.get("headers") or [])
|
|
114
|
+
if parent is None:
|
|
115
|
+
return None
|
|
116
|
+
trace_id, span_id = parent
|
|
117
|
+
return set_current_context(
|
|
118
|
+
TelemetryContext(run_id=new_run_id(), trace_id=trace_id, current_span_id=span_id)
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
def _open_scope(self, scope: Scope) -> RunScope | WorkflowScope:
|
|
122
|
+
name = self._resolve_agent_name(scope)
|
|
123
|
+
runtime = get_runtime()
|
|
124
|
+
if self._span_kind == "workflow_run":
|
|
125
|
+
return WorkflowScope(runtime, name=name)
|
|
126
|
+
return RunScope(runtime, name=name)
|
|
127
|
+
|
|
128
|
+
def _resolve_agent_name(self, scope: Scope) -> str:
|
|
129
|
+
if callable(self._agent_name):
|
|
130
|
+
from starlette.requests import Request
|
|
131
|
+
|
|
132
|
+
return str(self._agent_name(Request(scope)))
|
|
133
|
+
return self._agent_name
|
|
134
|
+
|
|
135
|
+
@staticmethod
|
|
136
|
+
def _set_request_metadata(run_scope: RunScope | WorkflowScope, scope: Scope) -> None:
|
|
137
|
+
run_scope.set_metadata(
|
|
138
|
+
**{"http.method": scope.get("method", ""), "http.target": scope.get("path", "")}
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
def _finalize(
|
|
142
|
+
self,
|
|
143
|
+
run_scope: RunScope | WorkflowScope,
|
|
144
|
+
scope: Scope,
|
|
145
|
+
status: dict[str, int],
|
|
146
|
+
capture: bool,
|
|
147
|
+
body_chunks: list[bytes],
|
|
148
|
+
) -> None:
|
|
149
|
+
run_scope.set_metadata(**{"http.route": _route_template(scope)})
|
|
150
|
+
code = status.get("code")
|
|
151
|
+
if code is not None:
|
|
152
|
+
run_scope.set_metadata(**{"http.status_code": code})
|
|
153
|
+
if code >= 500:
|
|
154
|
+
run_scope.record_error(HTTPServerError(f"HTTP {code} server error"), code=str(code))
|
|
155
|
+
if capture and body_chunks:
|
|
156
|
+
body = b"".join(body_chunks).decode("utf-8", "replace")
|
|
157
|
+
if body:
|
|
158
|
+
run_scope.set_metadata(**{"http.request.body": body})
|
|
159
|
+
|
|
160
|
+
def _should_instrument(self, path: str) -> bool:
|
|
161
|
+
if any(path.startswith(prefix) for prefix in self._exclude_paths):
|
|
162
|
+
return False
|
|
163
|
+
if self._include_routes is not None:
|
|
164
|
+
return any(path.startswith(prefix) for prefix in self._include_routes)
|
|
165
|
+
return True
|
|
166
|
+
|
|
167
|
+
def _resolve_capture(self) -> bool:
|
|
168
|
+
if self._capture_opt is not None:
|
|
169
|
+
return self._capture_opt
|
|
170
|
+
try:
|
|
171
|
+
return bool(get_runtime().config.capture_content)
|
|
172
|
+
except Exception: # pragma: no cover - runtime always present in practice
|
|
173
|
+
return False
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _route_template(scope: Scope) -> str:
|
|
177
|
+
"""Reconstruct the matched route template (``/agents/{id}/run``) for bounded cardinality.
|
|
178
|
+
|
|
179
|
+
Built from the raw path + ``path_params`` the router fills in after matching, so it
|
|
180
|
+
works regardless of whether the ASGI server exposes ``scope['route']``.
|
|
181
|
+
"""
|
|
182
|
+
path = str(scope.get("path", ""))
|
|
183
|
+
params = scope.get("path_params") or {}
|
|
184
|
+
template = path
|
|
185
|
+
for name, value in params.items():
|
|
186
|
+
template = template.replace(str(value), "{" + name + "}", 1)
|
|
187
|
+
return template
|
|
File without changes
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: forgesight-fastapi
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: ForgeSight FastAPI integration — ASGI middleware + lifespan flush; request↔run correlation.
|
|
5
|
+
Project-URL: Homepage, https://github.com/Scaffoldic/forgesight
|
|
6
|
+
Project-URL: Repository, https://github.com/Scaffoldic/forgesight
|
|
7
|
+
Project-URL: Issues, https://github.com/Scaffoldic/forgesight/issues
|
|
8
|
+
Project-URL: Changelog, https://github.com/Scaffoldic/forgesight/blob/main/docs/releases/v0.1.md
|
|
9
|
+
Author: kjoshi
|
|
10
|
+
License-Expression: Apache-2.0
|
|
11
|
+
Keywords: ai-agents,asgi,fastapi,forgesight,observability,starlette
|
|
12
|
+
Classifier: Development Status :: 2 - Pre-Alpha
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Intended Audience :: Information Technology
|
|
15
|
+
Classifier: License :: OSI Approved :: Apache Software License
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
20
|
+
Classifier: Topic :: System :: Monitoring
|
|
21
|
+
Classifier: Typing :: Typed
|
|
22
|
+
Requires-Python: >=3.11
|
|
23
|
+
Requires-Dist: forgesight-core
|
|
24
|
+
Requires-Dist: starlette>=0.37
|
|
25
|
+
Description-Content-Type: text/markdown
|
|
26
|
+
|
|
27
|
+
# forgesight-fastapi
|
|
28
|
+
|
|
29
|
+
The FastAPI integration for [ForgeSight](https://github.com/Scaffoldic/forgesight). Three
|
|
30
|
+
lines wire request↔run correlation, incoming-trace continuation, and flush-on-shutdown into
|
|
31
|
+
any FastAPI / Starlette app — no per-handler instrumentation.
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
pip install forgesight-fastapi
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
```python
|
|
38
|
+
from fastapi import FastAPI
|
|
39
|
+
from forgesight_fastapi import AgentForgeMiddleware, sdk_lifespan
|
|
40
|
+
|
|
41
|
+
app = FastAPI(lifespan=sdk_lifespan) # configure() on startup, flush on shutdown
|
|
42
|
+
app.add_middleware(AgentForgeMiddleware) # request → agent_run span, correlation
|
|
43
|
+
|
|
44
|
+
@app.post("/agents/pr-reviewer/run")
|
|
45
|
+
async def run(req: ReviewRequest):
|
|
46
|
+
# Unchanged agent code. The run span is already open and bound to this request;
|
|
47
|
+
# the agent's llm/tool/mcp calls nest under it automatically.
|
|
48
|
+
return await pr_reviewer.run(req.task)
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Compose with an existing lifespan:
|
|
52
|
+
|
|
53
|
+
```python
|
|
54
|
+
from contextlib import asynccontextmanager
|
|
55
|
+
from forgesight_fastapi import sdk_lifespan
|
|
56
|
+
|
|
57
|
+
@asynccontextmanager
|
|
58
|
+
async def lifespan(app):
|
|
59
|
+
async with sdk_lifespan(app):
|
|
60
|
+
await connect_db()
|
|
61
|
+
yield
|
|
62
|
+
await close_db()
|
|
63
|
+
|
|
64
|
+
app = FastAPI(lifespan=lifespan)
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
## What you get
|
|
68
|
+
|
|
69
|
+
- **Request↔run link.** The HTTP request and the agent run share a `trace_id`; the response
|
|
70
|
+
carries the `run_id` (header `x-agentforge-run-id`), so "request X was slow" jumps straight
|
|
71
|
+
to the run's span tree and cost.
|
|
72
|
+
- **Distributed traces just work.** An upstream `traceparent` is continued automatically —
|
|
73
|
+
the agent service is a child span, not a new root. No propagation code in the app.
|
|
74
|
+
- **Zero lost telemetry on deploy.** `sdk_lifespan` calls `force_flush()` + `shutdown()` on
|
|
75
|
+
the shutdown phase (which ASGI servers run on SIGTERM), with a bounded timeout so a wedged
|
|
76
|
+
backend can't hang the deploy.
|
|
77
|
+
- **Route-level metadata for free.** The matched route *template* (`/agents/{id}/run`, not
|
|
78
|
+
the raw path — bounded cardinality), method, and status land as span metadata (FR-5).
|
|
79
|
+
- **Correct error mapping.** 5xx ⇒ span ERROR + `error.type`; an unhandled exception is
|
|
80
|
+
recorded and re-raised (FR-7); 4xx is recorded without erroring the span.
|
|
81
|
+
|
|
82
|
+
Implemented as **pure ASGI** (not `BaseHTTPMiddleware`) to avoid streaming/lifespan pitfalls.
|
|
83
|
+
Request/response bodies are captured only when `capture_content` resolves true (P7).
|
|
84
|
+
|
|
85
|
+
## Configuration
|
|
86
|
+
|
|
87
|
+
| Key | Env | Default |
|
|
88
|
+
|---|---|---|
|
|
89
|
+
| `span_kind` | `FORGESIGHT_FASTAPI_SPAN_KIND` | `agent_run` (or `workflow_run`) |
|
|
90
|
+
| `exclude_paths` | `FORGESIGHT_FASTAPI_EXCLUDE` | `/health,/healthz,/metrics,/docs,/openapi.json` |
|
|
91
|
+
| `capture_content` | `FORGESIGHT_FASTAPI_CAPTURE_CONTENT` | `false` |
|
|
92
|
+
| `run_id_header` | `FORGESIGHT_FASTAPI_RUN_ID_HEADER` | `x-agentforge-run-id` |
|
|
93
|
+
|
|
94
|
+
Constructor kwargs win over env / `forgesight.yaml` (`integrations.fastapi`).
|
|
95
|
+
|
|
96
|
+
## License
|
|
97
|
+
|
|
98
|
+
Apache-2.0
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
forgesight_fastapi/__init__.py,sha256=IO2wwqB-fQHwTDsQeUx9bEUOilBADL4MBY9xAGF6plU,665
|
|
2
|
+
forgesight_fastapi/_config.py,sha256=2BbCSeBTnA8G7ng3-ztWRj6LGRpxcGNJ1NJC4Io24ts,3232
|
|
3
|
+
forgesight_fastapi/_w3c.py,sha256=sUsv68OUZ8HH8EtZlq0dq8GHKjmQNWxSvSmtfK5DbxE,1379
|
|
4
|
+
forgesight_fastapi/lifespan.py,sha256=fQIYGALsCzR2vd0Gg279VLnP6tXP9eE_RsbDV_MYjZs,1685
|
|
5
|
+
forgesight_fastapi/middleware.py,sha256=rcpRYiPPqdalF4FwsybEREhKY622Ky5846IH-ZgZiP0,7293
|
|
6
|
+
forgesight_fastapi/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
7
|
+
forgesight_fastapi-0.1.0.dist-info/METADATA,sha256=GvFmumEdzQYT5_FPkcpGR4HupwR5bturFN-pCiPV9G8,4053
|
|
8
|
+
forgesight_fastapi-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
9
|
+
forgesight_fastapi-0.1.0.dist-info/entry_points.txt,sha256=-0vHJUHXV013Ob_tQ7S4Np1Tbu-u1ZubVQlOUwh_ZMw,63
|
|
10
|
+
forgesight_fastapi-0.1.0.dist-info/RECORD,,
|