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.
Files changed (61) hide show
  1. {fastapi_observer-0.2.2/src/fastapi_observer.egg-info → fastapi_observer-0.2.4}/PKG-INFO +63 -2
  2. {fastapi_observer-0.2.2 → fastapi_observer-0.2.4}/README.md +58 -1
  3. {fastapi_observer-0.2.2 → fastapi_observer-0.2.4}/pyproject.toml +6 -1
  4. {fastapi_observer-0.2.2 → fastapi_observer-0.2.4/src/fastapi_observer.egg-info}/PKG-INFO +63 -2
  5. {fastapi_observer-0.2.2 → fastapi_observer-0.2.4}/src/fastapi_observer.egg-info/SOURCES.txt +3 -0
  6. {fastapi_observer-0.2.2 → fastapi_observer-0.2.4}/src/fastapi_observer.egg-info/requires.txt +5 -0
  7. {fastapi_observer-0.2.2 → fastapi_observer-0.2.4}/src/fastapiobserver/__init__.py +9 -0
  8. fastapi_observer-0.2.4/src/fastapiobserver/_version.py +1 -0
  9. {fastapi_observer-0.2.2 → fastapi_observer-0.2.4}/src/fastapiobserver/logging.py +36 -2
  10. fastapi_observer-0.2.4/src/fastapiobserver/loguru.py +171 -0
  11. {fastapi_observer-0.2.2 → fastapi_observer-0.2.4}/src/fastapiobserver/middleware.py +9 -8
  12. {fastapi_observer-0.2.2 → fastapi_observer-0.2.4}/src/fastapiobserver/otel/resource.py +17 -5
  13. fastapi_observer-0.2.4/src/fastapiobserver/otel/types.py +49 -0
  14. {fastapi_observer-0.2.2 → fastapi_observer-0.2.4}/src/fastapiobserver/sinks.py +13 -2
  15. {fastapi_observer-0.2.2 → fastapi_observer-0.2.4}/tests/test_logging_schema_contract.py +31 -0
  16. fastapi_observer-0.2.4/tests/test_loguru_bridge.py +74 -0
  17. {fastapi_observer-0.2.2 → fastapi_observer-0.2.4}/tests/test_middleware.py +10 -0
  18. fastapi_observer-0.2.2/src/fastapiobserver/_version.py +0 -1
  19. {fastapi_observer-0.2.2 → fastapi_observer-0.2.4}/LICENSE +0 -0
  20. {fastapi_observer-0.2.2 → fastapi_observer-0.2.4}/setup.cfg +0 -0
  21. {fastapi_observer-0.2.2 → fastapi_observer-0.2.4}/src/fastapi_observer.egg-info/dependency_links.txt +0 -0
  22. {fastapi_observer-0.2.2 → fastapi_observer-0.2.4}/src/fastapi_observer.egg-info/top_level.txt +0 -0
  23. {fastapi_observer-0.2.2 → fastapi_observer-0.2.4}/src/fastapiobserver/config.py +0 -0
  24. {fastapi_observer-0.2.2 → fastapi_observer-0.2.4}/src/fastapiobserver/control_plane.py +0 -0
  25. {fastapi_observer-0.2.2 → fastapi_observer-0.2.4}/src/fastapiobserver/fastapi.py +0 -0
  26. {fastapi_observer-0.2.2 → fastapi_observer-0.2.4}/src/fastapiobserver/metrics.py +0 -0
  27. {fastapi_observer-0.2.2 → fastapi_observer-0.2.4}/src/fastapiobserver/otel/__init__.py +0 -0
  28. {fastapi_observer-0.2.2 → fastapi_observer-0.2.4}/src/fastapiobserver/otel/lifecycle.py +0 -0
  29. {fastapi_observer-0.2.2 → fastapi_observer-0.2.4}/src/fastapiobserver/otel/logs.py +0 -0
  30. {fastapi_observer-0.2.2 → fastapi_observer-0.2.4}/src/fastapiobserver/otel/metrics.py +0 -0
  31. {fastapi_observer-0.2.2 → fastapi_observer-0.2.4}/src/fastapiobserver/otel/settings.py +0 -0
  32. {fastapi_observer-0.2.2 → fastapi_observer-0.2.4}/src/fastapiobserver/otel/tracing.py +0 -0
  33. {fastapi_observer-0.2.2 → fastapi_observer-0.2.4}/src/fastapiobserver/plugins.py +0 -0
  34. {fastapi_observer-0.2.2 → fastapi_observer-0.2.4}/src/fastapiobserver/propagation.py +0 -0
  35. {fastapi_observer-0.2.2 → fastapi_observer-0.2.4}/src/fastapiobserver/py.typed +0 -0
  36. {fastapi_observer-0.2.2 → fastapi_observer-0.2.4}/src/fastapiobserver/request_context.py +0 -0
  37. {fastapi_observer-0.2.2 → fastapi_observer-0.2.4}/src/fastapiobserver/security.py +0 -0
  38. {fastapi_observer-0.2.2 → fastapi_observer-0.2.4}/src/fastapiobserver/utils.py +0 -0
  39. {fastapi_observer-0.2.2 → fastapi_observer-0.2.4}/tests/test_body_media_allowlist.py +0 -0
  40. {fastapi_observer-0.2.2 → fastapi_observer-0.2.4}/tests/test_config_settings.py +0 -0
  41. {fastapi_observer-0.2.2 → fastapi_observer-0.2.4}/tests/test_excluded_url_precedence.py +0 -0
  42. {fastapi_observer-0.2.2 → fastapi_observer-0.2.4}/tests/test_fastapi_install.py +0 -0
  43. {fastapi_observer-0.2.2 → fastapi_observer-0.2.4}/tests/test_log_queue_controls.py +0 -0
  44. {fastapi_observer-0.2.2 → fastapi_observer-0.2.4}/tests/test_metrics_optional_dependency.py +0 -0
  45. {fastapi_observer-0.2.2 → fastapi_observer-0.2.4}/tests/test_negotiate_content_type.py +0 -0
  46. {fastapi_observer-0.2.2 → fastapi_observer-0.2.4}/tests/test_otel_integration.py +0 -0
  47. {fastapi_observer-0.2.2 → fastapi_observer-0.2.4}/tests/test_otel_lifecycle.py +0 -0
  48. {fastapi_observer-0.2.2 → fastapi_observer-0.2.4}/tests/test_otel_log_correlation.py +0 -0
  49. {fastapi_observer-0.2.2 → fastapi_observer-0.2.4}/tests/test_otel_logs_from_env.py +0 -0
  50. {fastapi_observer-0.2.2 → fastapi_observer-0.2.4}/tests/test_otel_logs_pipeline.py +0 -0
  51. {fastapi_observer-0.2.2 → fastapi_observer-0.2.4}/tests/test_otel_metrics_from_env.py +0 -0
  52. {fastapi_observer-0.2.2 → fastapi_observer-0.2.4}/tests/test_otlp_export_integration.py +0 -0
  53. {fastapi_observer-0.2.2 → fastapi_observer-0.2.4}/tests/test_plugin_isolation.py +0 -0
  54. {fastapi_observer-0.2.2 → fastapi_observer-0.2.4}/tests/test_propagation.py +0 -0
  55. {fastapi_observer-0.2.2 → fastapi_observer-0.2.4}/tests/test_redaction_presets.py +0 -0
  56. {fastapi_observer-0.2.2 → fastapi_observer-0.2.4}/tests/test_route_template_cardinality.py +0 -0
  57. {fastapi_observer-0.2.2 → fastapi_observer-0.2.4}/tests/test_runtime_control_auth.py +0 -0
  58. {fastapi_observer-0.2.2 → fastapi_observer-0.2.4}/tests/test_security_redaction.py +0 -0
  59. {fastapi_observer-0.2.2 → fastapi_observer-0.2.4}/tests/test_sink_circuit_breaker.py +0 -0
  60. {fastapi_observer-0.2.2 → fastapi_observer-0.2.4}/tests/test_trust_boundary.py +0 -0
  61. {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.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
+ [![CI](https://github.com/Vitaee/FastapiObserver/actions/workflows/ci.yml/badge.svg)](https://github.com/Vitaee/FastapiObserver/actions/workflows/ci.yml)
67
72
  [![Sponsor](https://img.shields.io/badge/Sponsor-Buy%20me%20a%20coffee-FFDD00?logo=buymeacoffee&logoColor=000000)](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.1`
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
+ [![CI](https://github.com/Vitaee/FastapiObserver/actions/workflows/ci.yml/badge.svg)](https://github.com/Vitaee/FastapiObserver/actions/workflows/ci.yml)
3
4
  [![Sponsor](https://img.shields.io/badge/Sponsor-Buy%20me%20a%20coffee-FFDD00?logo=buymeacoffee&logoColor=000000)](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.1`
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.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.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
+ [![CI](https://github.com/Vitaee/FastapiObserver/actions/workflows/ci.yml/badge.svg)](https://github.com/Vitaee/FastapiObserver/actions/workflows/ci.yml)
67
72
  [![Sponsor](https://img.shields.io/badge/Sponsor-Buy%20me%20a%20coffee-FFDD00?logo=buymeacoffee&logoColor=000000)](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.1`
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
@@ -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
- payload["exc_info"] = self.formatException(record.exc_info)
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
- pass
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
- exception_class=(
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
- exception_class: str | None,
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 exception_class:
280
- event["exception_class"] = 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(settings: ObservabilitySettings, otel_settings: OTelSettings) -> Any:
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) -> Any:
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) -> Any:
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(otel_metrics_settings: OTelMetricsSettings) -> Any:
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) -> Any:
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
- pass
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
- pass
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"