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.
- forgesight_fastapi-0.1.0/.gitignore +38 -0
- forgesight_fastapi-0.1.0/PKG-INFO +98 -0
- forgesight_fastapi-0.1.0/README.md +72 -0
- forgesight_fastapi-0.1.0/pyproject.toml +41 -0
- forgesight_fastapi-0.1.0/src/forgesight_fastapi/__init__.py +28 -0
- forgesight_fastapi-0.1.0/src/forgesight_fastapi/_config.py +98 -0
- forgesight_fastapi-0.1.0/src/forgesight_fastapi/_w3c.py +43 -0
- forgesight_fastapi-0.1.0/src/forgesight_fastapi/lifespan.py +47 -0
- forgesight_fastapi-0.1.0/src/forgesight_fastapi/middleware.py +187 -0
- forgesight_fastapi-0.1.0/src/forgesight_fastapi/py.typed +0 -0
- forgesight_fastapi-0.1.0/tests/test_fastapi.py +366 -0
|
@@ -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
|
|
File without changes
|
|
@@ -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
|