fastapi-observer 0.2.2__tar.gz → 0.2.4__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.
- {fastapi_observer-0.2.2/src/fastapi_observer.egg-info → fastapi_observer-0.2.4}/PKG-INFO +63 -2
- {fastapi_observer-0.2.2 → fastapi_observer-0.2.4}/README.md +58 -1
- {fastapi_observer-0.2.2 → fastapi_observer-0.2.4}/pyproject.toml +6 -1
- {fastapi_observer-0.2.2 → fastapi_observer-0.2.4/src/fastapi_observer.egg-info}/PKG-INFO +63 -2
- {fastapi_observer-0.2.2 → fastapi_observer-0.2.4}/src/fastapi_observer.egg-info/SOURCES.txt +3 -0
- {fastapi_observer-0.2.2 → fastapi_observer-0.2.4}/src/fastapi_observer.egg-info/requires.txt +5 -0
- {fastapi_observer-0.2.2 → fastapi_observer-0.2.4}/src/fastapiobserver/__init__.py +9 -0
- fastapi_observer-0.2.4/src/fastapiobserver/_version.py +1 -0
- {fastapi_observer-0.2.2 → fastapi_observer-0.2.4}/src/fastapiobserver/logging.py +36 -2
- fastapi_observer-0.2.4/src/fastapiobserver/loguru.py +171 -0
- {fastapi_observer-0.2.2 → fastapi_observer-0.2.4}/src/fastapiobserver/middleware.py +9 -8
- {fastapi_observer-0.2.2 → fastapi_observer-0.2.4}/src/fastapiobserver/otel/resource.py +17 -5
- fastapi_observer-0.2.4/src/fastapiobserver/otel/types.py +49 -0
- {fastapi_observer-0.2.2 → fastapi_observer-0.2.4}/src/fastapiobserver/sinks.py +13 -2
- {fastapi_observer-0.2.2 → fastapi_observer-0.2.4}/tests/test_logging_schema_contract.py +31 -0
- fastapi_observer-0.2.4/tests/test_loguru_bridge.py +74 -0
- {fastapi_observer-0.2.2 → fastapi_observer-0.2.4}/tests/test_middleware.py +10 -0
- fastapi_observer-0.2.2/src/fastapiobserver/_version.py +0 -1
- {fastapi_observer-0.2.2 → fastapi_observer-0.2.4}/LICENSE +0 -0
- {fastapi_observer-0.2.2 → fastapi_observer-0.2.4}/setup.cfg +0 -0
- {fastapi_observer-0.2.2 → fastapi_observer-0.2.4}/src/fastapi_observer.egg-info/dependency_links.txt +0 -0
- {fastapi_observer-0.2.2 → fastapi_observer-0.2.4}/src/fastapi_observer.egg-info/top_level.txt +0 -0
- {fastapi_observer-0.2.2 → fastapi_observer-0.2.4}/src/fastapiobserver/config.py +0 -0
- {fastapi_observer-0.2.2 → fastapi_observer-0.2.4}/src/fastapiobserver/control_plane.py +0 -0
- {fastapi_observer-0.2.2 → fastapi_observer-0.2.4}/src/fastapiobserver/fastapi.py +0 -0
- {fastapi_observer-0.2.2 → fastapi_observer-0.2.4}/src/fastapiobserver/metrics.py +0 -0
- {fastapi_observer-0.2.2 → fastapi_observer-0.2.4}/src/fastapiobserver/otel/__init__.py +0 -0
- {fastapi_observer-0.2.2 → fastapi_observer-0.2.4}/src/fastapiobserver/otel/lifecycle.py +0 -0
- {fastapi_observer-0.2.2 → fastapi_observer-0.2.4}/src/fastapiobserver/otel/logs.py +0 -0
- {fastapi_observer-0.2.2 → fastapi_observer-0.2.4}/src/fastapiobserver/otel/metrics.py +0 -0
- {fastapi_observer-0.2.2 → fastapi_observer-0.2.4}/src/fastapiobserver/otel/settings.py +0 -0
- {fastapi_observer-0.2.2 → fastapi_observer-0.2.4}/src/fastapiobserver/otel/tracing.py +0 -0
- {fastapi_observer-0.2.2 → fastapi_observer-0.2.4}/src/fastapiobserver/plugins.py +0 -0
- {fastapi_observer-0.2.2 → fastapi_observer-0.2.4}/src/fastapiobserver/propagation.py +0 -0
- {fastapi_observer-0.2.2 → fastapi_observer-0.2.4}/src/fastapiobserver/py.typed +0 -0
- {fastapi_observer-0.2.2 → fastapi_observer-0.2.4}/src/fastapiobserver/request_context.py +0 -0
- {fastapi_observer-0.2.2 → fastapi_observer-0.2.4}/src/fastapiobserver/security.py +0 -0
- {fastapi_observer-0.2.2 → fastapi_observer-0.2.4}/src/fastapiobserver/utils.py +0 -0
- {fastapi_observer-0.2.2 → fastapi_observer-0.2.4}/tests/test_body_media_allowlist.py +0 -0
- {fastapi_observer-0.2.2 → fastapi_observer-0.2.4}/tests/test_config_settings.py +0 -0
- {fastapi_observer-0.2.2 → fastapi_observer-0.2.4}/tests/test_excluded_url_precedence.py +0 -0
- {fastapi_observer-0.2.2 → fastapi_observer-0.2.4}/tests/test_fastapi_install.py +0 -0
- {fastapi_observer-0.2.2 → fastapi_observer-0.2.4}/tests/test_log_queue_controls.py +0 -0
- {fastapi_observer-0.2.2 → fastapi_observer-0.2.4}/tests/test_metrics_optional_dependency.py +0 -0
- {fastapi_observer-0.2.2 → fastapi_observer-0.2.4}/tests/test_negotiate_content_type.py +0 -0
- {fastapi_observer-0.2.2 → fastapi_observer-0.2.4}/tests/test_otel_integration.py +0 -0
- {fastapi_observer-0.2.2 → fastapi_observer-0.2.4}/tests/test_otel_lifecycle.py +0 -0
- {fastapi_observer-0.2.2 → fastapi_observer-0.2.4}/tests/test_otel_log_correlation.py +0 -0
- {fastapi_observer-0.2.2 → fastapi_observer-0.2.4}/tests/test_otel_logs_from_env.py +0 -0
- {fastapi_observer-0.2.2 → fastapi_observer-0.2.4}/tests/test_otel_logs_pipeline.py +0 -0
- {fastapi_observer-0.2.2 → fastapi_observer-0.2.4}/tests/test_otel_metrics_from_env.py +0 -0
- {fastapi_observer-0.2.2 → fastapi_observer-0.2.4}/tests/test_otlp_export_integration.py +0 -0
- {fastapi_observer-0.2.2 → fastapi_observer-0.2.4}/tests/test_plugin_isolation.py +0 -0
- {fastapi_observer-0.2.2 → fastapi_observer-0.2.4}/tests/test_propagation.py +0 -0
- {fastapi_observer-0.2.2 → fastapi_observer-0.2.4}/tests/test_redaction_presets.py +0 -0
- {fastapi_observer-0.2.2 → fastapi_observer-0.2.4}/tests/test_route_template_cardinality.py +0 -0
- {fastapi_observer-0.2.2 → fastapi_observer-0.2.4}/tests/test_runtime_control_auth.py +0 -0
- {fastapi_observer-0.2.2 → fastapi_observer-0.2.4}/tests/test_security_redaction.py +0 -0
- {fastapi_observer-0.2.2 → fastapi_observer-0.2.4}/tests/test_sink_circuit_breaker.py +0 -0
- {fastapi_observer-0.2.2 → fastapi_observer-0.2.4}/tests/test_trust_boundary.py +0 -0
- {fastapi_observer-0.2.2 → fastapi_observer-0.2.4}/tests/test_utils_refactors.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: fastapi-observer
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.4
|
|
4
4
|
Summary: Zero-glue FastAPI observability with security presets and runtime controls
|
|
5
5
|
Author-email: Vitaee <opensource@vitaee.dev>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -32,6 +32,8 @@ Provides-Extra: fast-json
|
|
|
32
32
|
Requires-Dist: orjson>=3.11.7; extra == "fast-json"
|
|
33
33
|
Provides-Extra: prometheus
|
|
34
34
|
Requires-Dist: prometheus-client>=0.24.1; extra == "prometheus"
|
|
35
|
+
Provides-Extra: loguru
|
|
36
|
+
Requires-Dist: loguru>=0.7.2; extra == "loguru"
|
|
35
37
|
Provides-Extra: otel
|
|
36
38
|
Requires-Dist: opentelemetry-api>=1.39.1; extra == "otel"
|
|
37
39
|
Requires-Dist: opentelemetry-sdk>=1.39.1; extra == "otel"
|
|
@@ -45,6 +47,7 @@ Requires-Dist: opentelemetry-instrumentation-requests>=0.60b1; extra == "otel-re
|
|
|
45
47
|
Provides-Extra: all
|
|
46
48
|
Requires-Dist: orjson>=3.11.7; extra == "all"
|
|
47
49
|
Requires-Dist: prometheus-client>=0.24.1; extra == "all"
|
|
50
|
+
Requires-Dist: loguru>=0.7.2; extra == "all"
|
|
48
51
|
Requires-Dist: opentelemetry-api>=1.39.1; extra == "all"
|
|
49
52
|
Requires-Dist: opentelemetry-sdk>=1.39.1; extra == "all"
|
|
50
53
|
Requires-Dist: opentelemetry-exporter-otlp>=1.39.1; extra == "all"
|
|
@@ -54,6 +57,7 @@ Requires-Dist: opentelemetry-instrumentation-httpx>=0.60b1; extra == "all"
|
|
|
54
57
|
Requires-Dist: opentelemetry-instrumentation-requests>=0.60b1; extra == "all"
|
|
55
58
|
Provides-Extra: dev
|
|
56
59
|
Requires-Dist: httpx>=0.28.1; extra == "dev"
|
|
60
|
+
Requires-Dist: loguru>=0.7.2; extra == "dev"
|
|
57
61
|
Requires-Dist: mypy>=1.19.1; extra == "dev"
|
|
58
62
|
Requires-Dist: pip-audit>=2.10.0; extra == "dev"
|
|
59
63
|
Requires-Dist: pytest>=9.0.2; extra == "dev"
|
|
@@ -64,6 +68,7 @@ Dynamic: license-file
|
|
|
64
68
|
|
|
65
69
|
# fastapi-observer
|
|
66
70
|
|
|
71
|
+
[](https://github.com/Vitaee/FastapiObserver/actions/workflows/ci.yml)
|
|
67
72
|
[](https://buymeacoffee.com/FYbPCSu)
|
|
68
73
|
|
|
69
74
|
**Zero-glue observability for FastAPI.**
|
|
@@ -74,6 +79,20 @@ Dynamic: license-file
|
|
|
74
79
|
|
|
75
80
|
---
|
|
76
81
|
|
|
82
|
+
## Compatibility Matrix
|
|
83
|
+
|
|
84
|
+
| Component | Supported / Tested |
|
|
85
|
+
|---|---|
|
|
86
|
+
| Python | `3.10` to `3.14` (CI matrix) |
|
|
87
|
+
| FastAPI | `>=0.129.0` |
|
|
88
|
+
| Starlette | `>=0.52.1` |
|
|
89
|
+
| pydantic-settings | `>=2.10.1` |
|
|
90
|
+
| Prometheus backend | `prometheus-client>=0.24.1` (optional extra) |
|
|
91
|
+
| OpenTelemetry | `opentelemetry-api/sdk/exporter>=1.39.1` (optional extra) |
|
|
92
|
+
| Loguru bridge | `loguru>=0.7.2` (optional extra) |
|
|
93
|
+
|
|
94
|
+
---
|
|
95
|
+
|
|
77
96
|
## Why This Package Exists
|
|
78
97
|
|
|
79
98
|
Most FastAPI services eventually need the same observability plumbing:
|
|
@@ -124,6 +143,9 @@ pip install fastapi-observer
|
|
|
124
143
|
# Prometheus metrics support
|
|
125
144
|
pip install "fastapi-observer[prometheus]"
|
|
126
145
|
|
|
146
|
+
# Loguru coexistence bridge support
|
|
147
|
+
pip install "fastapi-observer[loguru]"
|
|
148
|
+
|
|
127
149
|
# OpenTelemetry tracing/logs support
|
|
128
150
|
pip install "fastapi-observer[otel]"
|
|
129
151
|
|
|
@@ -391,6 +413,9 @@ Request arrives
|
|
|
391
413
|
"method": "GET",
|
|
392
414
|
"path": "/orders/42",
|
|
393
415
|
"status_code": 200,
|
|
416
|
+
"http.request.method": "GET",
|
|
417
|
+
"url.path": "/orders/42",
|
|
418
|
+
"http.response.status_code": 200,
|
|
394
419
|
"duration_ms": 3.456,
|
|
395
420
|
"client_ip": "10.0.0.1",
|
|
396
421
|
"error_type": "ok"
|
|
@@ -398,6 +423,18 @@ Request arrives
|
|
|
398
423
|
}
|
|
399
424
|
```
|
|
400
425
|
|
|
426
|
+
On exception logs, a structured `error` object is included for indexed queries:
|
|
427
|
+
|
|
428
|
+
```json
|
|
429
|
+
{
|
|
430
|
+
"error": {
|
|
431
|
+
"type": "RuntimeError",
|
|
432
|
+
"message": "boom",
|
|
433
|
+
"stacktrace": "Traceback (most recent call last): ..."
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
```
|
|
437
|
+
|
|
401
438
|
---
|
|
402
439
|
|
|
403
440
|
## Production Deployment Guide
|
|
@@ -546,6 +583,7 @@ The `examples/` directory contains runnable demos:
|
|
|
546
583
|
| [`security_presets_app.py`](examples/security_presets_app.py) | Preset-based security policy |
|
|
547
584
|
| [`allowlist_app.py`](examples/allowlist_app.py) | Allowlist-only sanitization |
|
|
548
585
|
| [`otel_app.py`](examples/otel_app.py) | OTel tracing and resource attributes |
|
|
586
|
+
| [`benchmarks/`](examples/benchmarks/) | Baseline vs observer benchmark harness |
|
|
549
587
|
| [`k8s/`](examples/k8s/) | Kubernetes-native stack with Prometheus + Loki + Tempo + Grafana |
|
|
550
588
|
| [`full_stack/`](examples/full_stack/) | **Docker Compose stack**: 3 FastAPI services + Grafana + Prometheus + Loki + Tempo |
|
|
551
589
|
|
|
@@ -749,6 +787,20 @@ Breaker metrics exposed on `/metrics`:
|
|
|
749
787
|
- `fastapiobserver_sink_circuit_breaker_half_open_total{sink}`
|
|
750
788
|
- `fastapiobserver_sink_circuit_breaker_closes_total{sink}`
|
|
751
789
|
|
|
790
|
+
### Loguru Coexistence
|
|
791
|
+
|
|
792
|
+
If your service already uses `loguru`, forward those logs into
|
|
793
|
+
`fastapi-observer` instead of maintaining two independent pipelines.
|
|
794
|
+
|
|
795
|
+
```python
|
|
796
|
+
from fastapiobserver import install_loguru_bridge
|
|
797
|
+
|
|
798
|
+
# loguru -> stdlib -> fastapi-observer queue/sinks
|
|
799
|
+
bridge_id = install_loguru_bridge()
|
|
800
|
+
```
|
|
801
|
+
|
|
802
|
+
Detailed migration/coexistence guide: [`loguru.md`](loguru.md)
|
|
803
|
+
|
|
752
804
|
---
|
|
753
805
|
|
|
754
806
|
## Plugin Hooks
|
|
@@ -832,13 +884,22 @@ Repository integration tests include:
|
|
|
832
884
|
|
|
833
885
|
---
|
|
834
886
|
|
|
887
|
+
## Benchmarking
|
|
888
|
+
|
|
889
|
+
Reproducible benchmark harness and methodology:
|
|
890
|
+
- Guide: [`benchmarks.md`](benchmarks.md)
|
|
891
|
+
- Apps: `examples/benchmarks/plain_fastapi.py` and `examples/benchmarks/observer_fastapi.py`
|
|
892
|
+
- Runner: `examples/benchmarks/run_local_benchmark.sh`
|
|
893
|
+
|
|
894
|
+
---
|
|
895
|
+
|
|
835
896
|
## Release Tracks
|
|
836
897
|
|
|
837
898
|
- `0.1.x`: secure-by-default core
|
|
838
899
|
- `0.2.x`: OTel interoperability, security presets, allowlists
|
|
839
900
|
- `1.0.0`: dynamic runtime controls and plugin stability
|
|
840
901
|
|
|
841
|
-
Current release version: `0.2.
|
|
902
|
+
Current release version: `0.2.4`
|
|
842
903
|
|
|
843
904
|
## Changelog Policy
|
|
844
905
|
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
# fastapi-observer
|
|
2
2
|
|
|
3
|
+
[](https://github.com/Vitaee/FastapiObserver/actions/workflows/ci.yml)
|
|
3
4
|
[](https://buymeacoffee.com/FYbPCSu)
|
|
4
5
|
|
|
5
6
|
**Zero-glue observability for FastAPI.**
|
|
@@ -10,6 +11,20 @@
|
|
|
10
11
|
|
|
11
12
|
---
|
|
12
13
|
|
|
14
|
+
## Compatibility Matrix
|
|
15
|
+
|
|
16
|
+
| Component | Supported / Tested |
|
|
17
|
+
|---|---|
|
|
18
|
+
| Python | `3.10` to `3.14` (CI matrix) |
|
|
19
|
+
| FastAPI | `>=0.129.0` |
|
|
20
|
+
| Starlette | `>=0.52.1` |
|
|
21
|
+
| pydantic-settings | `>=2.10.1` |
|
|
22
|
+
| Prometheus backend | `prometheus-client>=0.24.1` (optional extra) |
|
|
23
|
+
| OpenTelemetry | `opentelemetry-api/sdk/exporter>=1.39.1` (optional extra) |
|
|
24
|
+
| Loguru bridge | `loguru>=0.7.2` (optional extra) |
|
|
25
|
+
|
|
26
|
+
---
|
|
27
|
+
|
|
13
28
|
## Why This Package Exists
|
|
14
29
|
|
|
15
30
|
Most FastAPI services eventually need the same observability plumbing:
|
|
@@ -60,6 +75,9 @@ pip install fastapi-observer
|
|
|
60
75
|
# Prometheus metrics support
|
|
61
76
|
pip install "fastapi-observer[prometheus]"
|
|
62
77
|
|
|
78
|
+
# Loguru coexistence bridge support
|
|
79
|
+
pip install "fastapi-observer[loguru]"
|
|
80
|
+
|
|
63
81
|
# OpenTelemetry tracing/logs support
|
|
64
82
|
pip install "fastapi-observer[otel]"
|
|
65
83
|
|
|
@@ -327,6 +345,9 @@ Request arrives
|
|
|
327
345
|
"method": "GET",
|
|
328
346
|
"path": "/orders/42",
|
|
329
347
|
"status_code": 200,
|
|
348
|
+
"http.request.method": "GET",
|
|
349
|
+
"url.path": "/orders/42",
|
|
350
|
+
"http.response.status_code": 200,
|
|
330
351
|
"duration_ms": 3.456,
|
|
331
352
|
"client_ip": "10.0.0.1",
|
|
332
353
|
"error_type": "ok"
|
|
@@ -334,6 +355,18 @@ Request arrives
|
|
|
334
355
|
}
|
|
335
356
|
```
|
|
336
357
|
|
|
358
|
+
On exception logs, a structured `error` object is included for indexed queries:
|
|
359
|
+
|
|
360
|
+
```json
|
|
361
|
+
{
|
|
362
|
+
"error": {
|
|
363
|
+
"type": "RuntimeError",
|
|
364
|
+
"message": "boom",
|
|
365
|
+
"stacktrace": "Traceback (most recent call last): ..."
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
```
|
|
369
|
+
|
|
337
370
|
---
|
|
338
371
|
|
|
339
372
|
## Production Deployment Guide
|
|
@@ -482,6 +515,7 @@ The `examples/` directory contains runnable demos:
|
|
|
482
515
|
| [`security_presets_app.py`](examples/security_presets_app.py) | Preset-based security policy |
|
|
483
516
|
| [`allowlist_app.py`](examples/allowlist_app.py) | Allowlist-only sanitization |
|
|
484
517
|
| [`otel_app.py`](examples/otel_app.py) | OTel tracing and resource attributes |
|
|
518
|
+
| [`benchmarks/`](examples/benchmarks/) | Baseline vs observer benchmark harness |
|
|
485
519
|
| [`k8s/`](examples/k8s/) | Kubernetes-native stack with Prometheus + Loki + Tempo + Grafana |
|
|
486
520
|
| [`full_stack/`](examples/full_stack/) | **Docker Compose stack**: 3 FastAPI services + Grafana + Prometheus + Loki + Tempo |
|
|
487
521
|
|
|
@@ -685,6 +719,20 @@ Breaker metrics exposed on `/metrics`:
|
|
|
685
719
|
- `fastapiobserver_sink_circuit_breaker_half_open_total{sink}`
|
|
686
720
|
- `fastapiobserver_sink_circuit_breaker_closes_total{sink}`
|
|
687
721
|
|
|
722
|
+
### Loguru Coexistence
|
|
723
|
+
|
|
724
|
+
If your service already uses `loguru`, forward those logs into
|
|
725
|
+
`fastapi-observer` instead of maintaining two independent pipelines.
|
|
726
|
+
|
|
727
|
+
```python
|
|
728
|
+
from fastapiobserver import install_loguru_bridge
|
|
729
|
+
|
|
730
|
+
# loguru -> stdlib -> fastapi-observer queue/sinks
|
|
731
|
+
bridge_id = install_loguru_bridge()
|
|
732
|
+
```
|
|
733
|
+
|
|
734
|
+
Detailed migration/coexistence guide: [`loguru.md`](loguru.md)
|
|
735
|
+
|
|
688
736
|
---
|
|
689
737
|
|
|
690
738
|
## Plugin Hooks
|
|
@@ -768,13 +816,22 @@ Repository integration tests include:
|
|
|
768
816
|
|
|
769
817
|
---
|
|
770
818
|
|
|
819
|
+
## Benchmarking
|
|
820
|
+
|
|
821
|
+
Reproducible benchmark harness and methodology:
|
|
822
|
+
- Guide: [`benchmarks.md`](benchmarks.md)
|
|
823
|
+
- Apps: `examples/benchmarks/plain_fastapi.py` and `examples/benchmarks/observer_fastapi.py`
|
|
824
|
+
- Runner: `examples/benchmarks/run_local_benchmark.sh`
|
|
825
|
+
|
|
826
|
+
---
|
|
827
|
+
|
|
771
828
|
## Release Tracks
|
|
772
829
|
|
|
773
830
|
- `0.1.x`: secure-by-default core
|
|
774
831
|
- `0.2.x`: OTel interoperability, security presets, allowlists
|
|
775
832
|
- `1.0.0`: dynamic runtime controls and plugin stability
|
|
776
833
|
|
|
777
|
-
Current release version: `0.2.
|
|
834
|
+
Current release version: `0.2.4`
|
|
778
835
|
|
|
779
836
|
## Changelog Policy
|
|
780
837
|
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "fastapi-observer"
|
|
7
|
-
version = "0.2.
|
|
7
|
+
version = "0.2.4"
|
|
8
8
|
description = "Zero-glue FastAPI observability with security presets and runtime controls"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10,<3.15"
|
|
@@ -48,6 +48,9 @@ fast-json = [
|
|
|
48
48
|
prometheus = [
|
|
49
49
|
"prometheus-client>=0.24.1",
|
|
50
50
|
]
|
|
51
|
+
loguru = [
|
|
52
|
+
"loguru>=0.7.2",
|
|
53
|
+
]
|
|
51
54
|
otel = [
|
|
52
55
|
"opentelemetry-api>=1.39.1",
|
|
53
56
|
"opentelemetry-sdk>=1.39.1",
|
|
@@ -64,6 +67,7 @@ otel-requests = [
|
|
|
64
67
|
all = [
|
|
65
68
|
"orjson>=3.11.7",
|
|
66
69
|
"prometheus-client>=0.24.1",
|
|
70
|
+
"loguru>=0.7.2",
|
|
67
71
|
"opentelemetry-api>=1.39.1",
|
|
68
72
|
"opentelemetry-sdk>=1.39.1",
|
|
69
73
|
"opentelemetry-exporter-otlp>=1.39.1",
|
|
@@ -74,6 +78,7 @@ all = [
|
|
|
74
78
|
]
|
|
75
79
|
dev = [
|
|
76
80
|
"httpx>=0.28.1",
|
|
81
|
+
"loguru>=0.7.2",
|
|
77
82
|
"mypy>=1.19.1",
|
|
78
83
|
"pip-audit>=2.10.0",
|
|
79
84
|
"pytest>=9.0.2",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: fastapi-observer
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.4
|
|
4
4
|
Summary: Zero-glue FastAPI observability with security presets and runtime controls
|
|
5
5
|
Author-email: Vitaee <opensource@vitaee.dev>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -32,6 +32,8 @@ Provides-Extra: fast-json
|
|
|
32
32
|
Requires-Dist: orjson>=3.11.7; extra == "fast-json"
|
|
33
33
|
Provides-Extra: prometheus
|
|
34
34
|
Requires-Dist: prometheus-client>=0.24.1; extra == "prometheus"
|
|
35
|
+
Provides-Extra: loguru
|
|
36
|
+
Requires-Dist: loguru>=0.7.2; extra == "loguru"
|
|
35
37
|
Provides-Extra: otel
|
|
36
38
|
Requires-Dist: opentelemetry-api>=1.39.1; extra == "otel"
|
|
37
39
|
Requires-Dist: opentelemetry-sdk>=1.39.1; extra == "otel"
|
|
@@ -45,6 +47,7 @@ Requires-Dist: opentelemetry-instrumentation-requests>=0.60b1; extra == "otel-re
|
|
|
45
47
|
Provides-Extra: all
|
|
46
48
|
Requires-Dist: orjson>=3.11.7; extra == "all"
|
|
47
49
|
Requires-Dist: prometheus-client>=0.24.1; extra == "all"
|
|
50
|
+
Requires-Dist: loguru>=0.7.2; extra == "all"
|
|
48
51
|
Requires-Dist: opentelemetry-api>=1.39.1; extra == "all"
|
|
49
52
|
Requires-Dist: opentelemetry-sdk>=1.39.1; extra == "all"
|
|
50
53
|
Requires-Dist: opentelemetry-exporter-otlp>=1.39.1; extra == "all"
|
|
@@ -54,6 +57,7 @@ Requires-Dist: opentelemetry-instrumentation-httpx>=0.60b1; extra == "all"
|
|
|
54
57
|
Requires-Dist: opentelemetry-instrumentation-requests>=0.60b1; extra == "all"
|
|
55
58
|
Provides-Extra: dev
|
|
56
59
|
Requires-Dist: httpx>=0.28.1; extra == "dev"
|
|
60
|
+
Requires-Dist: loguru>=0.7.2; extra == "dev"
|
|
57
61
|
Requires-Dist: mypy>=1.19.1; extra == "dev"
|
|
58
62
|
Requires-Dist: pip-audit>=2.10.0; extra == "dev"
|
|
59
63
|
Requires-Dist: pytest>=9.0.2; extra == "dev"
|
|
@@ -64,6 +68,7 @@ Dynamic: license-file
|
|
|
64
68
|
|
|
65
69
|
# fastapi-observer
|
|
66
70
|
|
|
71
|
+
[](https://github.com/Vitaee/FastapiObserver/actions/workflows/ci.yml)
|
|
67
72
|
[](https://buymeacoffee.com/FYbPCSu)
|
|
68
73
|
|
|
69
74
|
**Zero-glue observability for FastAPI.**
|
|
@@ -74,6 +79,20 @@ Dynamic: license-file
|
|
|
74
79
|
|
|
75
80
|
---
|
|
76
81
|
|
|
82
|
+
## Compatibility Matrix
|
|
83
|
+
|
|
84
|
+
| Component | Supported / Tested |
|
|
85
|
+
|---|---|
|
|
86
|
+
| Python | `3.10` to `3.14` (CI matrix) |
|
|
87
|
+
| FastAPI | `>=0.129.0` |
|
|
88
|
+
| Starlette | `>=0.52.1` |
|
|
89
|
+
| pydantic-settings | `>=2.10.1` |
|
|
90
|
+
| Prometheus backend | `prometheus-client>=0.24.1` (optional extra) |
|
|
91
|
+
| OpenTelemetry | `opentelemetry-api/sdk/exporter>=1.39.1` (optional extra) |
|
|
92
|
+
| Loguru bridge | `loguru>=0.7.2` (optional extra) |
|
|
93
|
+
|
|
94
|
+
---
|
|
95
|
+
|
|
77
96
|
## Why This Package Exists
|
|
78
97
|
|
|
79
98
|
Most FastAPI services eventually need the same observability plumbing:
|
|
@@ -124,6 +143,9 @@ pip install fastapi-observer
|
|
|
124
143
|
# Prometheus metrics support
|
|
125
144
|
pip install "fastapi-observer[prometheus]"
|
|
126
145
|
|
|
146
|
+
# Loguru coexistence bridge support
|
|
147
|
+
pip install "fastapi-observer[loguru]"
|
|
148
|
+
|
|
127
149
|
# OpenTelemetry tracing/logs support
|
|
128
150
|
pip install "fastapi-observer[otel]"
|
|
129
151
|
|
|
@@ -391,6 +413,9 @@ Request arrives
|
|
|
391
413
|
"method": "GET",
|
|
392
414
|
"path": "/orders/42",
|
|
393
415
|
"status_code": 200,
|
|
416
|
+
"http.request.method": "GET",
|
|
417
|
+
"url.path": "/orders/42",
|
|
418
|
+
"http.response.status_code": 200,
|
|
394
419
|
"duration_ms": 3.456,
|
|
395
420
|
"client_ip": "10.0.0.1",
|
|
396
421
|
"error_type": "ok"
|
|
@@ -398,6 +423,18 @@ Request arrives
|
|
|
398
423
|
}
|
|
399
424
|
```
|
|
400
425
|
|
|
426
|
+
On exception logs, a structured `error` object is included for indexed queries:
|
|
427
|
+
|
|
428
|
+
```json
|
|
429
|
+
{
|
|
430
|
+
"error": {
|
|
431
|
+
"type": "RuntimeError",
|
|
432
|
+
"message": "boom",
|
|
433
|
+
"stacktrace": "Traceback (most recent call last): ..."
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
```
|
|
437
|
+
|
|
401
438
|
---
|
|
402
439
|
|
|
403
440
|
## Production Deployment Guide
|
|
@@ -546,6 +583,7 @@ The `examples/` directory contains runnable demos:
|
|
|
546
583
|
| [`security_presets_app.py`](examples/security_presets_app.py) | Preset-based security policy |
|
|
547
584
|
| [`allowlist_app.py`](examples/allowlist_app.py) | Allowlist-only sanitization |
|
|
548
585
|
| [`otel_app.py`](examples/otel_app.py) | OTel tracing and resource attributes |
|
|
586
|
+
| [`benchmarks/`](examples/benchmarks/) | Baseline vs observer benchmark harness |
|
|
549
587
|
| [`k8s/`](examples/k8s/) | Kubernetes-native stack with Prometheus + Loki + Tempo + Grafana |
|
|
550
588
|
| [`full_stack/`](examples/full_stack/) | **Docker Compose stack**: 3 FastAPI services + Grafana + Prometheus + Loki + Tempo |
|
|
551
589
|
|
|
@@ -749,6 +787,20 @@ Breaker metrics exposed on `/metrics`:
|
|
|
749
787
|
- `fastapiobserver_sink_circuit_breaker_half_open_total{sink}`
|
|
750
788
|
- `fastapiobserver_sink_circuit_breaker_closes_total{sink}`
|
|
751
789
|
|
|
790
|
+
### Loguru Coexistence
|
|
791
|
+
|
|
792
|
+
If your service already uses `loguru`, forward those logs into
|
|
793
|
+
`fastapi-observer` instead of maintaining two independent pipelines.
|
|
794
|
+
|
|
795
|
+
```python
|
|
796
|
+
from fastapiobserver import install_loguru_bridge
|
|
797
|
+
|
|
798
|
+
# loguru -> stdlib -> fastapi-observer queue/sinks
|
|
799
|
+
bridge_id = install_loguru_bridge()
|
|
800
|
+
```
|
|
801
|
+
|
|
802
|
+
Detailed migration/coexistence guide: [`loguru.md`](loguru.md)
|
|
803
|
+
|
|
752
804
|
---
|
|
753
805
|
|
|
754
806
|
## Plugin Hooks
|
|
@@ -832,13 +884,22 @@ Repository integration tests include:
|
|
|
832
884
|
|
|
833
885
|
---
|
|
834
886
|
|
|
887
|
+
## Benchmarking
|
|
888
|
+
|
|
889
|
+
Reproducible benchmark harness and methodology:
|
|
890
|
+
- Guide: [`benchmarks.md`](benchmarks.md)
|
|
891
|
+
- Apps: `examples/benchmarks/plain_fastapi.py` and `examples/benchmarks/observer_fastapi.py`
|
|
892
|
+
- Runner: `examples/benchmarks/run_local_benchmark.sh`
|
|
893
|
+
|
|
894
|
+
---
|
|
895
|
+
|
|
835
896
|
## Release Tracks
|
|
836
897
|
|
|
837
898
|
- `0.1.x`: secure-by-default core
|
|
838
899
|
- `0.2.x`: OTel interoperability, security presets, allowlists
|
|
839
900
|
- `1.0.0`: dynamic runtime controls and plugin stability
|
|
840
901
|
|
|
841
|
-
Current release version: `0.2.
|
|
902
|
+
Current release version: `0.2.4`
|
|
842
903
|
|
|
843
904
|
## Changelog Policy
|
|
844
905
|
|
|
@@ -12,6 +12,7 @@ src/fastapiobserver/config.py
|
|
|
12
12
|
src/fastapiobserver/control_plane.py
|
|
13
13
|
src/fastapiobserver/fastapi.py
|
|
14
14
|
src/fastapiobserver/logging.py
|
|
15
|
+
src/fastapiobserver/loguru.py
|
|
15
16
|
src/fastapiobserver/metrics.py
|
|
16
17
|
src/fastapiobserver/middleware.py
|
|
17
18
|
src/fastapiobserver/plugins.py
|
|
@@ -28,12 +29,14 @@ src/fastapiobserver/otel/metrics.py
|
|
|
28
29
|
src/fastapiobserver/otel/resource.py
|
|
29
30
|
src/fastapiobserver/otel/settings.py
|
|
30
31
|
src/fastapiobserver/otel/tracing.py
|
|
32
|
+
src/fastapiobserver/otel/types.py
|
|
31
33
|
tests/test_body_media_allowlist.py
|
|
32
34
|
tests/test_config_settings.py
|
|
33
35
|
tests/test_excluded_url_precedence.py
|
|
34
36
|
tests/test_fastapi_install.py
|
|
35
37
|
tests/test_log_queue_controls.py
|
|
36
38
|
tests/test_logging_schema_contract.py
|
|
39
|
+
tests/test_loguru_bridge.py
|
|
37
40
|
tests/test_metrics_optional_dependency.py
|
|
38
41
|
tests/test_middleware.py
|
|
39
42
|
tests/test_negotiate_content_type.py
|
{fastapi_observer-0.2.2 → fastapi_observer-0.2.4}/src/fastapi_observer.egg-info/requires.txt
RENAMED
|
@@ -5,6 +5,7 @@ starlette>=0.52.1
|
|
|
5
5
|
[all]
|
|
6
6
|
orjson>=3.11.7
|
|
7
7
|
prometheus-client>=0.24.1
|
|
8
|
+
loguru>=0.7.2
|
|
8
9
|
opentelemetry-api>=1.39.1
|
|
9
10
|
opentelemetry-sdk>=1.39.1
|
|
10
11
|
opentelemetry-exporter-otlp>=1.39.1
|
|
@@ -15,6 +16,7 @@ opentelemetry-instrumentation-requests>=0.60b1
|
|
|
15
16
|
|
|
16
17
|
[dev]
|
|
17
18
|
httpx>=0.28.1
|
|
19
|
+
loguru>=0.7.2
|
|
18
20
|
mypy>=1.19.1
|
|
19
21
|
pip-audit>=2.10.0
|
|
20
22
|
pytest>=9.0.2
|
|
@@ -25,6 +27,9 @@ cyclonedx-bom>=7.2.1
|
|
|
25
27
|
[fast-json]
|
|
26
28
|
orjson>=3.11.7
|
|
27
29
|
|
|
30
|
+
[loguru]
|
|
31
|
+
loguru>=0.7.2
|
|
32
|
+
|
|
28
33
|
[otel]
|
|
29
34
|
opentelemetry-api>=1.39.1
|
|
30
35
|
opentelemetry-sdk>=1.39.1
|
|
@@ -12,6 +12,11 @@ from .logging import (
|
|
|
12
12
|
get_sink_circuit_breaker_stats,
|
|
13
13
|
setup_logging,
|
|
14
14
|
)
|
|
15
|
+
from .loguru import (
|
|
16
|
+
build_loguru_to_stdlib_sink,
|
|
17
|
+
install_loguru_bridge,
|
|
18
|
+
remove_loguru_bridge,
|
|
19
|
+
)
|
|
15
20
|
from .metrics import (
|
|
16
21
|
build_metrics_backend,
|
|
17
22
|
get_registered_metrics_backends,
|
|
@@ -96,6 +101,10 @@ __all__ = [
|
|
|
96
101
|
"get_log_queue_stats",
|
|
97
102
|
"get_sink_circuit_breaker_stats",
|
|
98
103
|
"setup_logging",
|
|
104
|
+
# Loguru bridge
|
|
105
|
+
"build_loguru_to_stdlib_sink",
|
|
106
|
+
"install_loguru_bridge",
|
|
107
|
+
"remove_loguru_bridge",
|
|
99
108
|
# Sinks
|
|
100
109
|
"LogSink",
|
|
101
110
|
"LogtailSink",
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.2.4"
|
|
@@ -28,6 +28,7 @@ _LOGGING_LOCK = threading.Lock()
|
|
|
28
28
|
_QUEUE_LISTENER: logging.handlers.QueueListener | None = None
|
|
29
29
|
_MANAGED_OUTPUT_HANDLERS: list[logging.Handler] = []
|
|
30
30
|
_SINK_CIRCUIT_BREAKERS: list["SinkCircuitBreakerHandler"] = []
|
|
31
|
+
_LOGGER = logging.getLogger("fastapiobserver.logging")
|
|
31
32
|
LOG_SCHEMA_VERSION = "1.0.0"
|
|
32
33
|
|
|
33
34
|
CircuitBreakerState = Literal["closed", "open", "half_open"]
|
|
@@ -431,7 +432,10 @@ class StructuredJsonFormatter(logging.Formatter):
|
|
|
431
432
|
payload["event"] = event
|
|
432
433
|
|
|
433
434
|
if record.exc_info:
|
|
434
|
-
|
|
435
|
+
error_payload = _build_structured_error(record, self)
|
|
436
|
+
payload["error"] = error_payload
|
|
437
|
+
# Backward-compatible field retained for existing dashboards.
|
|
438
|
+
payload["exc_info"] = error_payload["stacktrace"]
|
|
435
439
|
|
|
436
440
|
if getattr(record, "_skip_enrichers", False):
|
|
437
441
|
enriched_payload = payload
|
|
@@ -479,7 +483,11 @@ class TraceContextFilter(logging.Filter):
|
|
|
479
483
|
trace_id = f"{span_context.trace_id:032x}"
|
|
480
484
|
span_id = f"{span_context.span_id:016x}"
|
|
481
485
|
except Exception:
|
|
482
|
-
|
|
486
|
+
_LOGGER.debug(
|
|
487
|
+
"trace_context_filter.otel_lookup_failed",
|
|
488
|
+
exc_info=True,
|
|
489
|
+
extra={"_skip_enrichers": True},
|
|
490
|
+
)
|
|
483
491
|
|
|
484
492
|
if trace_id:
|
|
485
493
|
record.trace_id = trace_id # type: ignore[attr-defined]
|
|
@@ -644,6 +652,32 @@ def _json_dumps(payload: dict[str, Any]) -> str:
|
|
|
644
652
|
return json.dumps(payload, ensure_ascii=True, default=str)
|
|
645
653
|
|
|
646
654
|
|
|
655
|
+
def _build_structured_error(
|
|
656
|
+
record: logging.LogRecord,
|
|
657
|
+
formatter: logging.Formatter,
|
|
658
|
+
) -> dict[str, str]:
|
|
659
|
+
exc_info = record.exc_info
|
|
660
|
+
if not exc_info:
|
|
661
|
+
return {"type": "", "message": "", "stacktrace": ""}
|
|
662
|
+
|
|
663
|
+
if not isinstance(exc_info, tuple) or len(exc_info) != 3:
|
|
664
|
+
return {
|
|
665
|
+
"type": "Exception",
|
|
666
|
+
"message": "",
|
|
667
|
+
"stacktrace": "",
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
exc_type, exc_value, _exc_tb = exc_info
|
|
671
|
+
error_type = exc_type.__name__ if exc_type is not None else "Exception"
|
|
672
|
+
error_message = str(exc_value) if exc_value is not None else ""
|
|
673
|
+
stacktrace = formatter.formatException(exc_info)
|
|
674
|
+
return {
|
|
675
|
+
"type": error_type,
|
|
676
|
+
"message": error_message,
|
|
677
|
+
"stacktrace": stacktrace,
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
|
|
647
681
|
def _wrap_sink_handlers(
|
|
648
682
|
handlers: list[logging.Handler],
|
|
649
683
|
*,
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from types import TracebackType
|
|
5
|
+
from typing import Any, Callable
|
|
6
|
+
|
|
7
|
+
from .utils import lazy_import
|
|
8
|
+
|
|
9
|
+
_LOGGER = logging.getLogger("fastapiobserver.loguru")
|
|
10
|
+
_BRIDGE_MARKER = "_fastapiobserver_loguru_bridge"
|
|
11
|
+
_STANDARD_LOG_RECORD_ATTRS = frozenset(logging.makeLogRecord({}).__dict__)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def build_loguru_to_stdlib_sink(
|
|
15
|
+
*,
|
|
16
|
+
stdlib_logger_name: str | None = None,
|
|
17
|
+
include_extra: bool = True,
|
|
18
|
+
) -> Callable[[Any], None]:
|
|
19
|
+
"""Build a Loguru sink that forwards records into stdlib logging.
|
|
20
|
+
|
|
21
|
+
This lets existing Loguru users keep ``logger.bind(...).info(...)`` calls
|
|
22
|
+
while routing output through fastapi-observer's queue/sanitization pipeline.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
def _sink(message: Any) -> None:
|
|
26
|
+
record = getattr(message, "record", None)
|
|
27
|
+
if not isinstance(record, dict):
|
|
28
|
+
return
|
|
29
|
+
|
|
30
|
+
raw_extra = record.get("extra")
|
|
31
|
+
extra: dict[str, Any]
|
|
32
|
+
if isinstance(raw_extra, dict):
|
|
33
|
+
extra = dict(raw_extra)
|
|
34
|
+
else:
|
|
35
|
+
extra = {}
|
|
36
|
+
|
|
37
|
+
# Guard against accidental recursive bridges in mixed logging setups.
|
|
38
|
+
if bool(extra.get(_BRIDGE_MARKER)):
|
|
39
|
+
return
|
|
40
|
+
|
|
41
|
+
record_logger_name = str(record.get("name") or "")
|
|
42
|
+
logger_name = _resolve_logger_name(stdlib_logger_name, record_logger_name)
|
|
43
|
+
stdlib_logger = logging.getLogger(logger_name)
|
|
44
|
+
stdlib_extra = _build_stdlib_extra(extra, include_extra=include_extra)
|
|
45
|
+
exc_info = _extract_exc_info(record.get("exception"))
|
|
46
|
+
level = _extract_level_number(record.get("level"))
|
|
47
|
+
message_text = str(record.get("message") or "")
|
|
48
|
+
|
|
49
|
+
stdlib_logger.log(
|
|
50
|
+
level,
|
|
51
|
+
message_text,
|
|
52
|
+
exc_info=exc_info,
|
|
53
|
+
extra=stdlib_extra,
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
return _sink
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def install_loguru_bridge(
|
|
60
|
+
*,
|
|
61
|
+
stdlib_logger_name: str | None = None,
|
|
62
|
+
level: str | int = 0,
|
|
63
|
+
include_extra: bool = True,
|
|
64
|
+
enqueue: bool = False,
|
|
65
|
+
catch: bool = True,
|
|
66
|
+
) -> int:
|
|
67
|
+
"""Install a Loguru -> stdlib bridge and return the Loguru handler id."""
|
|
68
|
+
loguru_logger = lazy_import(
|
|
69
|
+
"loguru",
|
|
70
|
+
"logger",
|
|
71
|
+
package_hint="fastapi-observer[loguru]",
|
|
72
|
+
)
|
|
73
|
+
sink = build_loguru_to_stdlib_sink(
|
|
74
|
+
stdlib_logger_name=stdlib_logger_name,
|
|
75
|
+
include_extra=include_extra,
|
|
76
|
+
)
|
|
77
|
+
handler_id = int(
|
|
78
|
+
loguru_logger.add(
|
|
79
|
+
sink,
|
|
80
|
+
level=level,
|
|
81
|
+
enqueue=enqueue,
|
|
82
|
+
catch=catch,
|
|
83
|
+
)
|
|
84
|
+
)
|
|
85
|
+
_LOGGER.info(
|
|
86
|
+
"loguru.bridge.installed",
|
|
87
|
+
extra={
|
|
88
|
+
"event": {
|
|
89
|
+
"handler_id": handler_id,
|
|
90
|
+
"target_logger": stdlib_logger_name or "<record.name>",
|
|
91
|
+
"include_extra": include_extra,
|
|
92
|
+
"enqueue": enqueue,
|
|
93
|
+
},
|
|
94
|
+
"_skip_enrichers": True,
|
|
95
|
+
},
|
|
96
|
+
)
|
|
97
|
+
return handler_id
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def remove_loguru_bridge(handler_id: int) -> None:
|
|
101
|
+
"""Remove a previously installed Loguru bridge handler."""
|
|
102
|
+
loguru_logger = lazy_import(
|
|
103
|
+
"loguru",
|
|
104
|
+
"logger",
|
|
105
|
+
package_hint="fastapi-observer[loguru]",
|
|
106
|
+
)
|
|
107
|
+
loguru_logger.remove(int(handler_id))
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _resolve_logger_name(configured_name: str | None, record_name: str) -> str:
|
|
111
|
+
if configured_name is not None:
|
|
112
|
+
candidate = configured_name.strip()
|
|
113
|
+
if candidate:
|
|
114
|
+
return candidate
|
|
115
|
+
candidate = record_name.strip()
|
|
116
|
+
if candidate:
|
|
117
|
+
return candidate
|
|
118
|
+
return "loguru"
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _build_stdlib_extra(
|
|
122
|
+
extra: dict[str, Any],
|
|
123
|
+
*,
|
|
124
|
+
include_extra: bool,
|
|
125
|
+
) -> dict[str, Any]:
|
|
126
|
+
forwarded_extra: dict[str, Any] = {_BRIDGE_MARKER: True}
|
|
127
|
+
if not include_extra:
|
|
128
|
+
return forwarded_extra
|
|
129
|
+
|
|
130
|
+
for key, value in extra.items():
|
|
131
|
+
normalized_key = str(key)
|
|
132
|
+
if normalized_key == _BRIDGE_MARKER:
|
|
133
|
+
continue
|
|
134
|
+
if normalized_key in _STANDARD_LOG_RECORD_ATTRS:
|
|
135
|
+
continue
|
|
136
|
+
forwarded_extra[normalized_key] = value
|
|
137
|
+
return forwarded_extra
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _extract_level_number(level: Any) -> int:
|
|
141
|
+
level_number = getattr(level, "no", None)
|
|
142
|
+
if isinstance(level_number, int):
|
|
143
|
+
return level_number
|
|
144
|
+
return logging.INFO
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _extract_exc_info(
|
|
148
|
+
exception: Any,
|
|
149
|
+
) -> tuple[type[BaseException], BaseException, TracebackType | None] | None:
|
|
150
|
+
if exception is None:
|
|
151
|
+
return None
|
|
152
|
+
|
|
153
|
+
exc_type = getattr(exception, "type", None)
|
|
154
|
+
exc_value = getattr(exception, "value", None)
|
|
155
|
+
exc_traceback = getattr(exception, "traceback", None)
|
|
156
|
+
if not isinstance(exc_type, type):
|
|
157
|
+
return None
|
|
158
|
+
if not issubclass(exc_type, BaseException):
|
|
159
|
+
return None
|
|
160
|
+
if not isinstance(exc_value, BaseException):
|
|
161
|
+
return None
|
|
162
|
+
if exc_traceback is not None and not isinstance(exc_traceback, TracebackType):
|
|
163
|
+
return None
|
|
164
|
+
return exc_type, exc_value, exc_traceback
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
__all__ = [
|
|
168
|
+
"build_loguru_to_stdlib_sink",
|
|
169
|
+
"install_loguru_bridge",
|
|
170
|
+
"remove_loguru_bridge",
|
|
171
|
+
]
|
|
@@ -142,11 +142,7 @@ class RequestLoggingMiddleware:
|
|
|
142
142
|
request_body=request_body_capture.value,
|
|
143
143
|
response_body=response_body_capture.value,
|
|
144
144
|
error_type=error_type,
|
|
145
|
-
|
|
146
|
-
captured_error.__class__.__name__
|
|
147
|
-
if captured_error is not None
|
|
148
|
-
else None
|
|
149
|
-
),
|
|
145
|
+
exception=captured_error,
|
|
150
146
|
)
|
|
151
147
|
if had_error:
|
|
152
148
|
self.logger.exception("request.failed", extra={"event": safe_event})
|
|
@@ -262,7 +258,7 @@ class _RequestEventBuilder:
|
|
|
262
258
|
request_body: str | None,
|
|
263
259
|
response_body: str | None,
|
|
264
260
|
error_type: str,
|
|
265
|
-
|
|
261
|
+
exception: Exception | None,
|
|
266
262
|
) -> dict[str, Any]:
|
|
267
263
|
event: dict[str, Any] = {
|
|
268
264
|
"method": method,
|
|
@@ -271,13 +267,18 @@ class _RequestEventBuilder:
|
|
|
271
267
|
"duration_ms": round(duration_seconds * 1000, 3),
|
|
272
268
|
"client_ip": client_ip,
|
|
273
269
|
"error_type": error_type,
|
|
270
|
+
# OpenTelemetry semantic-convention aliases for interoperability.
|
|
271
|
+
"http.request.method": method,
|
|
272
|
+
"url.path": path,
|
|
273
|
+
"http.response.status_code": status_code,
|
|
274
274
|
}
|
|
275
275
|
if request_body is not None:
|
|
276
276
|
event["request_body"] = request_body
|
|
277
277
|
if response_body is not None:
|
|
278
278
|
event["response_body"] = response_body
|
|
279
|
-
if
|
|
280
|
-
event["exception_class"] =
|
|
279
|
+
if exception is not None:
|
|
280
|
+
event["exception_class"] = exception.__class__.__name__
|
|
281
|
+
event["exception_message"] = str(exception)
|
|
281
282
|
return sanitize_event(event, self.policy)
|
|
282
283
|
|
|
283
284
|
|
|
@@ -4,17 +4,27 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
import logging
|
|
6
6
|
import os
|
|
7
|
+
from types import ModuleType
|
|
7
8
|
from typing import Any, Literal
|
|
8
9
|
from urllib.parse import urlparse, urlunparse
|
|
9
10
|
|
|
10
11
|
from ..config import ObservabilitySettings
|
|
11
12
|
from ..utils import lazy_import
|
|
12
13
|
from .settings import OTelLogsSettings, OTelMetricsSettings, OTelSettings
|
|
14
|
+
from .types import (
|
|
15
|
+
LogExporterLike,
|
|
16
|
+
MetricExporterLike,
|
|
17
|
+
OTelResourceLike,
|
|
18
|
+
SpanExporterLike,
|
|
19
|
+
)
|
|
13
20
|
|
|
14
21
|
_LOGGER = logging.getLogger("fastapiobserver.otel")
|
|
15
22
|
|
|
16
23
|
|
|
17
|
-
def create_otel_resource(
|
|
24
|
+
def create_otel_resource(
|
|
25
|
+
settings: ObservabilitySettings,
|
|
26
|
+
otel_settings: OTelSettings,
|
|
27
|
+
) -> OTelResourceLike:
|
|
18
28
|
resources_module = import_otel_module("opentelemetry.sdk.resources")
|
|
19
29
|
resource_attrs = {
|
|
20
30
|
"service.name": otel_settings.service_name or settings.service,
|
|
@@ -66,7 +76,7 @@ def build_excluded_urls_csv(settings: ObservabilitySettings) -> str | None:
|
|
|
66
76
|
# ---------------------------------------------------------------------------
|
|
67
77
|
|
|
68
78
|
|
|
69
|
-
def build_span_exporter(otel_settings: OTelSettings) ->
|
|
79
|
+
def build_span_exporter(otel_settings: OTelSettings) -> SpanExporterLike:
|
|
70
80
|
endpoint = normalize_otlp_endpoint(
|
|
71
81
|
otel_settings.otlp_endpoint,
|
|
72
82
|
otel_settings.protocol,
|
|
@@ -91,7 +101,7 @@ def build_span_exporter(otel_settings: OTelSettings) -> Any:
|
|
|
91
101
|
# ---------------------------------------------------------------------------
|
|
92
102
|
|
|
93
103
|
|
|
94
|
-
def build_log_exporter(otel_logs_settings: OTelLogsSettings) ->
|
|
104
|
+
def build_log_exporter(otel_logs_settings: OTelLogsSettings) -> LogExporterLike:
|
|
95
105
|
"""Build the OTLP log exporter based on protocol."""
|
|
96
106
|
endpoint = otel_logs_settings.otlp_endpoint
|
|
97
107
|
if otel_logs_settings.protocol == "http/protobuf":
|
|
@@ -112,7 +122,9 @@ def build_log_exporter(otel_logs_settings: OTelLogsSettings) -> Any:
|
|
|
112
122
|
# ---------------------------------------------------------------------------
|
|
113
123
|
|
|
114
124
|
|
|
115
|
-
def build_metric_exporter(
|
|
125
|
+
def build_metric_exporter(
|
|
126
|
+
otel_metrics_settings: OTelMetricsSettings,
|
|
127
|
+
) -> MetricExporterLike:
|
|
116
128
|
endpoint = normalize_otlp_metrics_endpoint(
|
|
117
129
|
otel_metrics_settings.otlp_endpoint,
|
|
118
130
|
otel_metrics_settings.protocol,
|
|
@@ -136,7 +148,7 @@ def build_metric_exporter(otel_metrics_settings: OTelMetricsSettings) -> Any:
|
|
|
136
148
|
# ---------------------------------------------------------------------------
|
|
137
149
|
|
|
138
150
|
|
|
139
|
-
def import_otel_module(name: str) ->
|
|
151
|
+
def import_otel_module(name: str) -> ModuleType:
|
|
140
152
|
try:
|
|
141
153
|
return lazy_import(name, package_hint="fastapi-observer[otel]")
|
|
142
154
|
except RuntimeError as exc:
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Typing protocols for optional OpenTelemetry integrations.
|
|
2
|
+
|
|
3
|
+
These protocols intentionally cover only the methods this package relies on,
|
|
4
|
+
so we can provide useful typing without hard-importing OTel runtime classes.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from types import ModuleType
|
|
10
|
+
from typing import Any, Protocol, Sequence
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class OTelResourceLike(Protocol):
|
|
14
|
+
"""Minimal resource shape accepted by OTel SDK providers."""
|
|
15
|
+
|
|
16
|
+
attributes: Any
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class SpanExporterLike(Protocol):
|
|
20
|
+
"""Minimal span exporter interface used by OTel processors."""
|
|
21
|
+
|
|
22
|
+
def export(self, spans: Sequence[Any]) -> Any: ...
|
|
23
|
+
|
|
24
|
+
def shutdown(self) -> None: ...
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class LogExporterLike(Protocol):
|
|
28
|
+
"""Minimal log exporter interface used by OTel log processors."""
|
|
29
|
+
|
|
30
|
+
def export(self, batch: Sequence[Any]) -> Any: ...
|
|
31
|
+
|
|
32
|
+
def shutdown(self) -> None: ...
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class MetricExporterLike(Protocol):
|
|
36
|
+
"""Minimal metric exporter interface used by OTel metric readers."""
|
|
37
|
+
|
|
38
|
+
def export(self, metrics_data: Any, timeout_millis: float = ...) -> Any: ...
|
|
39
|
+
|
|
40
|
+
def shutdown(self, timeout_millis: float = ...) -> None: ...
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
__all__ = [
|
|
44
|
+
"ModuleType",
|
|
45
|
+
"OTelResourceLike",
|
|
46
|
+
"SpanExporterLike",
|
|
47
|
+
"LogExporterLike",
|
|
48
|
+
"MetricExporterLike",
|
|
49
|
+
]
|
|
@@ -109,7 +109,11 @@ def discover_entry_point_sinks() -> None:
|
|
|
109
109
|
},
|
|
110
110
|
)
|
|
111
111
|
except Exception:
|
|
112
|
-
|
|
112
|
+
_LOGGER.debug(
|
|
113
|
+
"sinks.entry_point.discover_failed",
|
|
114
|
+
exc_info=True,
|
|
115
|
+
extra={"_skip_enrichers": True},
|
|
116
|
+
)
|
|
113
117
|
|
|
114
118
|
|
|
115
119
|
# =====================================================================
|
|
@@ -285,7 +289,14 @@ class _LogtailHandler(logging.Handler):
|
|
|
285
289
|
self._error_count += 1
|
|
286
290
|
return # Don't retry client errors
|
|
287
291
|
except Exception:
|
|
288
|
-
|
|
292
|
+
_LOGGER.debug(
|
|
293
|
+
"logtail.send.retryable_error",
|
|
294
|
+
exc_info=True,
|
|
295
|
+
extra={
|
|
296
|
+
"event": {"attempt": attempt + 1, "batch_size": len(batch)},
|
|
297
|
+
"_skip_enrichers": True,
|
|
298
|
+
},
|
|
299
|
+
)
|
|
289
300
|
|
|
290
301
|
# Exponential backoff
|
|
291
302
|
if attempt < self._max_retries - 1:
|
|
@@ -184,3 +184,34 @@ def test_setup_logging_applies_registered_log_filters() -> None:
|
|
|
184
184
|
|
|
185
185
|
assert any(p.get("message") == "business event" for p in filtered)
|
|
186
186
|
assert not any(p.get("message") == "health probe" for p in filtered)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def test_structured_formatter_emits_structured_error_payload() -> None:
|
|
190
|
+
settings = ObservabilitySettings(
|
|
191
|
+
app_name="orders-api",
|
|
192
|
+
service="orders",
|
|
193
|
+
environment="test",
|
|
194
|
+
version="1.2.3",
|
|
195
|
+
)
|
|
196
|
+
formatter = StructuredJsonFormatter(settings, security_policy=SecurityPolicy())
|
|
197
|
+
|
|
198
|
+
try:
|
|
199
|
+
raise RuntimeError("boom")
|
|
200
|
+
except RuntimeError as error:
|
|
201
|
+
exc_info = (error.__class__, error, error.__traceback__)
|
|
202
|
+
record = logging.LogRecord(
|
|
203
|
+
name="tests.logger",
|
|
204
|
+
level=logging.ERROR,
|
|
205
|
+
pathname=__file__,
|
|
206
|
+
lineno=200,
|
|
207
|
+
msg="request.failed",
|
|
208
|
+
args=(),
|
|
209
|
+
exc_info=exc_info,
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
payload = json.loads(formatter.format(record))
|
|
213
|
+
|
|
214
|
+
assert payload["error"]["type"] == "RuntimeError"
|
|
215
|
+
assert payload["error"]["message"] == "boom"
|
|
216
|
+
assert "RuntimeError: boom" in payload["error"]["stacktrace"]
|
|
217
|
+
assert payload["exc_info"] == payload["error"]["stacktrace"]
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
from fastapiobserver.loguru import install_loguru_bridge, remove_loguru_bridge
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class _CaptureHandler(logging.Handler):
|
|
11
|
+
def __init__(self) -> None:
|
|
12
|
+
super().__init__()
|
|
13
|
+
self.records: list[logging.LogRecord] = []
|
|
14
|
+
|
|
15
|
+
def emit(self, record: logging.LogRecord) -> None:
|
|
16
|
+
self.records.append(record)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _prepare_logger(name: str) -> tuple[logging.Logger, _CaptureHandler]:
|
|
20
|
+
logger = logging.getLogger(name)
|
|
21
|
+
for handler in list(logger.handlers):
|
|
22
|
+
logger.removeHandler(handler)
|
|
23
|
+
logger.propagate = False
|
|
24
|
+
logger.setLevel(logging.DEBUG)
|
|
25
|
+
capture = _CaptureHandler()
|
|
26
|
+
logger.addHandler(capture)
|
|
27
|
+
return logger, capture
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def test_loguru_bridge_forwards_message_and_extra_fields() -> None:
|
|
31
|
+
loguru = pytest.importorskip("loguru")
|
|
32
|
+
logger_name = "tests.loguru.bridge"
|
|
33
|
+
logger, capture = _prepare_logger(logger_name)
|
|
34
|
+
bridge_id = install_loguru_bridge(
|
|
35
|
+
stdlib_logger_name=logger_name,
|
|
36
|
+
include_extra=True,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
try:
|
|
40
|
+
loguru.logger.bind(
|
|
41
|
+
request_id="req-123",
|
|
42
|
+
event={"kind": "bridge"},
|
|
43
|
+
).info("hello from loguru")
|
|
44
|
+
finally:
|
|
45
|
+
remove_loguru_bridge(bridge_id)
|
|
46
|
+
logger.removeHandler(capture)
|
|
47
|
+
|
|
48
|
+
forwarded = [record for record in capture.records if record.getMessage() == "hello from loguru"]
|
|
49
|
+
assert forwarded
|
|
50
|
+
record = forwarded[-1]
|
|
51
|
+
assert getattr(record, "request_id", None) == "req-123"
|
|
52
|
+
assert getattr(record, "event", None) == {"kind": "bridge"}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def test_loguru_bridge_forwards_exception_info() -> None:
|
|
56
|
+
loguru = pytest.importorskip("loguru")
|
|
57
|
+
logger_name = "tests.loguru.exception"
|
|
58
|
+
logger, capture = _prepare_logger(logger_name)
|
|
59
|
+
bridge_id = install_loguru_bridge(stdlib_logger_name=logger_name)
|
|
60
|
+
|
|
61
|
+
try:
|
|
62
|
+
try:
|
|
63
|
+
raise RuntimeError("boom")
|
|
64
|
+
except RuntimeError:
|
|
65
|
+
loguru.logger.exception("loguru failed")
|
|
66
|
+
finally:
|
|
67
|
+
remove_loguru_bridge(bridge_id)
|
|
68
|
+
logger.removeHandler(capture)
|
|
69
|
+
|
|
70
|
+
forwarded = [record for record in capture.records if record.getMessage() == "loguru failed"]
|
|
71
|
+
assert forwarded
|
|
72
|
+
record = forwarded[-1]
|
|
73
|
+
assert record.exc_info is not None
|
|
74
|
+
assert record.exc_info[0] is RuntimeError
|
|
@@ -56,6 +56,9 @@ def test_middleware_logs_path_without_query_string(caplog) -> None:
|
|
|
56
56
|
]
|
|
57
57
|
assert request_logs
|
|
58
58
|
assert request_logs[-1].event["path"] == "/search"
|
|
59
|
+
assert request_logs[-1].event["url.path"] == "/search"
|
|
60
|
+
assert request_logs[-1].event["http.request.method"] == "GET"
|
|
61
|
+
assert request_logs[-1].event["http.response.status_code"] == 200
|
|
59
62
|
|
|
60
63
|
|
|
61
64
|
def test_middleware_classifies_server_error_response(caplog) -> None:
|
|
@@ -82,6 +85,9 @@ def test_middleware_classifies_server_error_response(caplog) -> None:
|
|
|
82
85
|
]
|
|
83
86
|
assert request_logs
|
|
84
87
|
assert request_logs[-1].event["error_type"] == "server_error"
|
|
88
|
+
assert request_logs[-1].event["http.request.method"] == "GET"
|
|
89
|
+
assert request_logs[-1].event["url.path"] == "/down"
|
|
90
|
+
assert request_logs[-1].event["http.response.status_code"] == 503
|
|
85
91
|
|
|
86
92
|
|
|
87
93
|
def test_middleware_classifies_unhandled_exception(caplog) -> None:
|
|
@@ -110,6 +116,10 @@ def test_middleware_classifies_unhandled_exception(caplog) -> None:
|
|
|
110
116
|
assert request_logs
|
|
111
117
|
assert request_logs[-1].event["error_type"] == "unhandled_exception"
|
|
112
118
|
assert request_logs[-1].event["exception_class"] == "RuntimeError"
|
|
119
|
+
assert request_logs[-1].event["exception_message"] == "boom"
|
|
120
|
+
assert request_logs[-1].event["http.request.method"] == "GET"
|
|
121
|
+
assert request_logs[-1].event["url.path"] == "/boom"
|
|
122
|
+
assert request_logs[-1].event["http.response.status_code"] == 500
|
|
113
123
|
|
|
114
124
|
|
|
115
125
|
def test_middleware_records_exception_on_active_span(
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = "0.2.2"
|
|
File without changes
|
|
File without changes
|
{fastapi_observer-0.2.2 → fastapi_observer-0.2.4}/src/fastapi_observer.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
{fastapi_observer-0.2.2 → fastapi_observer-0.2.4}/src/fastapi_observer.egg-info/top_level.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|