logbrew-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,74 @@
1
+ Metadata-Version: 2.4
2
+ Name: logbrew-fastapi
3
+ Version: 0.1.0
4
+ Summary: FastAPI integration for capturing LogBrew request spans and exceptions.
5
+ Author: LogBrew
6
+ License-Expression: MIT
7
+ Project-URL: Repository, https://github.com/LogBrewCo/sdk
8
+ Keywords: logbrew,observability,fastapi,asgi,logs
9
+ Classifier: Development Status :: 3 - Alpha
10
+ Classifier: Framework :: FastAPI
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3 :: Only
14
+ Classifier: Programming Language :: Python :: 3.11
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Programming Language :: Python :: 3.13
17
+ Classifier: Typing :: Typed
18
+ Requires-Python: >=3.11
19
+ Description-Content-Type: text/markdown
20
+ Requires-Dist: fastapi>=0.115
21
+ Requires-Dist: httpx2>=2.3
22
+ Requires-Dist: logbrew-sdk==0.1.0
23
+
24
+ # logbrew-fastapi
25
+
26
+ FastAPI integration for capturing LogBrew request spans and exceptions with the public Python SDK.
27
+
28
+ ## Install
29
+
30
+ ```bash
31
+ python3 -m pip install logbrew-sdk logbrew-fastapi
32
+ python3 -m logbrew_fastapi.examples --help
33
+ python3 -m logbrew_fastapi.examples --list
34
+ python3 -m logbrew_fastapi.examples readme-example
35
+ python3 -m logbrew_fastapi.examples real-user-smoke
36
+ python3 -m logbrew_fastapi.examples
37
+ ```
38
+
39
+ The package is typed, ships `py.typed`, depends on the core `logbrew-sdk`, and keeps FastAPI as a normal framework dependency instead of bundling or monkeypatching the user's app.
40
+
41
+ ## Example
42
+
43
+ ```python
44
+ from fastapi import FastAPI
45
+ from logbrew_fastapi import add_logbrew_middleware
46
+ from logbrew_sdk import LogBrewClient, RecordingTransport
47
+
48
+ client = LogBrewClient.create(
49
+ api_key="LOGBREW_API_KEY",
50
+ sdk_name="logbrew-fastapi",
51
+ sdk_version="0.1.0",
52
+ )
53
+ transport = RecordingTransport.always_accept()
54
+ app = FastAPI()
55
+ add_logbrew_middleware(
56
+ app,
57
+ client=client,
58
+ transport=transport,
59
+ span_id_factory=lambda: "b7ad6b7169203331",
60
+ )
61
+
62
+
63
+ @app.get("/health")
64
+ def health() -> dict[str, bool]:
65
+ return {"ok": True}
66
+ ```
67
+
68
+ `add_logbrew_middleware()` records successful requests as span events, records unhandled handler exceptions as issue plus error-span events, and flushes through the provided transport after each response. If no transport is provided, events stay queued on the core client so the app can flush them itself.
69
+
70
+ When an incoming request has a valid W3C `traceparent` header, request capture continues that trace by using the incoming `traceId` and parent span id while creating a fresh child span id. Missing or malformed `traceparent` headers keep the existing synthetic request span behavior so bad client headers do not break the app. Automatic metadata uses the request path without query text. Use `span_id_factory` only when tests need deterministic child span ids.
71
+
72
+ By default, transport failures do not break the FastAPI response path. Set `raise_flush_errors=True` in test environments when you want misconfigured transport behavior to fail loudly.
73
+
74
+ Use a clearly fake placeholder like `LOGBREW_API_KEY` in local examples and tests.
@@ -0,0 +1,51 @@
1
+ # logbrew-fastapi
2
+
3
+ FastAPI integration for capturing LogBrew request spans and exceptions with the public Python SDK.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ python3 -m pip install logbrew-sdk logbrew-fastapi
9
+ python3 -m logbrew_fastapi.examples --help
10
+ python3 -m logbrew_fastapi.examples --list
11
+ python3 -m logbrew_fastapi.examples readme-example
12
+ python3 -m logbrew_fastapi.examples real-user-smoke
13
+ python3 -m logbrew_fastapi.examples
14
+ ```
15
+
16
+ The package is typed, ships `py.typed`, depends on the core `logbrew-sdk`, and keeps FastAPI as a normal framework dependency instead of bundling or monkeypatching the user's app.
17
+
18
+ ## Example
19
+
20
+ ```python
21
+ from fastapi import FastAPI
22
+ from logbrew_fastapi import add_logbrew_middleware
23
+ from logbrew_sdk import LogBrewClient, RecordingTransport
24
+
25
+ client = LogBrewClient.create(
26
+ api_key="LOGBREW_API_KEY",
27
+ sdk_name="logbrew-fastapi",
28
+ sdk_version="0.1.0",
29
+ )
30
+ transport = RecordingTransport.always_accept()
31
+ app = FastAPI()
32
+ add_logbrew_middleware(
33
+ app,
34
+ client=client,
35
+ transport=transport,
36
+ span_id_factory=lambda: "b7ad6b7169203331",
37
+ )
38
+
39
+
40
+ @app.get("/health")
41
+ def health() -> dict[str, bool]:
42
+ return {"ok": True}
43
+ ```
44
+
45
+ `add_logbrew_middleware()` records successful requests as span events, records unhandled handler exceptions as issue plus error-span events, and flushes through the provided transport after each response. If no transport is provided, events stay queued on the core client so the app can flush them itself.
46
+
47
+ When an incoming request has a valid W3C `traceparent` header, request capture continues that trace by using the incoming `traceId` and parent span id while creating a fresh child span id. Missing or malformed `traceparent` headers keep the existing synthetic request span behavior so bad client headers do not break the app. Automatic metadata uses the request path without query text. Use `span_id_factory` only when tests need deterministic child span ids.
48
+
49
+ By default, transport failures do not break the FastAPI response path. Set `raise_flush_errors=True` in test environments when you want misconfigured transport behavior to fail loudly.
50
+
51
+ Use a clearly fake placeholder like `LOGBREW_API_KEY` in local examples and tests.
@@ -0,0 +1,43 @@
1
+ [build-system]
2
+ requires = ["setuptools>=80"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "logbrew-fastapi"
7
+ version = "0.1.0"
8
+ description = "FastAPI integration for capturing LogBrew request spans and exceptions."
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ requires-python = ">=3.11"
12
+ authors = [
13
+ { name = "LogBrew" }
14
+ ]
15
+ keywords = ["logbrew", "observability", "fastapi", "asgi", "logs"]
16
+ classifiers = [
17
+ "Development Status :: 3 - Alpha",
18
+ "Framework :: FastAPI",
19
+ "Intended Audience :: Developers",
20
+ "Programming Language :: Python :: 3",
21
+ "Programming Language :: Python :: 3 :: Only",
22
+ "Programming Language :: Python :: 3.11",
23
+ "Programming Language :: Python :: 3.12",
24
+ "Programming Language :: Python :: 3.13",
25
+ "Typing :: Typed"
26
+ ]
27
+ dependencies = [
28
+ "fastapi>=0.115",
29
+ "httpx2>=2.3",
30
+ "logbrew-sdk==0.1.0"
31
+ ]
32
+
33
+ [project.urls]
34
+ Repository = "https://github.com/LogBrewCo/sdk"
35
+
36
+ [tool.setuptools]
37
+ package-dir = {"" = "src"}
38
+
39
+ [tool.setuptools.packages.find]
40
+ where = ["src"]
41
+
42
+ [tool.setuptools.package-data]
43
+ logbrew_fastapi = ["py.typed"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,258 @@
1
+ """FastAPI integration helpers for capturing LogBrew request spans and exceptions."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+ import uuid
7
+ from collections.abc import Callable
8
+ from dataclasses import dataclass
9
+ from datetime import UTC, datetime
10
+ from typing import Any
11
+
12
+ from fastapi import FastAPI, Request, Response
13
+ from logbrew_sdk import (
14
+ LogBrewClient,
15
+ RecordingTransport,
16
+ SdkError,
17
+ SpanAttributes,
18
+ TransportError,
19
+ parse_traceparent,
20
+ span_attributes_from_traceparent,
21
+ )
22
+ from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
23
+ from starlette.types import ASGIApp
24
+
25
+
26
+ @dataclass(slots=True)
27
+ class LogBrewFastAPIConfig:
28
+ """Runtime options used by the LogBrew FastAPI middleware."""
29
+
30
+ client: LogBrewClient
31
+ transport: RecordingTransport | None = None
32
+ capture_successful_requests: bool = True
33
+ capture_exceptions: bool = True
34
+ flush_on_response: bool = True
35
+ raise_flush_errors: bool = False
36
+ service_name: str = "fastapi"
37
+ span_id_factory: Callable[[], str] | None = None
38
+
39
+
40
+ def utc_timestamp() -> str:
41
+ """Return a LogBrew-compatible UTC timestamp."""
42
+
43
+ return datetime.now(UTC).isoformat(timespec="milliseconds").replace("+00:00", "Z")
44
+
45
+
46
+ def request_name(request: Request) -> str:
47
+ """Return the stable request name used for span and issue titles."""
48
+
49
+ return f"{request.method} {request.url.path}"
50
+
51
+
52
+ def request_metadata(
53
+ request: Request,
54
+ *,
55
+ status_code: int | None = None,
56
+ duration_ms: float | None = None,
57
+ ) -> dict[str, Any]:
58
+ """Return metadata that is useful for request-level troubleshooting without including query strings."""
59
+
60
+ metadata: dict[str, Any] = {
61
+ "framework": "fastapi",
62
+ "method": request.method,
63
+ "path": request.url.path,
64
+ }
65
+ route = request.scope.get("route")
66
+ route_path = getattr(route, "path", None)
67
+ if isinstance(route_path, str):
68
+ metadata["route"] = route_path
69
+ if status_code is not None:
70
+ metadata["status_code"] = status_code
71
+ if duration_ms is not None:
72
+ metadata["duration_ms"] = round(duration_ms, 3)
73
+ return metadata
74
+
75
+
76
+ def capture_request_span(
77
+ client: LogBrewClient,
78
+ request: Request,
79
+ *,
80
+ status_code: int,
81
+ duration_ms: float,
82
+ event_id: str | None = None,
83
+ timestamp: str | None = None,
84
+ span_id_factory: Callable[[], str] | None = None,
85
+ ) -> str:
86
+ """Capture a FastAPI request as a LogBrew span event and return its event id."""
87
+
88
+ span_event_id = event_id or f"evt_fastapi_span_{uuid.uuid4().hex}"
89
+ span_seed = span_event_id.replace("-", "_")
90
+ traceparent = request.headers.get("traceparent")
91
+ attributes: SpanAttributes = {
92
+ "name": request_name(request),
93
+ "traceId": f"trace_{span_seed}",
94
+ "spanId": f"span_{span_seed}",
95
+ "status": "ok" if status_code < 500 else "error",
96
+ "durationMs": duration_ms,
97
+ "metadata": request_metadata(request, status_code=status_code, duration_ms=duration_ms),
98
+ }
99
+ if traceparent:
100
+ try:
101
+ parse_traceparent(traceparent)
102
+ attributes = span_attributes_from_traceparent(
103
+ traceparent,
104
+ name=request_name(request),
105
+ span_id=(span_id_factory or default_span_id_factory)(),
106
+ status="ok" if status_code < 500 else "error",
107
+ duration_ms=duration_ms,
108
+ metadata=request_metadata(request, status_code=status_code, duration_ms=duration_ms),
109
+ )
110
+ except SdkError:
111
+ pass
112
+ client.span(
113
+ span_event_id,
114
+ timestamp or utc_timestamp(),
115
+ attributes,
116
+ )
117
+ return span_event_id
118
+
119
+
120
+ def capture_exception(
121
+ client: LogBrewClient,
122
+ request: Request,
123
+ exc: BaseException,
124
+ *,
125
+ event_id: str | None = None,
126
+ timestamp: str | None = None,
127
+ ) -> str:
128
+ """Capture an exception raised while handling a FastAPI request and return its event id."""
129
+
130
+ issue_event_id = event_id or f"evt_fastapi_issue_{uuid.uuid4().hex}"
131
+ client.issue(
132
+ issue_event_id,
133
+ timestamp or utc_timestamp(),
134
+ {
135
+ "title": f"{request_name(request)} failed",
136
+ "level": "error",
137
+ "message": str(exc) or exc.__class__.__name__,
138
+ "metadata": {
139
+ **request_metadata(request, status_code=500),
140
+ "exception_type": exc.__class__.__name__,
141
+ },
142
+ },
143
+ )
144
+ return issue_event_id
145
+
146
+
147
+ class LogBrewFastAPIMiddleware(BaseHTTPMiddleware):
148
+ """FastAPI middleware that records request spans and exception issues with LogBrew."""
149
+
150
+ def __init__(
151
+ self,
152
+ app: ASGIApp,
153
+ *,
154
+ client: LogBrewClient,
155
+ transport: RecordingTransport | None = None,
156
+ capture_successful_requests: bool = True,
157
+ capture_exceptions: bool = True,
158
+ flush_on_response: bool = True,
159
+ raise_flush_errors: bool = False,
160
+ service_name: str = "fastapi",
161
+ span_id_factory: Callable[[], str] | None = None,
162
+ ) -> None:
163
+ super().__init__(app)
164
+ self.config = LogBrewFastAPIConfig(
165
+ client=client,
166
+ transport=transport,
167
+ capture_successful_requests=capture_successful_requests,
168
+ capture_exceptions=capture_exceptions,
169
+ flush_on_response=flush_on_response,
170
+ raise_flush_errors=raise_flush_errors,
171
+ service_name=service_name,
172
+ span_id_factory=span_id_factory,
173
+ )
174
+
175
+ async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -> Response:
176
+ start = time.perf_counter()
177
+ try:
178
+ response = await call_next(request)
179
+ except Exception as exc:
180
+ duration_ms = (time.perf_counter() - start) * 1000
181
+ if self.config.capture_exceptions:
182
+ capture_exception(self.config.client, request, exc)
183
+ capture_request_span(
184
+ self.config.client,
185
+ request,
186
+ status_code=500,
187
+ duration_ms=duration_ms,
188
+ span_id_factory=self.config.span_id_factory,
189
+ )
190
+ self._flush_if_configured()
191
+ raise
192
+
193
+ duration_ms = (time.perf_counter() - start) * 1000
194
+ if self.config.capture_successful_requests or response.status_code >= 500:
195
+ capture_request_span(
196
+ self.config.client,
197
+ request,
198
+ status_code=response.status_code,
199
+ duration_ms=duration_ms,
200
+ span_id_factory=self.config.span_id_factory,
201
+ )
202
+ self._flush_if_configured()
203
+ return response
204
+
205
+ def _flush_if_configured(self) -> None:
206
+ if not self.config.flush_on_response or self.config.transport is None:
207
+ return
208
+ try:
209
+ self.config.client.flush(self.config.transport)
210
+ except (SdkError, TransportError):
211
+ if self.config.raise_flush_errors:
212
+ raise
213
+
214
+
215
+ def add_logbrew_middleware(
216
+ app: FastAPI,
217
+ *,
218
+ client: LogBrewClient,
219
+ transport: RecordingTransport | None = None,
220
+ capture_successful_requests: bool = True,
221
+ capture_exceptions: bool = True,
222
+ flush_on_response: bool = True,
223
+ raise_flush_errors: bool = False,
224
+ service_name: str = "fastapi",
225
+ span_id_factory: Callable[[], str] | None = None,
226
+ ) -> None:
227
+ """Install LogBrew request/exception capture middleware on a FastAPI app."""
228
+
229
+ app.add_middleware(
230
+ LogBrewFastAPIMiddleware,
231
+ client=client,
232
+ transport=transport,
233
+ capture_successful_requests=capture_successful_requests,
234
+ capture_exceptions=capture_exceptions,
235
+ flush_on_response=flush_on_response,
236
+ raise_flush_errors=raise_flush_errors,
237
+ service_name=service_name,
238
+ span_id_factory=span_id_factory,
239
+ )
240
+
241
+
242
+ def default_span_id_factory() -> str:
243
+ """Return a fresh W3C-compatible child span id."""
244
+
245
+ span_id = uuid.uuid4().hex[:16]
246
+ return "0000000000000001" if span_id == "0000000000000000" else span_id
247
+
248
+
249
+ __all__ = [
250
+ "LogBrewFastAPIConfig",
251
+ "LogBrewFastAPIMiddleware",
252
+ "add_logbrew_middleware",
253
+ "capture_exception",
254
+ "capture_request_span",
255
+ "request_metadata",
256
+ "request_name",
257
+ "utc_timestamp",
258
+ ]
@@ -0,0 +1 @@
1
+ """Runnable examples shipped with logbrew-fastapi."""
@@ -0,0 +1,29 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import runpy
5
+
6
+ EXAMPLES = {
7
+ "readme-example": "logbrew_fastapi.examples.readme_example",
8
+ "real-user-smoke": "logbrew_fastapi.examples.real_user_smoke",
9
+ }
10
+
11
+
12
+ def main() -> int:
13
+ parser = argparse.ArgumentParser(description="Run packaged logbrew-fastapi examples.")
14
+ parser.add_argument("example", nargs="?", choices=sorted(EXAMPLES), default="real-user-smoke")
15
+ parser.add_argument("--list", action="store_true", help="List packaged examples and commands.")
16
+ args = parser.parse_args()
17
+
18
+ if args.list:
19
+ print("readme-example -> python -m logbrew_fastapi.examples readme-example")
20
+ print("real-user-smoke -> python -m logbrew_fastapi.examples real-user-smoke")
21
+ print("default (real-user-smoke) -> python -m logbrew_fastapi.examples")
22
+ return 0
23
+
24
+ runpy.run_module(EXAMPLES[args.example], run_name="__main__")
25
+ return 0
26
+
27
+
28
+ if __name__ == "__main__":
29
+ raise SystemExit(main())
@@ -0,0 +1,31 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import sys
5
+
6
+ from fastapi import FastAPI
7
+ from fastapi.testclient import TestClient
8
+ from logbrew_sdk import LogBrewClient, RecordingTransport
9
+
10
+ from logbrew_fastapi import add_logbrew_middleware
11
+
12
+ client = LogBrewClient.create(
13
+ api_key="LOGBREW_API_KEY",
14
+ sdk_name="logbrew-fastapi",
15
+ sdk_version="0.1.0",
16
+ )
17
+ transport = RecordingTransport.always_accept()
18
+ app = FastAPI()
19
+ add_logbrew_middleware(app, client=client, transport=transport)
20
+
21
+
22
+ @app.get("/health")
23
+ def health() -> dict[str, bool]:
24
+ return {"ok": True}
25
+
26
+
27
+ with TestClient(app) as http:
28
+ response = http.get("/health")
29
+ print(json.dumps({"ok": response.status_code == 200, "status": response.status_code}), file=sys.stderr)
30
+
31
+ print(transport.sent_bodies[-1])
@@ -0,0 +1,60 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import sys
5
+
6
+ from fastapi import FastAPI
7
+ from fastapi.testclient import TestClient
8
+ from logbrew_sdk import LogBrewClient, RecordingTransport
9
+
10
+ from logbrew_fastapi import add_logbrew_middleware
11
+
12
+ client = LogBrewClient.create(
13
+ api_key="LOGBREW_API_KEY",
14
+ sdk_name="logbrew-fastapi",
15
+ sdk_version="0.1.0",
16
+ )
17
+ transport = RecordingTransport.always_accept()
18
+ app = FastAPI()
19
+ add_logbrew_middleware(app, client=client, transport=transport, span_id_factory=lambda: "b7ad6b7169203331")
20
+
21
+
22
+ @app.get("/health")
23
+ def health() -> dict[str, bool]:
24
+ return {"ok": True}
25
+
26
+
27
+ @app.get("/boom")
28
+ def boom() -> dict[str, bool]:
29
+ raise RuntimeError("broken handler")
30
+
31
+
32
+ with TestClient(app, raise_server_exceptions=False) as http:
33
+ health_response = http.get(
34
+ "/health?debug=true",
35
+ headers={"traceparent": "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01"},
36
+ )
37
+ boom_response = http.get("/boom")
38
+
39
+ events = []
40
+ for body in transport.sent_bodies:
41
+ events.extend(json.loads(body)["events"])
42
+ first_span = events[0]["attributes"]
43
+
44
+ print(json.dumps({"sdk": client.sdk, "events": events}, indent=2))
45
+ print(
46
+ json.dumps(
47
+ {
48
+ "ok": health_response.status_code == 200 and boom_response.status_code == 500,
49
+ "requests": 2,
50
+ "sentBodies": len(transport.sent_bodies),
51
+ "events": len(events),
52
+ "pending": client.pending_events(),
53
+ "traceId": first_span["traceId"],
54
+ "parentSpanId": first_span["parentSpanId"],
55
+ "spanId": first_span["spanId"],
56
+ "path": first_span["metadata"]["path"],
57
+ }
58
+ ),
59
+ file=sys.stderr,
60
+ )
@@ -0,0 +1,74 @@
1
+ Metadata-Version: 2.4
2
+ Name: logbrew-fastapi
3
+ Version: 0.1.0
4
+ Summary: FastAPI integration for capturing LogBrew request spans and exceptions.
5
+ Author: LogBrew
6
+ License-Expression: MIT
7
+ Project-URL: Repository, https://github.com/LogBrewCo/sdk
8
+ Keywords: logbrew,observability,fastapi,asgi,logs
9
+ Classifier: Development Status :: 3 - Alpha
10
+ Classifier: Framework :: FastAPI
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3 :: Only
14
+ Classifier: Programming Language :: Python :: 3.11
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Programming Language :: Python :: 3.13
17
+ Classifier: Typing :: Typed
18
+ Requires-Python: >=3.11
19
+ Description-Content-Type: text/markdown
20
+ Requires-Dist: fastapi>=0.115
21
+ Requires-Dist: httpx2>=2.3
22
+ Requires-Dist: logbrew-sdk==0.1.0
23
+
24
+ # logbrew-fastapi
25
+
26
+ FastAPI integration for capturing LogBrew request spans and exceptions with the public Python SDK.
27
+
28
+ ## Install
29
+
30
+ ```bash
31
+ python3 -m pip install logbrew-sdk logbrew-fastapi
32
+ python3 -m logbrew_fastapi.examples --help
33
+ python3 -m logbrew_fastapi.examples --list
34
+ python3 -m logbrew_fastapi.examples readme-example
35
+ python3 -m logbrew_fastapi.examples real-user-smoke
36
+ python3 -m logbrew_fastapi.examples
37
+ ```
38
+
39
+ The package is typed, ships `py.typed`, depends on the core `logbrew-sdk`, and keeps FastAPI as a normal framework dependency instead of bundling or monkeypatching the user's app.
40
+
41
+ ## Example
42
+
43
+ ```python
44
+ from fastapi import FastAPI
45
+ from logbrew_fastapi import add_logbrew_middleware
46
+ from logbrew_sdk import LogBrewClient, RecordingTransport
47
+
48
+ client = LogBrewClient.create(
49
+ api_key="LOGBREW_API_KEY",
50
+ sdk_name="logbrew-fastapi",
51
+ sdk_version="0.1.0",
52
+ )
53
+ transport = RecordingTransport.always_accept()
54
+ app = FastAPI()
55
+ add_logbrew_middleware(
56
+ app,
57
+ client=client,
58
+ transport=transport,
59
+ span_id_factory=lambda: "b7ad6b7169203331",
60
+ )
61
+
62
+
63
+ @app.get("/health")
64
+ def health() -> dict[str, bool]:
65
+ return {"ok": True}
66
+ ```
67
+
68
+ `add_logbrew_middleware()` records successful requests as span events, records unhandled handler exceptions as issue plus error-span events, and flushes through the provided transport after each response. If no transport is provided, events stay queued on the core client so the app can flush them itself.
69
+
70
+ When an incoming request has a valid W3C `traceparent` header, request capture continues that trace by using the incoming `traceId` and parent span id while creating a fresh child span id. Missing or malformed `traceparent` headers keep the existing synthetic request span behavior so bad client headers do not break the app. Automatic metadata uses the request path without query text. Use `span_id_factory` only when tests need deterministic child span ids.
71
+
72
+ By default, transport failures do not break the FastAPI response path. Set `raise_flush_errors=True` in test environments when you want misconfigured transport behavior to fail loudly.
73
+
74
+ Use a clearly fake placeholder like `LOGBREW_API_KEY` in local examples and tests.
@@ -0,0 +1,14 @@
1
+ README.md
2
+ pyproject.toml
3
+ src/logbrew_fastapi/__init__.py
4
+ src/logbrew_fastapi/py.typed
5
+ src/logbrew_fastapi.egg-info/PKG-INFO
6
+ src/logbrew_fastapi.egg-info/SOURCES.txt
7
+ src/logbrew_fastapi.egg-info/dependency_links.txt
8
+ src/logbrew_fastapi.egg-info/requires.txt
9
+ src/logbrew_fastapi.egg-info/top_level.txt
10
+ src/logbrew_fastapi/examples/__init__.py
11
+ src/logbrew_fastapi/examples/__main__.py
12
+ src/logbrew_fastapi/examples/readme_example.py
13
+ src/logbrew_fastapi/examples/real_user_smoke.py
14
+ tests/test_fastapi_integration.py
@@ -0,0 +1,3 @@
1
+ fastapi>=0.115
2
+ httpx2>=2.3
3
+ logbrew-sdk==0.1.0
@@ -0,0 +1 @@
1
+ logbrew_fastapi
@@ -0,0 +1,161 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import unittest
5
+
6
+ from fastapi import FastAPI
7
+ from fastapi.testclient import TestClient
8
+ from logbrew_fastapi import add_logbrew_middleware
9
+ from logbrew_sdk import LogBrewClient, RecordingTransport, SdkError
10
+
11
+
12
+ def make_client() -> LogBrewClient:
13
+ return LogBrewClient.create(
14
+ api_key="LOGBREW_API_KEY",
15
+ sdk_name="logbrew-fastapi",
16
+ sdk_version="0.1.0",
17
+ )
18
+
19
+
20
+ class FastAPIIntegrationTests(unittest.TestCase):
21
+ def test_successful_request_captures_and_flushes_span(self) -> None:
22
+ sdk_client = make_client()
23
+ transport = RecordingTransport.always_accept()
24
+ app = FastAPI()
25
+ add_logbrew_middleware(app, client=sdk_client, transport=transport)
26
+
27
+ @app.get("/health")
28
+ def health() -> dict[str, bool]:
29
+ return {"ok": True}
30
+
31
+ with TestClient(app) as http:
32
+ response = http.get("/health")
33
+
34
+ self.assertEqual(response.status_code, 200)
35
+ self.assertEqual(sdk_client.pending_events(), 0)
36
+ self.assertEqual(len(transport.sent_bodies), 1)
37
+ payload = json.loads(transport.sent_bodies[0])
38
+ self.assertEqual([event["type"] for event in payload["events"]], ["span"])
39
+ attributes = payload["events"][0]["attributes"]
40
+ self.assertEqual(attributes["name"], "GET /health")
41
+ self.assertEqual(attributes["status"], "ok")
42
+ self.assertEqual(attributes["metadata"]["status_code"], 200)
43
+
44
+ def test_valid_traceparent_continues_request_span(self) -> None:
45
+ sdk_client = make_client()
46
+ transport = RecordingTransport.always_accept()
47
+ app = FastAPI()
48
+ add_logbrew_middleware(
49
+ app,
50
+ client=sdk_client,
51
+ transport=transport,
52
+ span_id_factory=lambda: "b7ad6b7169203331",
53
+ )
54
+
55
+ @app.get("/trace")
56
+ def trace() -> dict[str, bool]:
57
+ return {"ok": True}
58
+
59
+ with TestClient(app) as http:
60
+ response = http.get(
61
+ "/trace?debug=true",
62
+ headers={
63
+ "traceparent": "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01",
64
+ },
65
+ )
66
+
67
+ self.assertEqual(response.status_code, 200)
68
+ payload = json.loads(transport.sent_bodies[0])
69
+ attributes = payload["events"][0]["attributes"]
70
+ self.assertEqual(attributes["name"], "GET /trace")
71
+ self.assertEqual(attributes["traceId"], "4bf92f3577b34da6a3ce929d0e0e4736")
72
+ self.assertEqual(attributes["parentSpanId"], "00f067aa0ba902b7")
73
+ self.assertEqual(attributes["spanId"], "b7ad6b7169203331")
74
+ self.assertEqual(attributes["metadata"]["path"], "/trace")
75
+
76
+ def test_malformed_traceparent_keeps_synthetic_span_without_span_id_factory(self) -> None:
77
+ sdk_client = make_client()
78
+ transport = RecordingTransport.always_accept()
79
+ span_id_calls = 0
80
+ app = FastAPI()
81
+
82
+ def span_id_factory() -> str:
83
+ nonlocal span_id_calls
84
+ span_id_calls += 1
85
+ return "b7ad6b7169203331"
86
+
87
+ add_logbrew_middleware(app, client=sdk_client, transport=transport, span_id_factory=span_id_factory)
88
+
89
+ @app.get("/bad")
90
+ def bad() -> dict[str, bool]:
91
+ return {"ok": True}
92
+
93
+ with TestClient(app) as http:
94
+ response = http.get("/bad?debug=true", headers={"traceparent": "not-a-valid-traceparent"})
95
+
96
+ self.assertEqual(response.status_code, 200)
97
+ payload = json.loads(transport.sent_bodies[0])
98
+ attributes = payload["events"][0]["attributes"]
99
+ self.assertNotIn("parentSpanId", attributes)
100
+ self.assertTrue(attributes["traceId"].startswith("trace_evt_fastapi_span_"))
101
+ self.assertEqual(attributes["metadata"]["path"], "/bad")
102
+ self.assertEqual(span_id_calls, 0)
103
+
104
+ def test_exception_captures_issue_and_error_span(self) -> None:
105
+ sdk_client = make_client()
106
+ transport = RecordingTransport.always_accept()
107
+ app = FastAPI()
108
+ add_logbrew_middleware(app, client=sdk_client, transport=transport)
109
+
110
+ @app.get("/boom")
111
+ def boom() -> dict[str, bool]:
112
+ raise RuntimeError("broken handler")
113
+
114
+ with TestClient(app, raise_server_exceptions=False) as http:
115
+ response = http.get("/boom")
116
+
117
+ self.assertEqual(response.status_code, 500)
118
+ self.assertEqual(sdk_client.pending_events(), 0)
119
+ self.assertEqual(len(transport.sent_bodies), 1)
120
+ payload = json.loads(transport.sent_bodies[0])
121
+ self.assertEqual([event["type"] for event in payload["events"]], ["issue", "span"])
122
+ issue = payload["events"][0]["attributes"]
123
+ span = payload["events"][1]["attributes"]
124
+ self.assertEqual(issue["title"], "GET /boom failed")
125
+ self.assertEqual(issue["message"], "broken handler")
126
+ self.assertEqual(issue["metadata"]["exception_type"], "RuntimeError")
127
+ self.assertEqual(span["status"], "error")
128
+ self.assertEqual(span["metadata"]["status_code"], 500)
129
+
130
+ def test_flush_errors_do_not_break_application_by_default(self) -> None:
131
+ sdk_client = make_client()
132
+ transport = RecordingTransport([{"status_code": 401}])
133
+ app = FastAPI()
134
+ add_logbrew_middleware(app, client=sdk_client, transport=transport)
135
+
136
+ @app.get("/health")
137
+ def health() -> dict[str, bool]:
138
+ return {"ok": True}
139
+
140
+ with TestClient(app) as http:
141
+ response = http.get("/health")
142
+
143
+ self.assertEqual(response.status_code, 200)
144
+ self.assertEqual(sdk_client.pending_events(), 1)
145
+
146
+ def test_flush_errors_can_be_raised_for_test_environments(self) -> None:
147
+ sdk_client = make_client()
148
+ transport = RecordingTransport([{"status_code": 401}])
149
+ app = FastAPI()
150
+ add_logbrew_middleware(app, client=sdk_client, transport=transport, raise_flush_errors=True)
151
+
152
+ @app.get("/health")
153
+ def health() -> dict[str, bool]:
154
+ return {"ok": True}
155
+
156
+ with TestClient(app) as http, self.assertRaises(SdkError):
157
+ http.get("/health")
158
+
159
+
160
+ if __name__ == "__main__":
161
+ unittest.main()