forgesight-fastapi 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,38 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *.egg-info/
5
+ .eggs/
6
+ build/
7
+ dist/
8
+ *.so
9
+
10
+ # venv / tooling
11
+ .venv/
12
+ venv/
13
+ .uv/
14
+ uv.lock
15
+
16
+ # test / type / lint caches
17
+ .pytest_cache/
18
+ .mypy_cache/
19
+ .ruff_cache/
20
+ .coverage
21
+ .coverage.*
22
+ coverage.xml
23
+ htmlcov/
24
+
25
+ # secrets / local env (never commit)
26
+ .env
27
+ .env.*
28
+
29
+ # editor / OS
30
+ .DS_Store
31
+ .idea/
32
+ .vscode/
33
+
34
+ # local-only session working state (per the workspace pipeline)
35
+ .claude/state/
36
+
37
+ # local-only launch planning (not part of the published repo)
38
+ /launch/
@@ -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,72 @@
1
+ # forgesight-fastapi
2
+
3
+ The FastAPI integration for [ForgeSight](https://github.com/Scaffoldic/forgesight). Three
4
+ lines wire request↔run correlation, incoming-trace continuation, and flush-on-shutdown into
5
+ any FastAPI / Starlette app — no per-handler instrumentation.
6
+
7
+ ```bash
8
+ pip install forgesight-fastapi
9
+ ```
10
+
11
+ ```python
12
+ from fastapi import FastAPI
13
+ from forgesight_fastapi import AgentForgeMiddleware, sdk_lifespan
14
+
15
+ app = FastAPI(lifespan=sdk_lifespan) # configure() on startup, flush on shutdown
16
+ app.add_middleware(AgentForgeMiddleware) # request → agent_run span, correlation
17
+
18
+ @app.post("/agents/pr-reviewer/run")
19
+ async def run(req: ReviewRequest):
20
+ # Unchanged agent code. The run span is already open and bound to this request;
21
+ # the agent's llm/tool/mcp calls nest under it automatically.
22
+ return await pr_reviewer.run(req.task)
23
+ ```
24
+
25
+ Compose with an existing lifespan:
26
+
27
+ ```python
28
+ from contextlib import asynccontextmanager
29
+ from forgesight_fastapi import sdk_lifespan
30
+
31
+ @asynccontextmanager
32
+ async def lifespan(app):
33
+ async with sdk_lifespan(app):
34
+ await connect_db()
35
+ yield
36
+ await close_db()
37
+
38
+ app = FastAPI(lifespan=lifespan)
39
+ ```
40
+
41
+ ## What you get
42
+
43
+ - **Request↔run link.** The HTTP request and the agent run share a `trace_id`; the response
44
+ carries the `run_id` (header `x-agentforge-run-id`), so "request X was slow" jumps straight
45
+ to the run's span tree and cost.
46
+ - **Distributed traces just work.** An upstream `traceparent` is continued automatically —
47
+ the agent service is a child span, not a new root. No propagation code in the app.
48
+ - **Zero lost telemetry on deploy.** `sdk_lifespan` calls `force_flush()` + `shutdown()` on
49
+ the shutdown phase (which ASGI servers run on SIGTERM), with a bounded timeout so a wedged
50
+ backend can't hang the deploy.
51
+ - **Route-level metadata for free.** The matched route *template* (`/agents/{id}/run`, not
52
+ the raw path — bounded cardinality), method, and status land as span metadata (FR-5).
53
+ - **Correct error mapping.** 5xx ⇒ span ERROR + `error.type`; an unhandled exception is
54
+ recorded and re-raised (FR-7); 4xx is recorded without erroring the span.
55
+
56
+ Implemented as **pure ASGI** (not `BaseHTTPMiddleware`) to avoid streaming/lifespan pitfalls.
57
+ Request/response bodies are captured only when `capture_content` resolves true (P7).
58
+
59
+ ## Configuration
60
+
61
+ | Key | Env | Default |
62
+ |---|---|---|
63
+ | `span_kind` | `FORGESIGHT_FASTAPI_SPAN_KIND` | `agent_run` (or `workflow_run`) |
64
+ | `exclude_paths` | `FORGESIGHT_FASTAPI_EXCLUDE` | `/health,/healthz,/metrics,/docs,/openapi.json` |
65
+ | `capture_content` | `FORGESIGHT_FASTAPI_CAPTURE_CONTENT` | `false` |
66
+ | `run_id_header` | `FORGESIGHT_FASTAPI_RUN_ID_HEADER` | `x-agentforge-run-id` |
67
+
68
+ Constructor kwargs win over env / `forgesight.yaml` (`integrations.fastapi`).
69
+
70
+ ## License
71
+
72
+ Apache-2.0
@@ -0,0 +1,41 @@
1
+ [project]
2
+ name = "forgesight-fastapi"
3
+ version = "0.1.0"
4
+ description = "ForgeSight FastAPI integration — ASGI middleware + lifespan flush; request↔run correlation."
5
+ readme = "README.md"
6
+ requires-python = ">=3.11"
7
+ license = "Apache-2.0"
8
+ authors = [{ name = "kjoshi" }]
9
+ keywords = ["observability", "fastapi", "asgi", "starlette", "ai-agents", "forgesight"]
10
+ classifiers = [
11
+ "Development Status :: 2 - Pre-Alpha",
12
+ "Intended Audience :: Developers",
13
+ "Intended Audience :: Information Technology",
14
+ "Topic :: System :: Monitoring",
15
+ "Topic :: Scientific/Engineering :: Artificial Intelligence",
16
+ "License :: OSI Approved :: Apache Software License",
17
+ "Programming Language :: Python :: 3.11",
18
+ "Programming Language :: Python :: 3.12",
19
+ "Programming Language :: Python :: 3.13",
20
+ "Typing :: Typed",
21
+ ]
22
+ dependencies = ["forgesight-core", "starlette>=0.37"]
23
+
24
+ [project.entry-points."forgesight.integrations"]
25
+ fastapi = "forgesight_fastapi:install"
26
+
27
+ [project.urls]
28
+ Homepage = "https://github.com/Scaffoldic/forgesight"
29
+ Repository = "https://github.com/Scaffoldic/forgesight"
30
+ Issues = "https://github.com/Scaffoldic/forgesight/issues"
31
+ Changelog = "https://github.com/Scaffoldic/forgesight/blob/main/docs/releases/v0.1.md"
32
+
33
+ [build-system]
34
+ requires = ["hatchling"]
35
+ build-backend = "hatchling.build"
36
+
37
+ [tool.hatch.build.targets.wheel]
38
+ packages = ["src/forgesight_fastapi"]
39
+
40
+ [tool.uv.sources]
41
+ forgesight-core = { workspace = true }
@@ -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
@@ -0,0 +1,366 @@
1
+ """Tests for the FastAPI integration: correlation, propagation, errors, lifespan flush."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Iterator
6
+
7
+ import pytest
8
+ from starlette.applications import Starlette
9
+ from starlette.requests import Request
10
+ from starlette.responses import JSONResponse, PlainTextResponse
11
+ from starlette.routing import Route
12
+ from starlette.testclient import TestClient
13
+
14
+ from forgesight_api import Kind, RunStatus
15
+ from forgesight_core import InMemoryExporter, configure, get_runtime, reset_runtime, telemetry
16
+ from forgesight_fastapi import AgentForgeMiddleware, sdk_lifespan
17
+ from forgesight_fastapi._config import install
18
+
19
+ TRACE = "4bf92f3577b34da6a3ce929d0e0e4736"
20
+ SPAN = "00f067aa0ba902b7"
21
+
22
+
23
+ @pytest.fixture
24
+ def sink() -> Iterator[InMemoryExporter]:
25
+ exporter = InMemoryExporter()
26
+ configure(exporters=[exporter], sync_export=True)
27
+ try:
28
+ yield exporter
29
+ finally:
30
+ reset_runtime()
31
+
32
+
33
+ def _build_app(sink: InMemoryExporter, **mw: object) -> Starlette:
34
+ async def run_handler(request: Request) -> JSONResponse:
35
+ await request.body() # read the body so capture (when on) sees the chunks
36
+ # the request span is open and bound — a child llm call nests under it
37
+ run = telemetry.current_run()
38
+ if run is not None: # None under workflow_run span_kind
39
+ with run.llm_call("anthropic", "claude-sonnet-4-5") as call:
40
+ call.record_usage(input=10, output=5)
41
+ return JSONResponse({"ok": True})
42
+
43
+ async def boom(request: Request) -> JSONResponse:
44
+ raise RuntimeError("handler exploded")
45
+
46
+ async def server_error(request: Request) -> PlainTextResponse:
47
+ return PlainTextResponse("nope", status_code=503)
48
+
49
+ async def bad_request(request: Request) -> PlainTextResponse:
50
+ return PlainTextResponse("bad", status_code=422)
51
+
52
+ async def health(request: Request) -> PlainTextResponse:
53
+ return PlainTextResponse("ok")
54
+
55
+ app = Starlette(
56
+ routes=[
57
+ Route("/agents/{agent_id}/run", run_handler, methods=["POST"]),
58
+ Route("/boom", boom, methods=["GET"]),
59
+ Route("/server-error", server_error, methods=["GET"]),
60
+ Route("/bad", bad_request, methods=["GET"]),
61
+ Route("/health", health, methods=["GET"]),
62
+ ]
63
+ )
64
+ app.add_middleware(AgentForgeMiddleware, **mw)
65
+ return app
66
+
67
+
68
+ # --- correlation + mapping ----------------------------------------------------
69
+ def test_request_opens_run_span_with_route_template(sink: InMemoryExporter) -> None:
70
+ client = TestClient(_build_app(sink))
71
+ response = client.post("/agents/pr-reviewer/run")
72
+ assert response.status_code == 200
73
+ assert response.headers["x-agentforge-run-id"] # run_id correlation header
74
+
75
+ runs = [r for r in sink.records if r.kind is Kind.AGENT]
76
+ assert len(runs) == 1
77
+ run = runs[0]
78
+ assert run.attributes["http.route"] == "/agents/{agent_id}/run" # template, not raw path
79
+ assert run.attributes["http.method"] == "POST"
80
+ assert run.attributes["http.status_code"] == 200
81
+ assert run.status is RunStatus.OK
82
+ # the header run_id matches the run record
83
+ assert response.headers["x-agentforge-run-id"] == run.run_id
84
+
85
+
86
+ def test_child_calls_nest_under_request_run(sink: InMemoryExporter) -> None:
87
+ client = TestClient(_build_app(sink))
88
+ client.post("/agents/x/run")
89
+ run = next(r for r in sink.records if r.kind is Kind.AGENT)
90
+ llm = next(r for r in sink.records if r.kind is Kind.LLM)
91
+ assert llm.trace_id == run.trace_id # same trace
92
+ assert llm.parent_span_id == run.span_id # nested under the request span
93
+
94
+
95
+ def test_workflow_span_kind(sink: InMemoryExporter) -> None:
96
+ client = TestClient(_build_app(sink, span_kind="workflow_run"))
97
+ client.post("/agents/x/run")
98
+ assert any(r.kind is Kind.WORKFLOW for r in sink.records)
99
+
100
+
101
+ def test_agent_name_callable(sink: InMemoryExporter) -> None:
102
+ app = _build_app(sink, agent_name=lambda req: f"svc:{req.url.path}")
103
+ TestClient(app).post("/agents/x/run")
104
+ run = next(r for r in sink.records if r.kind is Kind.AGENT)
105
+ assert run.name == "svc:/agents/x/run"
106
+
107
+
108
+ # --- exclude / include --------------------------------------------------------
109
+ def test_excluded_path_gets_no_span(sink: InMemoryExporter) -> None:
110
+ client = TestClient(_build_app(sink))
111
+ client.get("/health")
112
+ assert [r for r in sink.records if r.kind is Kind.AGENT] == []
113
+
114
+
115
+ def test_include_routes_allow_list(sink: InMemoryExporter) -> None:
116
+ client = TestClient(_build_app(sink, include_routes=["/agents"]))
117
+ client.get("/bad") # not in include list ⇒ no span
118
+ client.post("/agents/x/run") # included
119
+ runs = [r for r in sink.records if r.kind is Kind.AGENT]
120
+ assert len(runs) == 1
121
+ assert runs[0].attributes["http.target"] == "/agents/x/run"
122
+
123
+
124
+ # --- propagation --------------------------------------------------------------
125
+ def test_incoming_traceparent_continued(sink: InMemoryExporter) -> None:
126
+ client = TestClient(_build_app(sink))
127
+ client.post("/agents/x/run", headers={"traceparent": f"00-{TRACE}-{SPAN}-01"})
128
+ run = next(r for r in sink.records if r.kind is Kind.AGENT)
129
+ assert run.trace_id == TRACE # request run is a child of the upstream trace
130
+ assert run.parent_span_id == SPAN
131
+
132
+
133
+ def test_malformed_traceparent_starts_new_root(sink: InMemoryExporter) -> None:
134
+ client = TestClient(_build_app(sink))
135
+ client.post("/agents/x/run", headers={"traceparent": "garbage"})
136
+ run = next(r for r in sink.records if r.kind is Kind.AGENT)
137
+ assert run.trace_id != TRACE
138
+ assert run.parent_span_id is None # new root, not a broken child
139
+
140
+
141
+ # --- error mapping ------------------------------------------------------------
142
+ def test_5xx_marks_span_error(sink: InMemoryExporter) -> None:
143
+ client = TestClient(_build_app(sink))
144
+ client.get("/server-error")
145
+ run = next(r for r in sink.records if r.kind is Kind.AGENT)
146
+ assert run.status is RunStatus.ERROR
147
+ assert run.error is not None
148
+ assert run.attributes["http.status_code"] == 503
149
+
150
+
151
+ def test_4xx_recorded_without_error(sink: InMemoryExporter) -> None:
152
+ client = TestClient(_build_app(sink))
153
+ client.get("/bad")
154
+ run = next(r for r in sink.records if r.kind is Kind.AGENT)
155
+ assert run.status is RunStatus.OK # 4xx is the caller's fault, not a server error
156
+ assert run.attributes["http.status_code"] == 422
157
+
158
+
159
+ def test_unhandled_exception_recorded_and_reraised(sink: InMemoryExporter) -> None:
160
+ client = TestClient(_build_app(sink), raise_server_exceptions=False)
161
+ client.get("/boom")
162
+ run = next(r for r in sink.records if r.kind is Kind.AGENT)
163
+ assert run.status is RunStatus.ERROR
164
+ assert run.error is not None
165
+ assert run.error.error_type == "RuntimeError"
166
+
167
+
168
+ # --- content gate (P7) --------------------------------------------------------
169
+ def test_body_absent_by_default(sink: InMemoryExporter) -> None:
170
+ client = TestClient(_build_app(sink))
171
+ client.post("/agents/x/run", content=b'{"task": "secret"}')
172
+ run = next(r for r in sink.records if r.kind is Kind.AGENT)
173
+ assert "http.request.body" not in run.attributes
174
+
175
+
176
+ def test_body_captured_when_opted_in(sink: InMemoryExporter) -> None:
177
+ client = TestClient(_build_app(sink, capture_content=True))
178
+ client.post("/agents/x/run", content=b'{"task": "do it"}')
179
+ run = next(r for r in sink.records if r.kind is Kind.AGENT)
180
+ assert "do it" in str(run.attributes["http.request.body"])
181
+
182
+
183
+ # --- lifespan flush -----------------------------------------------------------
184
+ class RetainingExporter:
185
+ """Like InMemoryExporter but does NOT clear on shutdown — so a test can assert the
186
+ flushed batch *after* the lifespan shutdown that drains it."""
187
+
188
+ def __init__(self) -> None:
189
+ self.records: list[object] = []
190
+
191
+ def export(self, records: object) -> object:
192
+ from forgesight_api import ExportResult
193
+
194
+ self.records.extend(records) # type: ignore[arg-type]
195
+ return ExportResult.SUCCESS
196
+
197
+ def force_flush(self, timeout_millis: int = 30_000) -> bool:
198
+ return True
199
+
200
+ def shutdown(self, timeout_millis: int = 30_000) -> None:
201
+ return None # retain across shutdown
202
+
203
+
204
+ def test_lifespan_configures_and_flushes() -> None:
205
+ exporter = RetainingExporter()
206
+
207
+ async def handler(request: Request) -> JSONResponse:
208
+ with telemetry.agent_run("x"):
209
+ pass
210
+ return JSONResponse({"ok": True})
211
+
212
+ def lifespan(app: Starlette): # type: ignore[no-untyped-def]
213
+ return sdk_lifespan(app, exporters=[exporter], sync_export=False)
214
+
215
+ app = Starlette(routes=[Route("/agents/x/run", handler, methods=["POST"])], lifespan=lifespan)
216
+ app.add_middleware(AgentForgeMiddleware)
217
+
218
+ with TestClient(app) as client: # __enter__ runs startup, __exit__ runs shutdown
219
+ client.post("/agents/x/run")
220
+ # after shutdown, the buffered batch has been flushed (force_flush + shutdown) to the exporter
221
+ assert any(getattr(r, "kind", None) is Kind.AGENT for r in exporter.records)
222
+ reset_runtime()
223
+
224
+
225
+ def test_lifespan_respects_already_configured() -> None:
226
+ exporter = InMemoryExporter()
227
+ configure(exporters=[exporter], sync_export=True)
228
+ try:
229
+ runtime_before = get_runtime()
230
+
231
+ async def cm() -> None:
232
+ async with sdk_lifespan(configure_sdk=False):
233
+ with telemetry.agent_run("x"):
234
+ # assert the record landed BEFORE shutdown clears the InMemoryExporter
235
+ pass
236
+ assert any(r.kind is Kind.AGENT for r in exporter.records)
237
+ assert get_runtime() is runtime_before # not reconfigured
238
+
239
+ import anyio
240
+
241
+ anyio.run(cm)
242
+ finally:
243
+ reset_runtime()
244
+
245
+
246
+ # --- config / install ---------------------------------------------------------
247
+ def test_install_provides_defaults(sink: InMemoryExporter) -> None:
248
+ try:
249
+ assert install({"enabled": True, "span_kind": "workflow_run"}) is True
250
+ client = TestClient(_build_app(sink)) # no explicit span_kind ⇒ from install()
251
+ client.post("/agents/x/run")
252
+ assert any(r.kind is Kind.WORKFLOW for r in sink.records)
253
+ finally:
254
+ install({"enabled": False}) # clear
255
+
256
+
257
+ def test_install_disabled_returns_false() -> None:
258
+ assert install({"enabled": False}) is False
259
+
260
+
261
+ def test_invalid_span_kind_rejected(sink: InMemoryExporter) -> None:
262
+ with pytest.raises(ValueError, match="span_kind must be"):
263
+ AgentForgeMiddleware(_build_app(sink), span_kind="teleport")
264
+
265
+
266
+ def test_non_http_scope_passes_through() -> None:
267
+ # a lifespan/websocket scope must not be instrumented
268
+ configure(exporters=[InMemoryExporter()], sync_export=True)
269
+ try:
270
+ seen = {}
271
+
272
+ async def app(scope, receive, send): # type: ignore[no-untyped-def]
273
+ seen["type"] = scope["type"]
274
+
275
+ mw = AgentForgeMiddleware(app)
276
+
277
+ async def run() -> None:
278
+ await mw({"type": "lifespan"}, _noop_receive, _noop_send)
279
+
280
+ import anyio
281
+
282
+ anyio.run(run)
283
+ assert seen["type"] == "lifespan"
284
+ finally:
285
+ reset_runtime()
286
+
287
+
288
+ async def _noop_receive() -> dict[str, object]:
289
+ return {"type": "noop"}
290
+
291
+
292
+ async def _noop_send(message: dict[str, object]) -> None:
293
+ return None
294
+
295
+
296
+ def test_run_id_header_custom(sink: InMemoryExporter) -> None:
297
+ client = TestClient(_build_app(sink, run_id_header="x-run"))
298
+ response = client.post("/agents/x/run")
299
+ assert "x-run" in response.headers
300
+ assert get_runtime() is not None
301
+
302
+
303
+ # --- _w3c unit coverage -------------------------------------------------------
304
+ def test_w3c_extract_valid_and_missing() -> None:
305
+ from forgesight_fastapi._w3c import extract_parent
306
+
307
+ headers = [(b"traceparent", f"00-{TRACE}-{SPAN}-01".encode())]
308
+ assert extract_parent(headers) == (TRACE, SPAN)
309
+ assert extract_parent([(b"content-type", b"application/json")]) is None # no traceparent
310
+
311
+
312
+ @pytest.mark.parametrize(
313
+ "value",
314
+ [
315
+ "garbage", # wrong part count
316
+ f"00-{TRACE}-tooShort-01", # wrong span length
317
+ "00-" + "g" * 32 + "-" + "0" * 16 + "-01", # non-hex
318
+ "00-" + "0" * 32 + "-" + SPAN + "-01", # all-zero trace
319
+ ],
320
+ )
321
+ def test_w3c_rejects_bad_traceparent(value: str) -> None:
322
+ from forgesight_fastapi._w3c import extract_parent
323
+
324
+ assert extract_parent([(b"traceparent", value.encode())]) is None
325
+
326
+
327
+ # --- _config resolver coverage ------------------------------------------------
328
+ def test_config_env_resolvers(monkeypatch: pytest.MonkeyPatch) -> None:
329
+ from forgesight_fastapi._config import (
330
+ resolve_exclude_paths,
331
+ resolve_run_id_header,
332
+ resolve_span_kind,
333
+ )
334
+
335
+ monkeypatch.setenv("FORGESIGHT_FASTAPI_EXCLUDE", "/a, /b")
336
+ monkeypatch.setenv("FORGESIGHT_FASTAPI_RUN_ID_HEADER", "x-corr")
337
+ monkeypatch.setenv("FORGESIGHT_FASTAPI_SPAN_KIND", "workflow_run")
338
+ assert resolve_exclude_paths(None) == ("/a", "/b")
339
+ assert resolve_run_id_header(None) == "x-corr"
340
+ assert resolve_span_kind(None) == "workflow_run"
341
+
342
+
343
+ def test_config_install_include_routes_and_run_id() -> None:
344
+ from forgesight_fastapi._config import resolve_include_routes, resolve_run_id_header
345
+
346
+ try:
347
+ install({"include_routes": ["/agents"], "run_id_header": "x-from-yaml"})
348
+ assert resolve_include_routes(None) == ("/agents",)
349
+ assert resolve_run_id_header(None) == "x-from-yaml"
350
+ finally:
351
+ install({"enabled": False})
352
+
353
+
354
+ def test_config_capture_content_resolution(monkeypatch: pytest.MonkeyPatch) -> None:
355
+ from forgesight_fastapi._config import resolve_capture_content
356
+
357
+ monkeypatch.setenv("FORGESIGHT_FASTAPI_CAPTURE_CONTENT", "true")
358
+ assert resolve_capture_content(None) is True # env path
359
+ monkeypatch.delenv("FORGESIGHT_FASTAPI_CAPTURE_CONTENT")
360
+ try:
361
+ install({"capture_content": True}) # logs once + sets installed default
362
+ install({"capture_content": True}) # already logged ⇒ no second log
363
+ assert resolve_capture_content(None) is True # installed path
364
+ finally:
365
+ install({"enabled": False})
366
+ assert resolve_capture_content(None) is None # nothing set ⇒ inherit global gate