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.
@@ -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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [forgesight.integrations]
2
+ fastapi = forgesight_fastapi:install