fastapi-observer 0.2.4__tar.gz → 0.3.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. {fastapi_observer-0.2.4/src/fastapi_observer.egg-info → fastapi_observer-0.3.0}/PKG-INFO +68 -4
  2. {fastapi_observer-0.2.4 → fastapi_observer-0.3.0}/README.md +66 -3
  3. {fastapi_observer-0.2.4 → fastapi_observer-0.3.0}/pyproject.toml +14 -1
  4. {fastapi_observer-0.2.4 → fastapi_observer-0.3.0/src/fastapi_observer.egg-info}/PKG-INFO +68 -4
  5. {fastapi_observer-0.2.4 → fastapi_observer-0.3.0}/src/fastapi_observer.egg-info/SOURCES.txt +5 -0
  6. {fastapi_observer-0.2.4 → fastapi_observer-0.3.0}/src/fastapiobserver/__init__.py +2 -0
  7. fastapi_observer-0.3.0/src/fastapiobserver/_version.py +1 -0
  8. {fastapi_observer-0.2.4 → fastapi_observer-0.3.0}/src/fastapiobserver/config.py +22 -0
  9. {fastapi_observer-0.2.4 → fastapi_observer-0.3.0}/src/fastapiobserver/control_plane.py +9 -4
  10. {fastapi_observer-0.2.4 → fastapi_observer-0.3.0}/src/fastapiobserver/fastapi.py +25 -1
  11. fastapi_observer-0.3.0/src/fastapiobserver/integrations/__init__.py +0 -0
  12. fastapi_observer-0.3.0/src/fastapiobserver/integrations/strawberry.py +78 -0
  13. {fastapi_observer-0.2.4 → fastapi_observer-0.3.0}/src/fastapiobserver/logging.py +122 -16
  14. {fastapi_observer-0.2.4 → fastapi_observer-0.3.0}/src/fastapiobserver/metrics.py +26 -1
  15. {fastapi_observer-0.2.4 → fastapi_observer-0.3.0}/src/fastapiobserver/middleware.py +2 -2
  16. {fastapi_observer-0.2.4 → fastapi_observer-0.3.0}/src/fastapiobserver/otel/lifecycle.py +3 -5
  17. {fastapi_observer-0.2.4 → fastapi_observer-0.3.0}/src/fastapiobserver/otel/settings.py +5 -1
  18. {fastapi_observer-0.2.4 → fastapi_observer-0.3.0}/src/fastapiobserver/otel/tracing.py +5 -3
  19. {fastapi_observer-0.2.4 → fastapi_observer-0.3.0}/src/fastapiobserver/plugins.py +15 -0
  20. {fastapi_observer-0.2.4 → fastapi_observer-0.3.0}/src/fastapiobserver/request_context.py +15 -0
  21. {fastapi_observer-0.2.4 → fastapi_observer-0.3.0}/src/fastapiobserver/sinks.py +255 -20
  22. {fastapi_observer-0.2.4 → fastapi_observer-0.3.0}/tests/test_fastapi_install.py +26 -0
  23. fastapi_observer-0.3.0/tests/test_graphql_integrations.py +69 -0
  24. {fastapi_observer-0.2.4 → fastapi_observer-0.3.0}/tests/test_logging_schema_contract.py +74 -3
  25. fastapi_observer-0.3.0/tests/test_logtail_dlq.py +211 -0
  26. fastapi_observer-0.3.0/tests/test_sinks.py +265 -0
  27. fastapi_observer-0.2.4/src/fastapiobserver/_version.py +0 -1
  28. {fastapi_observer-0.2.4 → fastapi_observer-0.3.0}/LICENSE +0 -0
  29. {fastapi_observer-0.2.4 → fastapi_observer-0.3.0}/setup.cfg +0 -0
  30. {fastapi_observer-0.2.4 → fastapi_observer-0.3.0}/src/fastapi_observer.egg-info/dependency_links.txt +0 -0
  31. {fastapi_observer-0.2.4 → fastapi_observer-0.3.0}/src/fastapi_observer.egg-info/requires.txt +0 -0
  32. {fastapi_observer-0.2.4 → fastapi_observer-0.3.0}/src/fastapi_observer.egg-info/top_level.txt +0 -0
  33. {fastapi_observer-0.2.4 → fastapi_observer-0.3.0}/src/fastapiobserver/loguru.py +0 -0
  34. {fastapi_observer-0.2.4 → fastapi_observer-0.3.0}/src/fastapiobserver/otel/__init__.py +0 -0
  35. {fastapi_observer-0.2.4 → fastapi_observer-0.3.0}/src/fastapiobserver/otel/logs.py +0 -0
  36. {fastapi_observer-0.2.4 → fastapi_observer-0.3.0}/src/fastapiobserver/otel/metrics.py +0 -0
  37. {fastapi_observer-0.2.4 → fastapi_observer-0.3.0}/src/fastapiobserver/otel/resource.py +0 -0
  38. {fastapi_observer-0.2.4 → fastapi_observer-0.3.0}/src/fastapiobserver/otel/types.py +0 -0
  39. {fastapi_observer-0.2.4 → fastapi_observer-0.3.0}/src/fastapiobserver/propagation.py +0 -0
  40. {fastapi_observer-0.2.4 → fastapi_observer-0.3.0}/src/fastapiobserver/py.typed +0 -0
  41. {fastapi_observer-0.2.4 → fastapi_observer-0.3.0}/src/fastapiobserver/security.py +0 -0
  42. {fastapi_observer-0.2.4 → fastapi_observer-0.3.0}/src/fastapiobserver/utils.py +0 -0
  43. {fastapi_observer-0.2.4 → fastapi_observer-0.3.0}/tests/test_body_media_allowlist.py +0 -0
  44. {fastapi_observer-0.2.4 → fastapi_observer-0.3.0}/tests/test_config_settings.py +0 -0
  45. {fastapi_observer-0.2.4 → fastapi_observer-0.3.0}/tests/test_excluded_url_precedence.py +0 -0
  46. {fastapi_observer-0.2.4 → fastapi_observer-0.3.0}/tests/test_log_queue_controls.py +0 -0
  47. {fastapi_observer-0.2.4 → fastapi_observer-0.3.0}/tests/test_loguru_bridge.py +0 -0
  48. {fastapi_observer-0.2.4 → fastapi_observer-0.3.0}/tests/test_metrics_optional_dependency.py +0 -0
  49. {fastapi_observer-0.2.4 → fastapi_observer-0.3.0}/tests/test_middleware.py +0 -0
  50. {fastapi_observer-0.2.4 → fastapi_observer-0.3.0}/tests/test_negotiate_content_type.py +0 -0
  51. {fastapi_observer-0.2.4 → fastapi_observer-0.3.0}/tests/test_otel_integration.py +0 -0
  52. {fastapi_observer-0.2.4 → fastapi_observer-0.3.0}/tests/test_otel_lifecycle.py +0 -0
  53. {fastapi_observer-0.2.4 → fastapi_observer-0.3.0}/tests/test_otel_log_correlation.py +0 -0
  54. {fastapi_observer-0.2.4 → fastapi_observer-0.3.0}/tests/test_otel_logs_from_env.py +0 -0
  55. {fastapi_observer-0.2.4 → fastapi_observer-0.3.0}/tests/test_otel_logs_pipeline.py +0 -0
  56. {fastapi_observer-0.2.4 → fastapi_observer-0.3.0}/tests/test_otel_metrics_from_env.py +0 -0
  57. {fastapi_observer-0.2.4 → fastapi_observer-0.3.0}/tests/test_otlp_export_integration.py +0 -0
  58. {fastapi_observer-0.2.4 → fastapi_observer-0.3.0}/tests/test_plugin_isolation.py +0 -0
  59. {fastapi_observer-0.2.4 → fastapi_observer-0.3.0}/tests/test_propagation.py +0 -0
  60. {fastapi_observer-0.2.4 → fastapi_observer-0.3.0}/tests/test_redaction_presets.py +0 -0
  61. {fastapi_observer-0.2.4 → fastapi_observer-0.3.0}/tests/test_route_template_cardinality.py +0 -0
  62. {fastapi_observer-0.2.4 → fastapi_observer-0.3.0}/tests/test_runtime_control_auth.py +0 -0
  63. {fastapi_observer-0.2.4 → fastapi_observer-0.3.0}/tests/test_security_redaction.py +0 -0
  64. {fastapi_observer-0.2.4 → fastapi_observer-0.3.0}/tests/test_sink_circuit_breaker.py +0 -0
  65. {fastapi_observer-0.2.4 → fastapi_observer-0.3.0}/tests/test_trust_boundary.py +0 -0
  66. {fastapi_observer-0.2.4 → fastapi_observer-0.3.0}/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.4
3
+ Version: 0.3.0
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
@@ -22,6 +22,7 @@ Classifier: Programming Language :: Python :: 3.14
22
22
  Classifier: Framework :: FastAPI
23
23
  Classifier: Topic :: System :: Logging
24
24
  Classifier: Topic :: System :: Monitoring
25
+ Classifier: Typing :: Typed
25
26
  Requires-Python: <3.15,>=3.10
26
27
  Description-Content-Type: text/markdown
27
28
  License-File: LICENSE
@@ -423,14 +424,15 @@ Request arrives
423
424
  }
424
425
  ```
425
426
 
426
- On exception logs, a structured `error` object is included for indexed queries:
427
+ On exception logs, a structured `error` object is included for indexed queries, featuring a stable AST-based `fingerprint` hash which ignores transient memory locations or exact line numbers, allowing zero-dependency alerting directly in your search backend.
427
428
 
428
429
  ```json
429
430
  {
430
431
  "error": {
431
432
  "type": "RuntimeError",
432
433
  "message": "boom",
433
- "stacktrace": "Traceback (most recent call last): ..."
434
+ "stacktrace": "Traceback (most recent call last): ...",
435
+ "fingerprint": "a1b2c3d4e5f67890abcd12345678bbcc"
434
436
  }
435
437
  }
436
438
  ```
@@ -583,6 +585,7 @@ The `examples/` directory contains runnable demos:
583
585
  | [`security_presets_app.py`](examples/security_presets_app.py) | Preset-based security policy |
584
586
  | [`allowlist_app.py`](examples/allowlist_app.py) | Allowlist-only sanitization |
585
587
  | [`otel_app.py`](examples/otel_app.py) | OTel tracing and resource attributes |
588
+ | [`graphql_app.py`](examples/graphql_app.py) | Native Strawberry GraphQL observability |
586
589
  | [`benchmarks/`](examples/benchmarks/) | Baseline vs observer benchmark harness |
587
590
  | [`k8s/`](examples/k8s/) | Kubernetes-native stack with Prometheus + Loki + Tempo + Grafana |
588
591
  | [`full_stack/`](examples/full_stack/) | **Docker Compose stack**: 3 FastAPI services + Grafana + Prometheus + Loki + Tempo |
@@ -702,6 +705,13 @@ Notes:
702
705
  | `LOGTAIL_SOURCE_TOKEN` | - | Logtail source token |
703
706
  | `LOGTAIL_BATCH_SIZE` | `50` | Batch size for shipping |
704
707
  | `LOGTAIL_FLUSH_INTERVAL` | `2.0` | Flush interval (seconds) |
708
+ | `LOGTAIL_DLQ_ENABLED` | `false` | Enable resilient local disk fallback for dropped logs |
709
+ | `LOGTAIL_DLQ_DIR` | `.dlq/logtail` | Directory to archive dropped NDJSON messages |
710
+ | `LOGTAIL_DLQ_MAX_BYTES` | `52428800` | Max bytes per DLQ file before rotation (50MB) |
711
+ | `LOGTAIL_DLQ_COMPRESS` | `true` | GZIP compress rotated DLQ files |
712
+
713
+ > [!TIP]
714
+ > The Logtail Dead Letter Queue (DLQ) provides best-effort local durability. If the internal memory queue overflows under immense API pressure (`queue.Full`), or if an external network partition completely exhausts the outbound HTTP retry backoff, the dropped log payloads are immediately salvaged into local NDJSON envelopes. You can replay these files to BetterStack later using the provided `scripts/replay_dlq.py` utility.
705
715
 
706
716
  ---
707
717
 
@@ -787,6 +797,21 @@ Breaker metrics exposed on `/metrics`:
787
797
  - `fastapiobserver_sink_circuit_breaker_half_open_total{sink}`
788
798
  - `fastapiobserver_sink_circuit_breaker_closes_total{sink}`
789
799
 
800
+ ### Logging Shutdown Lifecycle
801
+
802
+ `install_observability()` now registers graceful logging teardown on FastAPI
803
+ shutdown and also uses an `atexit` fallback. This reduces lost log records
804
+ during process termination.
805
+
806
+ If you embed logging setup outside FastAPI lifecycle management, you can stop
807
+ the queue pipeline explicitly:
808
+
809
+ ```python
810
+ from fastapiobserver import shutdown_logging
811
+
812
+ shutdown_logging()
813
+ ```
814
+
790
815
  ### Loguru Coexistence
791
816
 
792
817
  If your service already uses `loguru`, forward those logs into
@@ -803,6 +828,45 @@ Detailed migration/coexistence guide: [`loguru.md`](loguru.md)
803
828
 
804
829
  ---
805
830
 
831
+ ## GraphQL Integrations (Strawberry)
832
+
833
+ If you use `strawberry-graphql`, routing all traffic through `POST /graphql` severely blinds your logs and traces.
834
+ `fastapi-observer` ships a native Strawberry extension (via Duck Typing, meaning NO bloated pip dependencies) to automatically extract GraphQL operations.
835
+
836
+ ```python
837
+ import strawberry
838
+ from fastapiobserver.integrations.strawberry import StrawberryObservabilityExtension
839
+
840
+ @strawberry.type
841
+ class Query:
842
+ @strawberry.field
843
+ def hello(self) -> str:
844
+ return "world"
845
+
846
+ schema = strawberry.Schema(
847
+ query=Query,
848
+ extensions=[StrawberryObservabilityExtension], # Inject this!
849
+ )
850
+ ```
851
+
852
+ With this extension, your logs will automatically get a `graphql` context key containing the extracted `operation_name`:
853
+ ```json
854
+ {
855
+ "event": {
856
+ "method": "POST",
857
+ "path": "/graphql"
858
+ },
859
+ "user_context": {
860
+ "graphql": {
861
+ "operation_name": "GetUsersQuery"
862
+ }
863
+ }
864
+ }
865
+ ```
866
+ If OpenTelemetry is enabled, your traces will dynamically rename from `POST /graphql` to `graphql.operation.GetUsersQuery`.
867
+
868
+ ---
869
+
806
870
  ## Plugin Hooks
807
871
 
808
872
  Extend behavior without editing package internals:
@@ -899,7 +963,7 @@ Reproducible benchmark harness and methodology:
899
963
  - `0.2.x`: OTel interoperability, security presets, allowlists
900
964
  - `1.0.0`: dynamic runtime controls and plugin stability
901
965
 
902
- Current release version: `0.2.4`
966
+ Current release version: `0.3.0`
903
967
 
904
968
  ## Changelog Policy
905
969
 
@@ -355,14 +355,15 @@ Request arrives
355
355
  }
356
356
  ```
357
357
 
358
- On exception logs, a structured `error` object is included for indexed queries:
358
+ On exception logs, a structured `error` object is included for indexed queries, featuring a stable AST-based `fingerprint` hash which ignores transient memory locations or exact line numbers, allowing zero-dependency alerting directly in your search backend.
359
359
 
360
360
  ```json
361
361
  {
362
362
  "error": {
363
363
  "type": "RuntimeError",
364
364
  "message": "boom",
365
- "stacktrace": "Traceback (most recent call last): ..."
365
+ "stacktrace": "Traceback (most recent call last): ...",
366
+ "fingerprint": "a1b2c3d4e5f67890abcd12345678bbcc"
366
367
  }
367
368
  }
368
369
  ```
@@ -515,6 +516,7 @@ The `examples/` directory contains runnable demos:
515
516
  | [`security_presets_app.py`](examples/security_presets_app.py) | Preset-based security policy |
516
517
  | [`allowlist_app.py`](examples/allowlist_app.py) | Allowlist-only sanitization |
517
518
  | [`otel_app.py`](examples/otel_app.py) | OTel tracing and resource attributes |
519
+ | [`graphql_app.py`](examples/graphql_app.py) | Native Strawberry GraphQL observability |
518
520
  | [`benchmarks/`](examples/benchmarks/) | Baseline vs observer benchmark harness |
519
521
  | [`k8s/`](examples/k8s/) | Kubernetes-native stack with Prometheus + Loki + Tempo + Grafana |
520
522
  | [`full_stack/`](examples/full_stack/) | **Docker Compose stack**: 3 FastAPI services + Grafana + Prometheus + Loki + Tempo |
@@ -634,6 +636,13 @@ Notes:
634
636
  | `LOGTAIL_SOURCE_TOKEN` | - | Logtail source token |
635
637
  | `LOGTAIL_BATCH_SIZE` | `50` | Batch size for shipping |
636
638
  | `LOGTAIL_FLUSH_INTERVAL` | `2.0` | Flush interval (seconds) |
639
+ | `LOGTAIL_DLQ_ENABLED` | `false` | Enable resilient local disk fallback for dropped logs |
640
+ | `LOGTAIL_DLQ_DIR` | `.dlq/logtail` | Directory to archive dropped NDJSON messages |
641
+ | `LOGTAIL_DLQ_MAX_BYTES` | `52428800` | Max bytes per DLQ file before rotation (50MB) |
642
+ | `LOGTAIL_DLQ_COMPRESS` | `true` | GZIP compress rotated DLQ files |
643
+
644
+ > [!TIP]
645
+ > The Logtail Dead Letter Queue (DLQ) provides best-effort local durability. If the internal memory queue overflows under immense API pressure (`queue.Full`), or if an external network partition completely exhausts the outbound HTTP retry backoff, the dropped log payloads are immediately salvaged into local NDJSON envelopes. You can replay these files to BetterStack later using the provided `scripts/replay_dlq.py` utility.
637
646
 
638
647
  ---
639
648
 
@@ -719,6 +728,21 @@ Breaker metrics exposed on `/metrics`:
719
728
  - `fastapiobserver_sink_circuit_breaker_half_open_total{sink}`
720
729
  - `fastapiobserver_sink_circuit_breaker_closes_total{sink}`
721
730
 
731
+ ### Logging Shutdown Lifecycle
732
+
733
+ `install_observability()` now registers graceful logging teardown on FastAPI
734
+ shutdown and also uses an `atexit` fallback. This reduces lost log records
735
+ during process termination.
736
+
737
+ If you embed logging setup outside FastAPI lifecycle management, you can stop
738
+ the queue pipeline explicitly:
739
+
740
+ ```python
741
+ from fastapiobserver import shutdown_logging
742
+
743
+ shutdown_logging()
744
+ ```
745
+
722
746
  ### Loguru Coexistence
723
747
 
724
748
  If your service already uses `loguru`, forward those logs into
@@ -735,6 +759,45 @@ Detailed migration/coexistence guide: [`loguru.md`](loguru.md)
735
759
 
736
760
  ---
737
761
 
762
+ ## GraphQL Integrations (Strawberry)
763
+
764
+ If you use `strawberry-graphql`, routing all traffic through `POST /graphql` severely blinds your logs and traces.
765
+ `fastapi-observer` ships a native Strawberry extension (via Duck Typing, meaning NO bloated pip dependencies) to automatically extract GraphQL operations.
766
+
767
+ ```python
768
+ import strawberry
769
+ from fastapiobserver.integrations.strawberry import StrawberryObservabilityExtension
770
+
771
+ @strawberry.type
772
+ class Query:
773
+ @strawberry.field
774
+ def hello(self) -> str:
775
+ return "world"
776
+
777
+ schema = strawberry.Schema(
778
+ query=Query,
779
+ extensions=[StrawberryObservabilityExtension], # Inject this!
780
+ )
781
+ ```
782
+
783
+ With this extension, your logs will automatically get a `graphql` context key containing the extracted `operation_name`:
784
+ ```json
785
+ {
786
+ "event": {
787
+ "method": "POST",
788
+ "path": "/graphql"
789
+ },
790
+ "user_context": {
791
+ "graphql": {
792
+ "operation_name": "GetUsersQuery"
793
+ }
794
+ }
795
+ }
796
+ ```
797
+ If OpenTelemetry is enabled, your traces will dynamically rename from `POST /graphql` to `graphql.operation.GetUsersQuery`.
798
+
799
+ ---
800
+
738
801
  ## Plugin Hooks
739
802
 
740
803
  Extend behavior without editing package internals:
@@ -831,7 +894,7 @@ Reproducible benchmark harness and methodology:
831
894
  - `0.2.x`: OTel interoperability, security presets, allowlists
832
895
  - `1.0.0`: dynamic runtime controls and plugin stability
833
896
 
834
- Current release version: `0.2.4`
897
+ Current release version: `0.3.0`
835
898
 
836
899
  ## Changelog Policy
837
900
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "fastapi-observer"
7
- version = "0.2.4"
7
+ dynamic = ["version"]
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"
@@ -24,6 +24,7 @@ classifiers = [
24
24
  "Framework :: FastAPI",
25
25
  "Topic :: System :: Logging",
26
26
  "Topic :: System :: Monitoring",
27
+ "Typing :: Typed",
27
28
  ]
28
29
  keywords = ["fastapi", "observability", "logging", "metrics", "opentelemetry"]
29
30
  license = "MIT"
@@ -90,9 +91,20 @@ dev = [
90
91
  [tool.setuptools]
91
92
  package-dir = { "" = "src" }
92
93
 
94
+ [tool.setuptools.dynamic]
95
+ version = { attr = "fastapiobserver._version.__version__" }
96
+
93
97
  [tool.setuptools.packages.find]
94
98
  where = ["src"]
95
99
 
100
+ [tool.ruff]
101
+ target-version = "py310"
102
+ line-length = 100
103
+ src = ["src", "tests"]
104
+
105
+ [tool.ruff.lint]
106
+ select = ["E", "F"]
107
+
96
108
  [tool.mypy]
97
109
  ignore_missing_imports = false
98
110
 
@@ -103,3 +115,4 @@ ignore_missing_imports = true
103
115
  [tool.pytest.ini_options]
104
116
  pythonpath = ["src"]
105
117
  testpaths = ["tests"]
118
+ asyncio_mode = "auto"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fastapi-observer
3
- Version: 0.2.4
3
+ Version: 0.3.0
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
@@ -22,6 +22,7 @@ Classifier: Programming Language :: Python :: 3.14
22
22
  Classifier: Framework :: FastAPI
23
23
  Classifier: Topic :: System :: Logging
24
24
  Classifier: Topic :: System :: Monitoring
25
+ Classifier: Typing :: Typed
25
26
  Requires-Python: <3.15,>=3.10
26
27
  Description-Content-Type: text/markdown
27
28
  License-File: LICENSE
@@ -423,14 +424,15 @@ Request arrives
423
424
  }
424
425
  ```
425
426
 
426
- On exception logs, a structured `error` object is included for indexed queries:
427
+ On exception logs, a structured `error` object is included for indexed queries, featuring a stable AST-based `fingerprint` hash which ignores transient memory locations or exact line numbers, allowing zero-dependency alerting directly in your search backend.
427
428
 
428
429
  ```json
429
430
  {
430
431
  "error": {
431
432
  "type": "RuntimeError",
432
433
  "message": "boom",
433
- "stacktrace": "Traceback (most recent call last): ..."
434
+ "stacktrace": "Traceback (most recent call last): ...",
435
+ "fingerprint": "a1b2c3d4e5f67890abcd12345678bbcc"
434
436
  }
435
437
  }
436
438
  ```
@@ -583,6 +585,7 @@ The `examples/` directory contains runnable demos:
583
585
  | [`security_presets_app.py`](examples/security_presets_app.py) | Preset-based security policy |
584
586
  | [`allowlist_app.py`](examples/allowlist_app.py) | Allowlist-only sanitization |
585
587
  | [`otel_app.py`](examples/otel_app.py) | OTel tracing and resource attributes |
588
+ | [`graphql_app.py`](examples/graphql_app.py) | Native Strawberry GraphQL observability |
586
589
  | [`benchmarks/`](examples/benchmarks/) | Baseline vs observer benchmark harness |
587
590
  | [`k8s/`](examples/k8s/) | Kubernetes-native stack with Prometheus + Loki + Tempo + Grafana |
588
591
  | [`full_stack/`](examples/full_stack/) | **Docker Compose stack**: 3 FastAPI services + Grafana + Prometheus + Loki + Tempo |
@@ -702,6 +705,13 @@ Notes:
702
705
  | `LOGTAIL_SOURCE_TOKEN` | - | Logtail source token |
703
706
  | `LOGTAIL_BATCH_SIZE` | `50` | Batch size for shipping |
704
707
  | `LOGTAIL_FLUSH_INTERVAL` | `2.0` | Flush interval (seconds) |
708
+ | `LOGTAIL_DLQ_ENABLED` | `false` | Enable resilient local disk fallback for dropped logs |
709
+ | `LOGTAIL_DLQ_DIR` | `.dlq/logtail` | Directory to archive dropped NDJSON messages |
710
+ | `LOGTAIL_DLQ_MAX_BYTES` | `52428800` | Max bytes per DLQ file before rotation (50MB) |
711
+ | `LOGTAIL_DLQ_COMPRESS` | `true` | GZIP compress rotated DLQ files |
712
+
713
+ > [!TIP]
714
+ > The Logtail Dead Letter Queue (DLQ) provides best-effort local durability. If the internal memory queue overflows under immense API pressure (`queue.Full`), or if an external network partition completely exhausts the outbound HTTP retry backoff, the dropped log payloads are immediately salvaged into local NDJSON envelopes. You can replay these files to BetterStack later using the provided `scripts/replay_dlq.py` utility.
705
715
 
706
716
  ---
707
717
 
@@ -787,6 +797,21 @@ Breaker metrics exposed on `/metrics`:
787
797
  - `fastapiobserver_sink_circuit_breaker_half_open_total{sink}`
788
798
  - `fastapiobserver_sink_circuit_breaker_closes_total{sink}`
789
799
 
800
+ ### Logging Shutdown Lifecycle
801
+
802
+ `install_observability()` now registers graceful logging teardown on FastAPI
803
+ shutdown and also uses an `atexit` fallback. This reduces lost log records
804
+ during process termination.
805
+
806
+ If you embed logging setup outside FastAPI lifecycle management, you can stop
807
+ the queue pipeline explicitly:
808
+
809
+ ```python
810
+ from fastapiobserver import shutdown_logging
811
+
812
+ shutdown_logging()
813
+ ```
814
+
790
815
  ### Loguru Coexistence
791
816
 
792
817
  If your service already uses `loguru`, forward those logs into
@@ -803,6 +828,45 @@ Detailed migration/coexistence guide: [`loguru.md`](loguru.md)
803
828
 
804
829
  ---
805
830
 
831
+ ## GraphQL Integrations (Strawberry)
832
+
833
+ If you use `strawberry-graphql`, routing all traffic through `POST /graphql` severely blinds your logs and traces.
834
+ `fastapi-observer` ships a native Strawberry extension (via Duck Typing, meaning NO bloated pip dependencies) to automatically extract GraphQL operations.
835
+
836
+ ```python
837
+ import strawberry
838
+ from fastapiobserver.integrations.strawberry import StrawberryObservabilityExtension
839
+
840
+ @strawberry.type
841
+ class Query:
842
+ @strawberry.field
843
+ def hello(self) -> str:
844
+ return "world"
845
+
846
+ schema = strawberry.Schema(
847
+ query=Query,
848
+ extensions=[StrawberryObservabilityExtension], # Inject this!
849
+ )
850
+ ```
851
+
852
+ With this extension, your logs will automatically get a `graphql` context key containing the extracted `operation_name`:
853
+ ```json
854
+ {
855
+ "event": {
856
+ "method": "POST",
857
+ "path": "/graphql"
858
+ },
859
+ "user_context": {
860
+ "graphql": {
861
+ "operation_name": "GetUsersQuery"
862
+ }
863
+ }
864
+ }
865
+ ```
866
+ If OpenTelemetry is enabled, your traces will dynamically rename from `POST /graphql` to `graphql.operation.GetUsersQuery`.
867
+
868
+ ---
869
+
806
870
  ## Plugin Hooks
807
871
 
808
872
  Extend behavior without editing package internals:
@@ -899,7 +963,7 @@ Reproducible benchmark harness and methodology:
899
963
  - `0.2.x`: OTel interoperability, security presets, allowlists
900
964
  - `1.0.0`: dynamic runtime controls and plugin stability
901
965
 
902
- Current release version: `0.2.4`
966
+ Current release version: `0.3.0`
903
967
 
904
968
  ## Changelog Policy
905
969
 
@@ -22,6 +22,8 @@ src/fastapiobserver/request_context.py
22
22
  src/fastapiobserver/security.py
23
23
  src/fastapiobserver/sinks.py
24
24
  src/fastapiobserver/utils.py
25
+ src/fastapiobserver/integrations/__init__.py
26
+ src/fastapiobserver/integrations/strawberry.py
25
27
  src/fastapiobserver/otel/__init__.py
26
28
  src/fastapiobserver/otel/lifecycle.py
27
29
  src/fastapiobserver/otel/logs.py
@@ -34,8 +36,10 @@ tests/test_body_media_allowlist.py
34
36
  tests/test_config_settings.py
35
37
  tests/test_excluded_url_precedence.py
36
38
  tests/test_fastapi_install.py
39
+ tests/test_graphql_integrations.py
37
40
  tests/test_log_queue_controls.py
38
41
  tests/test_logging_schema_contract.py
42
+ tests/test_logtail_dlq.py
39
43
  tests/test_loguru_bridge.py
40
44
  tests/test_metrics_optional_dependency.py
41
45
  tests/test_middleware.py
@@ -54,5 +58,6 @@ tests/test_route_template_cardinality.py
54
58
  tests/test_runtime_control_auth.py
55
59
  tests/test_security_redaction.py
56
60
  tests/test_sink_circuit_breaker.py
61
+ tests/test_sinks.py
57
62
  tests/test_trust_boundary.py
58
63
  tests/test_utils_refactors.py
@@ -11,6 +11,7 @@ from .logging import (
11
11
  get_log_queue_stats,
12
12
  get_sink_circuit_breaker_stats,
13
13
  setup_logging,
14
+ shutdown_logging,
14
15
  )
15
16
  from .loguru import (
16
17
  build_loguru_to_stdlib_sink,
@@ -101,6 +102,7 @@ __all__ = [
101
102
  "get_log_queue_stats",
102
103
  "get_sink_circuit_breaker_stats",
103
104
  "setup_logging",
105
+ "shutdown_logging",
104
106
  # Loguru bridge
105
107
  "build_loguru_to_stdlib_sink",
106
108
  "install_loguru_bridge",
@@ -0,0 +1 @@
1
+ __version__ = "0.3.0"
@@ -103,6 +103,28 @@ class ObservabilitySettings(BaseSettings):
103
103
  default=2.0, gt=0.0, validation_alias="LOGTAIL_FLUSH_INTERVAL"
104
104
  )
105
105
 
106
+ # --- logtail DLQ ---
107
+ logtail_dlq_enabled: bool = Field(
108
+ default=False, validation_alias="LOGTAIL_DLQ_ENABLED"
109
+ )
110
+ logtail_dlq_dir: str = Field(
111
+ default=".dlq/logtail", validation_alias="LOGTAIL_DLQ_DIR"
112
+ )
113
+ logtail_dlq_filename: str = Field(
114
+ default="logtail_dlq.ndjson",
115
+ validation_alias="LOGTAIL_DLQ_FILENAME",
116
+ )
117
+ logtail_dlq_max_bytes: int = Field(
118
+ default=50 * 1024 * 1024,
119
+ validation_alias="LOGTAIL_DLQ_MAX_BYTES",
120
+ )
121
+ logtail_dlq_backup_count: int = Field(
122
+ default=10, validation_alias="LOGTAIL_DLQ_BACKUP_COUNT"
123
+ )
124
+ logtail_dlq_compress: bool = Field(
125
+ default=True, validation_alias="LOGTAIL_DLQ_COMPRESS"
126
+ )
127
+
106
128
  # ---------- validators ----------
107
129
 
108
130
  @field_validator("log_level")
@@ -3,6 +3,7 @@ from __future__ import annotations
3
3
  import logging
4
4
  import os
5
5
  import secrets
6
+ import weakref
6
7
  from typing import Annotated, Literal
7
8
 
8
9
  from fastapi import APIRouter, Depends, FastAPI, Header, HTTPException, status
@@ -12,6 +13,13 @@ from pydantic_settings import BaseSettings, SettingsConfigDict
12
13
  from .otel import get_trace_sampling_ratio, set_trace_sampling_ratio
13
14
  from .utils import EnvLoadable, normalize_path
14
15
 
16
+ __all__ = [
17
+ "RuntimeControlSettings",
18
+ "ControlPlanePayload",
19
+ "mount_control_plane",
20
+ ]
21
+
22
+ _MOUNTED_CONTROL_PATHS: weakref.WeakKeyDictionary[FastAPI, set[str]] = weakref.WeakKeyDictionary()
15
23
 
16
24
  class RuntimeControlSettings(EnvLoadable, BaseModel):
17
25
  model_config = ConfigDict(frozen=True)
@@ -57,9 +65,7 @@ def mount_control_plane(app: FastAPI, settings: RuntimeControlSettings) -> None:
57
65
  )
58
66
 
59
67
  normalized_path = settings.path
60
- mounted_paths: set[str] = getattr(
61
- app.state, "_fastapiobserver_control_paths", set()
62
- )
68
+ mounted_paths = _MOUNTED_CONTROL_PATHS.setdefault(app, set())
63
69
  if normalized_path in mounted_paths:
64
70
  return
65
71
 
@@ -93,7 +99,6 @@ def mount_control_plane(app: FastAPI, settings: RuntimeControlSettings) -> None:
93
99
 
94
100
  app.include_router(router)
95
101
  mounted_paths.add(normalized_path)
96
- app.state._fastapiobserver_control_paths = mounted_paths
97
102
 
98
103
 
99
104
  def _current_runtime_settings() -> dict[str, object]:
@@ -1,13 +1,16 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import logging
4
+ import weakref
5
+ import typing
6
+ from contextlib import asynccontextmanager
4
7
  from typing import Literal
5
8
 
6
9
  from fastapi import FastAPI
7
10
 
8
11
  from .config import ObservabilitySettings
9
12
  from .control_plane import RuntimeControlSettings, mount_control_plane
10
- from .logging import setup_logging
13
+ from .logging import setup_logging, shutdown_logging
11
14
  from .metrics import (
12
15
  build_metrics_backend,
13
16
  mount_backend_metrics_endpoint,
@@ -24,6 +27,7 @@ from .otel import (
24
27
  from .security import SecurityPolicy, TrustedProxyPolicy
25
28
 
26
29
  _LOGGER = logging.getLogger("fastapiobserver.fastapi")
30
+ _REGISTERED_APPS: weakref.WeakSet[FastAPI] = weakref.WeakSet()
27
31
 
28
32
 
29
33
  def install_observability(
@@ -77,6 +81,7 @@ def install_observability(
77
81
  logs_mode=logs_mode,
78
82
  extra_handlers=extra_handlers,
79
83
  )
84
+ _register_logging_shutdown_hook(app)
80
85
 
81
86
  # --- 3. Metrics backend ---
82
87
  enable_metrics = (
@@ -151,3 +156,22 @@ def _has_request_logging_middleware(app: FastAPI) -> bool:
151
156
  if middleware.cls is RequestLoggingMiddleware:
152
157
  return True
153
158
  return False
159
+
160
+
161
+ def _register_logging_shutdown_hook(app: FastAPI) -> None:
162
+ if app in _REGISTERED_APPS:
163
+ return
164
+
165
+ original_lifespan = app.router.lifespan_context
166
+
167
+ @asynccontextmanager
168
+ async def observability_lifespan(app_inst: FastAPI) -> typing.AsyncGenerator[typing.Any, None]:
169
+ if original_lifespan:
170
+ async with original_lifespan(app_inst) as state:
171
+ yield state
172
+ else:
173
+ yield {}
174
+ shutdown_logging()
175
+
176
+ app.router.lifespan_context = observability_lifespan
177
+ _REGISTERED_APPS.add(app)
@@ -0,0 +1,78 @@
1
+ """Strawberry GraphQL integration for fastapi-observer.
2
+
3
+ This module provides a `StrawberryObservabilityExtension` that automatically
4
+ extracts the `operationName` from incoming GraphQL requests and injects it
5
+ into the active `fastapiobserver` telemetry context, as well as setting the
6
+ OpenTelemetry span name if active.
7
+
8
+ It does NOT require `strawberry-graphql` as a hard dependency. It uses Duck Typing.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import logging
14
+ from typing import Any
15
+
16
+ from ..request_context import get_user_context, set_user_context
17
+
18
+ _LOGGER = logging.getLogger("fastapiobserver.integrations.strawberry")
19
+
20
+
21
+ class StrawberryObservabilityExtension:
22
+ """Strawberry SchemaExtension for injecting operation names into logs/traces.
23
+
24
+ Usage:
25
+ import strawberry
26
+ from fastapiobserver.integrations.strawberry import StrawberryObservabilityExtension
27
+
28
+ schema = strawberry.Schema(
29
+ query=Query,
30
+ extensions=[StrawberryObservabilityExtension],
31
+ )
32
+ """
33
+
34
+ def __init__(self, *, execution_context: Any = None) -> None:
35
+ self.execution_context = execution_context
36
+
37
+ async def on_operation(self) -> Any:
38
+ # Note: In Strawberry extensions, `on_operation` is an async generator.
39
+ # We intercept before the operation runs, yield to allow execution, then clean up.
40
+
41
+ op_name = "AnonymousOperation"
42
+
43
+ try:
44
+ if self.execution_context:
45
+ op_name = self.execution_context.operation_name or op_name
46
+ except Exception:
47
+ _LOGGER.debug(
48
+ "strawberry_extension.parse_failed",
49
+ exc_info=True,
50
+ extra={"_skip_enrichers": True},
51
+ )
52
+
53
+ # 1. Update the logging context so StructuredJsonFormatter sees it
54
+ context = get_user_context() or {}
55
+ graphql_data = context.get("graphql", {})
56
+ if isinstance(graphql_data, dict):
57
+ graphql_data["operation_name"] = op_name
58
+ context["graphql"] = graphql_data
59
+ set_user_context(context)
60
+
61
+ # 2. Update OpenTelemetry Trace if active
62
+ try:
63
+ from opentelemetry import trace as otel_trace # type: ignore
64
+
65
+ span = otel_trace.get_current_span()
66
+ if span and span.is_recording():
67
+ span.update_name(f"graphql.operation.{op_name}")
68
+ span.set_attribute("graphql.operation.name", op_name)
69
+ except ImportError:
70
+ pass # OTel not installed
71
+ except Exception:
72
+ _LOGGER.debug(
73
+ "strawberry_extension.otel_failed",
74
+ exc_info=True,
75
+ extra={"_skip_enrichers": True},
76
+ )
77
+
78
+ yield # Allow Strawberry to execute the actual GraphQL resolvers