logbrew-fastapi 0.1.1__tar.gz → 0.1.2__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: logbrew-fastapi
3
- Version: 0.1.1
3
+ Version: 0.1.2
4
4
  Summary: FastAPI integration for capturing LogBrew request spans and exceptions.
5
5
  Author: LogBrew
6
6
  License-Expression: MIT
@@ -23,6 +23,10 @@ Requires-Dist: logbrew-sdk<0.2.0,>=0.1.1
23
23
 
24
24
  # logbrew-fastapi
25
25
 
26
+ <p align="center">
27
+ <img src="https://raw.githubusercontent.com/LogBrewCo/sdk/main/assets/brand/logbrew-logo-espresso-bg-512.png" alt="LogBrew logo" width="96" height="96">
28
+ </p>
29
+
26
30
  FastAPI integration for capturing LogBrew request spans and exceptions with the public Python SDK.
27
31
 
28
32
  ## Install
@@ -64,6 +68,19 @@ def health() -> dict[str, bool]:
64
68
 
65
69
  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.
66
70
 
71
+ Request duration metrics are opt-in. Set `capture_request_metrics=True` to emit an explicit `http.server.duration` histogram for completed requests:
72
+
73
+ ```python
74
+ add_logbrew_middleware(
75
+ app,
76
+ client=client,
77
+ transport=transport,
78
+ capture_request_metrics=True,
79
+ )
80
+ ```
81
+
82
+ The metric includes primitive, low-cardinality metadata: `framework`, `method`, `routeTemplate`, `statusCode`, and `statusCodeClass`. Query strings and URL hashes are omitted. Set `capture_successful_requests=False` with `capture_request_metrics=True` when you only want duration metrics and not successful request spans. Avoid user IDs, request payloads, headers, or free-form text in custom metric metadata.
83
+
67
84
  By default, transport failures do not break the FastAPI response path. Set `raise_flush_errors=True` only when your app wants delivery failures to surface as request errors.
68
85
 
69
86
  Use a clearly fake placeholder like `LOGBREW_API_KEY` in examples.
@@ -1,5 +1,9 @@
1
1
  # logbrew-fastapi
2
2
 
3
+ <p align="center">
4
+ <img src="https://raw.githubusercontent.com/LogBrewCo/sdk/main/assets/brand/logbrew-logo-espresso-bg-512.png" alt="LogBrew logo" width="96" height="96">
5
+ </p>
6
+
3
7
  FastAPI integration for capturing LogBrew request spans and exceptions with the public Python SDK.
4
8
 
5
9
  ## Install
@@ -41,6 +45,19 @@ def health() -> dict[str, bool]:
41
45
 
42
46
  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.
43
47
 
48
+ Request duration metrics are opt-in. Set `capture_request_metrics=True` to emit an explicit `http.server.duration` histogram for completed requests:
49
+
50
+ ```python
51
+ add_logbrew_middleware(
52
+ app,
53
+ client=client,
54
+ transport=transport,
55
+ capture_request_metrics=True,
56
+ )
57
+ ```
58
+
59
+ The metric includes primitive, low-cardinality metadata: `framework`, `method`, `routeTemplate`, `statusCode`, and `statusCodeClass`. Query strings and URL hashes are omitted. Set `capture_successful_requests=False` with `capture_request_metrics=True` when you only want duration metrics and not successful request spans. Avoid user IDs, request payloads, headers, or free-form text in custom metric metadata.
60
+
44
61
  By default, transport failures do not break the FastAPI response path. Set `raise_flush_errors=True` only when your app wants delivery failures to surface as request errors.
45
62
 
46
63
  Use a clearly fake placeholder like `LOGBREW_API_KEY` in examples.
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "logbrew-fastapi"
7
- version = "0.1.1"
7
+ version = "0.1.2"
8
8
  description = "FastAPI integration for capturing LogBrew request spans and exceptions."
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -12,6 +12,7 @@ from typing import Any
12
12
  from fastapi import FastAPI, Request, Response
13
13
  from logbrew_sdk import (
14
14
  LogBrewClient,
15
+ MetricAttributes,
15
16
  RecordingTransport,
16
17
  SdkError,
17
18
  SpanAttributes,
@@ -30,10 +31,12 @@ class LogBrewFastAPIConfig:
30
31
  client: LogBrewClient
31
32
  transport: RecordingTransport | None = None
32
33
  capture_successful_requests: bool = True
34
+ capture_request_metrics: bool = False
33
35
  capture_exceptions: bool = True
34
36
  flush_on_response: bool = True
35
37
  raise_flush_errors: bool = False
36
38
  service_name: str = "fastapi"
39
+ request_metric_name: str = "http.server.duration"
37
40
  span_id_factory: Callable[[], str] | None = None
38
41
 
39
42
 
@@ -73,6 +76,82 @@ def request_metadata(
73
76
  return metadata
74
77
 
75
78
 
79
+ def request_route_template(request: Request) -> str:
80
+ """Return a low-cardinality FastAPI route template without query strings."""
81
+
82
+ route = request.scope.get("route")
83
+ route_path = getattr(route, "path", None)
84
+ template = route_path if isinstance(route_path, str) and route_path else request.url.path
85
+ return route_template_only(template)
86
+
87
+
88
+ def route_template_only(value: str) -> str:
89
+ """Strip query/hash text from a route template and normalize empty values."""
90
+
91
+ route_template = value.split("?", 1)[0].split("#", 1)[0].strip()
92
+ return route_template or "/"
93
+
94
+
95
+ def status_code_class(status_code: int) -> str:
96
+ """Return the coarse HTTP status code class used by request metrics."""
97
+
98
+ return f"{status_code // 100}xx" if 100 <= status_code <= 599 else "unknown"
99
+
100
+
101
+ def create_request_metric_attributes(
102
+ request: Request,
103
+ *,
104
+ status_code: int,
105
+ duration_ms: float,
106
+ metric_name: str = "http.server.duration",
107
+ ) -> MetricAttributes:
108
+ """Create privacy-safe request duration metric attributes for a completed FastAPI request."""
109
+
110
+ duration_value = float(duration_ms)
111
+ if duration_value < 0:
112
+ duration_value = 0.0
113
+ return {
114
+ "name": metric_name,
115
+ "kind": "histogram",
116
+ "value": duration_value,
117
+ "unit": "ms",
118
+ "temporality": "delta",
119
+ "metadata": {
120
+ "framework": "fastapi",
121
+ "method": request.method,
122
+ "routeTemplate": request_route_template(request),
123
+ "statusCode": status_code,
124
+ "statusCodeClass": status_code_class(status_code),
125
+ },
126
+ }
127
+
128
+
129
+ def capture_request_metric(
130
+ client: LogBrewClient,
131
+ request: Request,
132
+ *,
133
+ status_code: int,
134
+ duration_ms: float,
135
+ event_id: str | None = None,
136
+ timestamp: str | None = None,
137
+ metric_name: str = "http.server.duration",
138
+ ) -> str:
139
+ """Capture a FastAPI request duration metric and return its event id."""
140
+
141
+ metric_event_id = event_id or f"evt_fastapi_metric_{uuid.uuid4().hex}"
142
+ client.metric(
143
+ metric_event_id,
144
+ timestamp or utc_timestamp(),
145
+ create_request_metric_attributes(
146
+ request,
147
+ status_code=status_code,
148
+ duration_ms=duration_ms,
149
+ metric_name=metric_name,
150
+ ),
151
+ )
152
+ return metric_event_id
153
+
154
+
76
155
  def capture_request_span(
77
156
  client: LogBrewClient,
78
157
  request: Request,
@@ -154,10 +233,12 @@ class LogBrewFastAPIMiddleware(BaseHTTPMiddleware):
154
233
  client: LogBrewClient,
155
234
  transport: RecordingTransport | None = None,
156
235
  capture_successful_requests: bool = True,
236
+ capture_request_metrics: bool = False,
157
237
  capture_exceptions: bool = True,
158
238
  flush_on_response: bool = True,
159
239
  raise_flush_errors: bool = False,
160
240
  service_name: str = "fastapi",
241
+ request_metric_name: str = "http.server.duration",
161
242
  span_id_factory: Callable[[], str] | None = None,
162
243
  ) -> None:
163
244
  super().__init__(app)
@@ -165,10 +246,12 @@ class LogBrewFastAPIMiddleware(BaseHTTPMiddleware):
165
246
  client=client,
166
247
  transport=transport,
167
248
  capture_successful_requests=capture_successful_requests,
249
+ capture_request_metrics=capture_request_metrics,
168
250
  capture_exceptions=capture_exceptions,
169
251
  flush_on_response=flush_on_response,
170
252
  raise_flush_errors=raise_flush_errors,
171
253
  service_name=service_name,
254
+ request_metric_name=request_metric_name,
172
255
  span_id_factory=span_id_factory,
173
256
  )
174
257
 
@@ -178,7 +261,8 @@ class LogBrewFastAPIMiddleware(BaseHTTPMiddleware):
178
261
  response = await call_next(request)
179
262
  except Exception as exc:
180
263
  duration_ms = (time.perf_counter() - start) * 1000
181
- if self.config.capture_exceptions:
264
+ should_capture_exception = self.config.capture_exceptions
265
+ if should_capture_exception:
182
266
  capture_exception(self.config.client, request, exc)
183
267
  capture_request_span(
184
268
  self.config.client,
@@ -187,11 +271,21 @@ class LogBrewFastAPIMiddleware(BaseHTTPMiddleware):
187
271
  duration_ms=duration_ms,
188
272
  span_id_factory=self.config.span_id_factory,
189
273
  )
274
+ if self.config.capture_request_metrics:
275
+ capture_request_metric(
276
+ self.config.client,
277
+ request,
278
+ status_code=500,
279
+ duration_ms=duration_ms,
280
+ metric_name=self.config.request_metric_name,
281
+ )
282
+ if should_capture_exception or self.config.capture_request_metrics:
190
283
  self._flush_if_configured()
191
284
  raise
192
285
 
193
286
  duration_ms = (time.perf_counter() - start) * 1000
194
- if self.config.capture_successful_requests or response.status_code >= 500:
287
+ should_capture_request_span = self.config.capture_successful_requests or response.status_code >= 500
288
+ if should_capture_request_span:
195
289
  capture_request_span(
196
290
  self.config.client,
197
291
  request,
@@ -199,6 +293,15 @@ class LogBrewFastAPIMiddleware(BaseHTTPMiddleware):
199
293
  duration_ms=duration_ms,
200
294
  span_id_factory=self.config.span_id_factory,
201
295
  )
296
+ if self.config.capture_request_metrics:
297
+ capture_request_metric(
298
+ self.config.client,
299
+ request,
300
+ status_code=response.status_code,
301
+ duration_ms=duration_ms,
302
+ metric_name=self.config.request_metric_name,
303
+ )
304
+ if should_capture_request_span or self.config.capture_request_metrics:
202
305
  self._flush_if_configured()
203
306
  return response
204
307
 
@@ -218,10 +321,12 @@ def add_logbrew_middleware(
218
321
  client: LogBrewClient,
219
322
  transport: RecordingTransport | None = None,
220
323
  capture_successful_requests: bool = True,
324
+ capture_request_metrics: bool = False,
221
325
  capture_exceptions: bool = True,
222
326
  flush_on_response: bool = True,
223
327
  raise_flush_errors: bool = False,
224
328
  service_name: str = "fastapi",
329
+ request_metric_name: str = "http.server.duration",
225
330
  span_id_factory: Callable[[], str] | None = None,
226
331
  ) -> None:
227
332
  """Install LogBrew request/exception capture middleware on a FastAPI app."""
@@ -231,10 +336,12 @@ def add_logbrew_middleware(
231
336
  client=client,
232
337
  transport=transport,
233
338
  capture_successful_requests=capture_successful_requests,
339
+ capture_request_metrics=capture_request_metrics,
234
340
  capture_exceptions=capture_exceptions,
235
341
  flush_on_response=flush_on_response,
236
342
  raise_flush_errors=raise_flush_errors,
237
343
  service_name=service_name,
344
+ request_metric_name=request_metric_name,
238
345
  span_id_factory=span_id_factory,
239
346
  )
240
347
 
@@ -251,8 +358,11 @@ __all__ = [
251
358
  "LogBrewFastAPIMiddleware",
252
359
  "add_logbrew_middleware",
253
360
  "capture_exception",
361
+ "capture_request_metric",
254
362
  "capture_request_span",
363
+ "create_request_metric_attributes",
255
364
  "request_metadata",
256
365
  "request_name",
366
+ "request_route_template",
257
367
  "utc_timestamp",
258
368
  ]
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: logbrew-fastapi
3
- Version: 0.1.1
3
+ Version: 0.1.2
4
4
  Summary: FastAPI integration for capturing LogBrew request spans and exceptions.
5
5
  Author: LogBrew
6
6
  License-Expression: MIT
@@ -23,6 +23,10 @@ Requires-Dist: logbrew-sdk<0.2.0,>=0.1.1
23
23
 
24
24
  # logbrew-fastapi
25
25
 
26
+ <p align="center">
27
+ <img src="https://raw.githubusercontent.com/LogBrewCo/sdk/main/assets/brand/logbrew-logo-espresso-bg-512.png" alt="LogBrew logo" width="96" height="96">
28
+ </p>
29
+
26
30
  FastAPI integration for capturing LogBrew request spans and exceptions with the public Python SDK.
27
31
 
28
32
  ## Install
@@ -64,6 +68,19 @@ def health() -> dict[str, bool]:
64
68
 
65
69
  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.
66
70
 
71
+ Request duration metrics are opt-in. Set `capture_request_metrics=True` to emit an explicit `http.server.duration` histogram for completed requests:
72
+
73
+ ```python
74
+ add_logbrew_middleware(
75
+ app,
76
+ client=client,
77
+ transport=transport,
78
+ capture_request_metrics=True,
79
+ )
80
+ ```
81
+
82
+ The metric includes primitive, low-cardinality metadata: `framework`, `method`, `routeTemplate`, `statusCode`, and `statusCodeClass`. Query strings and URL hashes are omitted. Set `capture_successful_requests=False` with `capture_request_metrics=True` when you only want duration metrics and not successful request spans. Avoid user IDs, request payloads, headers, or free-form text in custom metric metadata.
83
+
67
84
  By default, transport failures do not break the FastAPI response path. Set `raise_flush_errors=True` only when your app wants delivery failures to surface as request errors.
68
85
 
69
86
  Use a clearly fake placeholder like `LOGBREW_API_KEY` in examples.
@@ -41,6 +41,44 @@ class FastAPIIntegrationTests(unittest.TestCase):
41
41
  self.assertEqual(attributes["status"], "ok")
42
42
  self.assertEqual(attributes["metadata"]["status_code"], 200)
43
43
 
44
+ def test_request_metrics_can_be_captured_without_request_spans(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
+ capture_successful_requests=False,
53
+ capture_request_metrics=True,
54
+ )
55
+
56
+ @app.get("/orders/{order_id}")
57
+ def order_detail(order_id: int) -> dict[str, int]:
58
+ return {"orderId": order_id}
59
+
60
+ with TestClient(app) as http:
61
+ response = http.get("/orders/42?debug=true#receipt")
62
+
63
+ self.assertEqual(response.status_code, 200)
64
+ self.assertEqual(sdk_client.pending_events(), 0)
65
+ self.assertEqual(len(transport.sent_bodies), 1)
66
+ payload = json.loads(transport.sent_bodies[0])
67
+ self.assertEqual([event["type"] for event in payload["events"]], ["metric"])
68
+ metric = payload["events"][0]["attributes"]
69
+ self.assertEqual(metric["name"], "http.server.duration")
70
+ self.assertEqual(metric["kind"], "histogram")
71
+ self.assertGreaterEqual(metric["value"], 0)
72
+ self.assertEqual(metric["unit"], "ms")
73
+ self.assertEqual(metric["temporality"], "delta")
74
+ metadata = metric["metadata"]
75
+ self.assertEqual(metadata["framework"], "fastapi")
76
+ self.assertEqual(metadata["method"], "GET")
77
+ self.assertEqual(metadata["routeTemplate"], "/orders/{order_id}")
78
+ self.assertEqual(metadata["statusCode"], 200)
79
+ self.assertEqual(metadata["statusCodeClass"], "2xx")
80
+ self.assertNotIn("debug", json.dumps(metadata))
81
+
44
82
  def test_valid_traceparent_continues_request_span(self) -> None:
45
83
  sdk_client = make_client()
46
84
  transport = RecordingTransport.always_accept()