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.
- {fastapi_observer-0.2.4/src/fastapi_observer.egg-info → fastapi_observer-0.3.0}/PKG-INFO +68 -4
- {fastapi_observer-0.2.4 → fastapi_observer-0.3.0}/README.md +66 -3
- {fastapi_observer-0.2.4 → fastapi_observer-0.3.0}/pyproject.toml +14 -1
- {fastapi_observer-0.2.4 → fastapi_observer-0.3.0/src/fastapi_observer.egg-info}/PKG-INFO +68 -4
- {fastapi_observer-0.2.4 → fastapi_observer-0.3.0}/src/fastapi_observer.egg-info/SOURCES.txt +5 -0
- {fastapi_observer-0.2.4 → fastapi_observer-0.3.0}/src/fastapiobserver/__init__.py +2 -0
- fastapi_observer-0.3.0/src/fastapiobserver/_version.py +1 -0
- {fastapi_observer-0.2.4 → fastapi_observer-0.3.0}/src/fastapiobserver/config.py +22 -0
- {fastapi_observer-0.2.4 → fastapi_observer-0.3.0}/src/fastapiobserver/control_plane.py +9 -4
- {fastapi_observer-0.2.4 → fastapi_observer-0.3.0}/src/fastapiobserver/fastapi.py +25 -1
- 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.4 → fastapi_observer-0.3.0}/src/fastapiobserver/logging.py +122 -16
- {fastapi_observer-0.2.4 → fastapi_observer-0.3.0}/src/fastapiobserver/metrics.py +26 -1
- {fastapi_observer-0.2.4 → fastapi_observer-0.3.0}/src/fastapiobserver/middleware.py +2 -2
- {fastapi_observer-0.2.4 → fastapi_observer-0.3.0}/src/fastapiobserver/otel/lifecycle.py +3 -5
- {fastapi_observer-0.2.4 → fastapi_observer-0.3.0}/src/fastapiobserver/otel/settings.py +5 -1
- {fastapi_observer-0.2.4 → fastapi_observer-0.3.0}/src/fastapiobserver/otel/tracing.py +5 -3
- {fastapi_observer-0.2.4 → fastapi_observer-0.3.0}/src/fastapiobserver/plugins.py +15 -0
- {fastapi_observer-0.2.4 → fastapi_observer-0.3.0}/src/fastapiobserver/request_context.py +15 -0
- {fastapi_observer-0.2.4 → fastapi_observer-0.3.0}/src/fastapiobserver/sinks.py +255 -20
- {fastapi_observer-0.2.4 → fastapi_observer-0.3.0}/tests/test_fastapi_install.py +26 -0
- fastapi_observer-0.3.0/tests/test_graphql_integrations.py +69 -0
- {fastapi_observer-0.2.4 → fastapi_observer-0.3.0}/tests/test_logging_schema_contract.py +74 -3
- 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.4/src/fastapiobserver/_version.py +0 -1
- {fastapi_observer-0.2.4 → fastapi_observer-0.3.0}/LICENSE +0 -0
- {fastapi_observer-0.2.4 → fastapi_observer-0.3.0}/setup.cfg +0 -0
- {fastapi_observer-0.2.4 → fastapi_observer-0.3.0}/src/fastapi_observer.egg-info/dependency_links.txt +0 -0
- {fastapi_observer-0.2.4 → fastapi_observer-0.3.0}/src/fastapi_observer.egg-info/requires.txt +0 -0
- {fastapi_observer-0.2.4 → fastapi_observer-0.3.0}/src/fastapi_observer.egg-info/top_level.txt +0 -0
- {fastapi_observer-0.2.4 → fastapi_observer-0.3.0}/src/fastapiobserver/loguru.py +0 -0
- {fastapi_observer-0.2.4 → fastapi_observer-0.3.0}/src/fastapiobserver/otel/__init__.py +0 -0
- {fastapi_observer-0.2.4 → fastapi_observer-0.3.0}/src/fastapiobserver/otel/logs.py +0 -0
- {fastapi_observer-0.2.4 → fastapi_observer-0.3.0}/src/fastapiobserver/otel/metrics.py +0 -0
- {fastapi_observer-0.2.4 → fastapi_observer-0.3.0}/src/fastapiobserver/otel/resource.py +0 -0
- {fastapi_observer-0.2.4 → fastapi_observer-0.3.0}/src/fastapiobserver/otel/types.py +0 -0
- {fastapi_observer-0.2.4 → fastapi_observer-0.3.0}/src/fastapiobserver/propagation.py +0 -0
- {fastapi_observer-0.2.4 → fastapi_observer-0.3.0}/src/fastapiobserver/py.typed +0 -0
- {fastapi_observer-0.2.4 → fastapi_observer-0.3.0}/src/fastapiobserver/security.py +0 -0
- {fastapi_observer-0.2.4 → fastapi_observer-0.3.0}/src/fastapiobserver/utils.py +0 -0
- {fastapi_observer-0.2.4 → fastapi_observer-0.3.0}/tests/test_body_media_allowlist.py +0 -0
- {fastapi_observer-0.2.4 → fastapi_observer-0.3.0}/tests/test_config_settings.py +0 -0
- {fastapi_observer-0.2.4 → fastapi_observer-0.3.0}/tests/test_excluded_url_precedence.py +0 -0
- {fastapi_observer-0.2.4 → fastapi_observer-0.3.0}/tests/test_log_queue_controls.py +0 -0
- {fastapi_observer-0.2.4 → fastapi_observer-0.3.0}/tests/test_loguru_bridge.py +0 -0
- {fastapi_observer-0.2.4 → fastapi_observer-0.3.0}/tests/test_metrics_optional_dependency.py +0 -0
- {fastapi_observer-0.2.4 → fastapi_observer-0.3.0}/tests/test_middleware.py +0 -0
- {fastapi_observer-0.2.4 → fastapi_observer-0.3.0}/tests/test_negotiate_content_type.py +0 -0
- {fastapi_observer-0.2.4 → fastapi_observer-0.3.0}/tests/test_otel_integration.py +0 -0
- {fastapi_observer-0.2.4 → fastapi_observer-0.3.0}/tests/test_otel_lifecycle.py +0 -0
- {fastapi_observer-0.2.4 → fastapi_observer-0.3.0}/tests/test_otel_log_correlation.py +0 -0
- {fastapi_observer-0.2.4 → fastapi_observer-0.3.0}/tests/test_otel_logs_from_env.py +0 -0
- {fastapi_observer-0.2.4 → fastapi_observer-0.3.0}/tests/test_otel_logs_pipeline.py +0 -0
- {fastapi_observer-0.2.4 → fastapi_observer-0.3.0}/tests/test_otel_metrics_from_env.py +0 -0
- {fastapi_observer-0.2.4 → fastapi_observer-0.3.0}/tests/test_otlp_export_integration.py +0 -0
- {fastapi_observer-0.2.4 → fastapi_observer-0.3.0}/tests/test_plugin_isolation.py +0 -0
- {fastapi_observer-0.2.4 → fastapi_observer-0.3.0}/tests/test_propagation.py +0 -0
- {fastapi_observer-0.2.4 → fastapi_observer-0.3.0}/tests/test_redaction_presets.py +0 -0
- {fastapi_observer-0.2.4 → fastapi_observer-0.3.0}/tests/test_route_template_cardinality.py +0 -0
- {fastapi_observer-0.2.4 → fastapi_observer-0.3.0}/tests/test_runtime_control_auth.py +0 -0
- {fastapi_observer-0.2.4 → fastapi_observer-0.3.0}/tests/test_security_redaction.py +0 -0
- {fastapi_observer-0.2.4 → fastapi_observer-0.3.0}/tests/test_sink_circuit_breaker.py +0 -0
- {fastapi_observer-0.2.4 → fastapi_observer-0.3.0}/tests/test_trust_boundary.py +0 -0
- {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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
|
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)
|
|
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
|