fastapi-observer 0.2.5__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.
- {fastapi_observer-0.2.5/src/fastapi_observer.egg-info → fastapi_observer-0.3.0}/PKG-INFO +53 -4
- {fastapi_observer-0.2.5 → fastapi_observer-0.3.0}/README.md +51 -3
- {fastapi_observer-0.2.5 → fastapi_observer-0.3.0}/pyproject.toml +2 -0
- {fastapi_observer-0.2.5 → fastapi_observer-0.3.0/src/fastapi_observer.egg-info}/PKG-INFO +53 -4
- {fastapi_observer-0.2.5 → fastapi_observer-0.3.0}/src/fastapi_observer.egg-info/SOURCES.txt +5 -0
- fastapi_observer-0.3.0/src/fastapiobserver/_version.py +1 -0
- {fastapi_observer-0.2.5 → fastapi_observer-0.3.0}/src/fastapiobserver/config.py +22 -0
- {fastapi_observer-0.2.5 → fastapi_observer-0.3.0}/src/fastapiobserver/control_plane.py +9 -4
- {fastapi_observer-0.2.5 → fastapi_observer-0.3.0}/src/fastapiobserver/fastapi.py +19 -4
- fastapi_observer-0.3.0/src/fastapiobserver/integrations/__init__.py +0 -0
- fastapi_observer-0.3.0/src/fastapiobserver/integrations/strawberry.py +78 -0
- {fastapi_observer-0.2.5 → fastapi_observer-0.3.0}/src/fastapiobserver/logging.py +54 -17
- {fastapi_observer-0.2.5 → fastapi_observer-0.3.0}/src/fastapiobserver/metrics.py +26 -1
- {fastapi_observer-0.2.5 → fastapi_observer-0.3.0}/src/fastapiobserver/middleware.py +2 -2
- {fastapi_observer-0.2.5 → fastapi_observer-0.3.0}/src/fastapiobserver/otel/lifecycle.py +3 -5
- {fastapi_observer-0.2.5 → fastapi_observer-0.3.0}/src/fastapiobserver/otel/tracing.py +5 -3
- {fastapi_observer-0.2.5 → fastapi_observer-0.3.0}/src/fastapiobserver/plugins.py +15 -0
- {fastapi_observer-0.2.5 → fastapi_observer-0.3.0}/src/fastapiobserver/request_context.py +15 -0
- {fastapi_observer-0.2.5 → fastapi_observer-0.3.0}/src/fastapiobserver/sinks.py +255 -20
- {fastapi_observer-0.2.5 → fastapi_observer-0.3.0}/tests/test_fastapi_install.py +14 -11
- fastapi_observer-0.3.0/tests/test_graphql_integrations.py +69 -0
- {fastapi_observer-0.2.5 → fastapi_observer-0.3.0}/tests/test_logging_schema_contract.py +41 -4
- fastapi_observer-0.3.0/tests/test_logtail_dlq.py +211 -0
- fastapi_observer-0.3.0/tests/test_sinks.py +265 -0
- fastapi_observer-0.2.5/src/fastapiobserver/_version.py +0 -1
- {fastapi_observer-0.2.5 → fastapi_observer-0.3.0}/LICENSE +0 -0
- {fastapi_observer-0.2.5 → fastapi_observer-0.3.0}/setup.cfg +0 -0
- {fastapi_observer-0.2.5 → fastapi_observer-0.3.0}/src/fastapi_observer.egg-info/dependency_links.txt +0 -0
- {fastapi_observer-0.2.5 → fastapi_observer-0.3.0}/src/fastapi_observer.egg-info/requires.txt +0 -0
- {fastapi_observer-0.2.5 → fastapi_observer-0.3.0}/src/fastapi_observer.egg-info/top_level.txt +0 -0
- {fastapi_observer-0.2.5 → fastapi_observer-0.3.0}/src/fastapiobserver/__init__.py +0 -0
- {fastapi_observer-0.2.5 → fastapi_observer-0.3.0}/src/fastapiobserver/loguru.py +0 -0
- {fastapi_observer-0.2.5 → fastapi_observer-0.3.0}/src/fastapiobserver/otel/__init__.py +0 -0
- {fastapi_observer-0.2.5 → fastapi_observer-0.3.0}/src/fastapiobserver/otel/logs.py +0 -0
- {fastapi_observer-0.2.5 → fastapi_observer-0.3.0}/src/fastapiobserver/otel/metrics.py +0 -0
- {fastapi_observer-0.2.5 → fastapi_observer-0.3.0}/src/fastapiobserver/otel/resource.py +0 -0
- {fastapi_observer-0.2.5 → fastapi_observer-0.3.0}/src/fastapiobserver/otel/settings.py +0 -0
- {fastapi_observer-0.2.5 → fastapi_observer-0.3.0}/src/fastapiobserver/otel/types.py +0 -0
- {fastapi_observer-0.2.5 → fastapi_observer-0.3.0}/src/fastapiobserver/propagation.py +0 -0
- {fastapi_observer-0.2.5 → fastapi_observer-0.3.0}/src/fastapiobserver/py.typed +0 -0
- {fastapi_observer-0.2.5 → fastapi_observer-0.3.0}/src/fastapiobserver/security.py +0 -0
- {fastapi_observer-0.2.5 → fastapi_observer-0.3.0}/src/fastapiobserver/utils.py +0 -0
- {fastapi_observer-0.2.5 → fastapi_observer-0.3.0}/tests/test_body_media_allowlist.py +0 -0
- {fastapi_observer-0.2.5 → fastapi_observer-0.3.0}/tests/test_config_settings.py +0 -0
- {fastapi_observer-0.2.5 → fastapi_observer-0.3.0}/tests/test_excluded_url_precedence.py +0 -0
- {fastapi_observer-0.2.5 → fastapi_observer-0.3.0}/tests/test_log_queue_controls.py +0 -0
- {fastapi_observer-0.2.5 → fastapi_observer-0.3.0}/tests/test_loguru_bridge.py +0 -0
- {fastapi_observer-0.2.5 → fastapi_observer-0.3.0}/tests/test_metrics_optional_dependency.py +0 -0
- {fastapi_observer-0.2.5 → fastapi_observer-0.3.0}/tests/test_middleware.py +0 -0
- {fastapi_observer-0.2.5 → fastapi_observer-0.3.0}/tests/test_negotiate_content_type.py +0 -0
- {fastapi_observer-0.2.5 → fastapi_observer-0.3.0}/tests/test_otel_integration.py +0 -0
- {fastapi_observer-0.2.5 → fastapi_observer-0.3.0}/tests/test_otel_lifecycle.py +0 -0
- {fastapi_observer-0.2.5 → fastapi_observer-0.3.0}/tests/test_otel_log_correlation.py +0 -0
- {fastapi_observer-0.2.5 → fastapi_observer-0.3.0}/tests/test_otel_logs_from_env.py +0 -0
- {fastapi_observer-0.2.5 → fastapi_observer-0.3.0}/tests/test_otel_logs_pipeline.py +0 -0
- {fastapi_observer-0.2.5 → fastapi_observer-0.3.0}/tests/test_otel_metrics_from_env.py +0 -0
- {fastapi_observer-0.2.5 → fastapi_observer-0.3.0}/tests/test_otlp_export_integration.py +0 -0
- {fastapi_observer-0.2.5 → fastapi_observer-0.3.0}/tests/test_plugin_isolation.py +0 -0
- {fastapi_observer-0.2.5 → fastapi_observer-0.3.0}/tests/test_propagation.py +0 -0
- {fastapi_observer-0.2.5 → fastapi_observer-0.3.0}/tests/test_redaction_presets.py +0 -0
- {fastapi_observer-0.2.5 → fastapi_observer-0.3.0}/tests/test_route_template_cardinality.py +0 -0
- {fastapi_observer-0.2.5 → fastapi_observer-0.3.0}/tests/test_runtime_control_auth.py +0 -0
- {fastapi_observer-0.2.5 → fastapi_observer-0.3.0}/tests/test_security_redaction.py +0 -0
- {fastapi_observer-0.2.5 → fastapi_observer-0.3.0}/tests/test_sink_circuit_breaker.py +0 -0
- {fastapi_observer-0.2.5 → fastapi_observer-0.3.0}/tests/test_trust_boundary.py +0 -0
- {fastapi_observer-0.2.5 → 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.
|
|
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
|
|
|
@@ -818,6 +828,45 @@ Detailed migration/coexistence guide: [`loguru.md`](loguru.md)
|
|
|
818
828
|
|
|
819
829
|
---
|
|
820
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
|
+
|
|
821
870
|
## Plugin Hooks
|
|
822
871
|
|
|
823
872
|
Extend behavior without editing package internals:
|
|
@@ -914,7 +963,7 @@ Reproducible benchmark harness and methodology:
|
|
|
914
963
|
- `0.2.x`: OTel interoperability, security presets, allowlists
|
|
915
964
|
- `1.0.0`: dynamic runtime controls and plugin stability
|
|
916
965
|
|
|
917
|
-
Current release version: `0.
|
|
966
|
+
Current release version: `0.3.0`
|
|
918
967
|
|
|
919
968
|
## Changelog Policy
|
|
920
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
|
|
|
@@ -750,6 +759,45 @@ Detailed migration/coexistence guide: [`loguru.md`](loguru.md)
|
|
|
750
759
|
|
|
751
760
|
---
|
|
752
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
|
+
|
|
753
801
|
## Plugin Hooks
|
|
754
802
|
|
|
755
803
|
Extend behavior without editing package internals:
|
|
@@ -846,7 +894,7 @@ Reproducible benchmark harness and methodology:
|
|
|
846
894
|
- `0.2.x`: OTel interoperability, security presets, allowlists
|
|
847
895
|
- `1.0.0`: dynamic runtime controls and plugin stability
|
|
848
896
|
|
|
849
|
-
Current release version: `0.
|
|
897
|
+
Current release version: `0.3.0`
|
|
850
898
|
|
|
851
899
|
## Changelog Policy
|
|
852
900
|
|
|
@@ -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"
|
|
@@ -114,3 +115,4 @@ ignore_missing_imports = true
|
|
|
114
115
|
[tool.pytest.ini_options]
|
|
115
116
|
pythonpath = ["src"]
|
|
116
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.
|
|
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
|
|
|
@@ -818,6 +828,45 @@ Detailed migration/coexistence guide: [`loguru.md`](loguru.md)
|
|
|
818
828
|
|
|
819
829
|
---
|
|
820
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
|
+
|
|
821
870
|
## Plugin Hooks
|
|
822
871
|
|
|
823
872
|
Extend behavior without editing package internals:
|
|
@@ -914,7 +963,7 @@ Reproducible benchmark harness and methodology:
|
|
|
914
963
|
- `0.2.x`: OTel interoperability, security presets, allowlists
|
|
915
964
|
- `1.0.0`: dynamic runtime controls and plugin stability
|
|
916
965
|
|
|
917
|
-
Current release version: `0.
|
|
966
|
+
Current release version: `0.3.0`
|
|
918
967
|
|
|
919
968
|
## Changelog Policy
|
|
920
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
|
|
@@ -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
|
|
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,6 +1,9 @@
|
|
|
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
|
|
@@ -24,7 +27,7 @@ from .otel import (
|
|
|
24
27
|
from .security import SecurityPolicy, TrustedProxyPolicy
|
|
25
28
|
|
|
26
29
|
_LOGGER = logging.getLogger("fastapiobserver.fastapi")
|
|
27
|
-
|
|
30
|
+
_REGISTERED_APPS: weakref.WeakSet[FastAPI] = weakref.WeakSet()
|
|
28
31
|
|
|
29
32
|
|
|
30
33
|
def install_observability(
|
|
@@ -156,7 +159,19 @@ def _has_request_logging_middleware(app: FastAPI) -> bool:
|
|
|
156
159
|
|
|
157
160
|
|
|
158
161
|
def _register_logging_shutdown_hook(app: FastAPI) -> None:
|
|
159
|
-
if
|
|
162
|
+
if app in _REGISTERED_APPS:
|
|
160
163
|
return
|
|
161
|
-
|
|
162
|
-
|
|
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)
|
|
File without changes
|
|
@@ -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
|