fastapi-observer 0.3.1__tar.gz → 0.3.2__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.3.1/src/fastapi_observer.egg-info → fastapi_observer-0.3.2}/PKG-INFO +79 -3
  2. {fastapi_observer-0.3.1 → fastapi_observer-0.3.2}/README.md +77 -2
  3. {fastapi_observer-0.3.1 → fastapi_observer-0.3.2}/pyproject.toml +1 -0
  4. {fastapi_observer-0.3.1 → fastapi_observer-0.3.2/src/fastapi_observer.egg-info}/PKG-INFO +79 -3
  5. {fastapi_observer-0.3.1 → fastapi_observer-0.3.2}/src/fastapi_observer.egg-info/requires.txt +1 -0
  6. fastapi_observer-0.3.2/src/fastapiobserver/_version.py +1 -0
  7. {fastapi_observer-0.3.1 → fastapi_observer-0.3.2}/src/fastapiobserver/middleware.py +23 -19
  8. {fastapi_observer-0.3.1 → fastapi_observer-0.3.2}/src/fastapiobserver/security.py +10 -2
  9. fastapi_observer-0.3.1/src/fastapiobserver/_version.py +0 -1
  10. {fastapi_observer-0.3.1 → fastapi_observer-0.3.2}/LICENSE +0 -0
  11. {fastapi_observer-0.3.1 → fastapi_observer-0.3.2}/setup.cfg +0 -0
  12. {fastapi_observer-0.3.1 → fastapi_observer-0.3.2}/src/fastapi_observer.egg-info/SOURCES.txt +0 -0
  13. {fastapi_observer-0.3.1 → fastapi_observer-0.3.2}/src/fastapi_observer.egg-info/dependency_links.txt +0 -0
  14. {fastapi_observer-0.3.1 → fastapi_observer-0.3.2}/src/fastapi_observer.egg-info/top_level.txt +0 -0
  15. {fastapi_observer-0.3.1 → fastapi_observer-0.3.2}/src/fastapiobserver/__init__.py +0 -0
  16. {fastapi_observer-0.3.1 → fastapi_observer-0.3.2}/src/fastapiobserver/config.py +0 -0
  17. {fastapi_observer-0.3.1 → fastapi_observer-0.3.2}/src/fastapiobserver/control_plane.py +0 -0
  18. {fastapi_observer-0.3.1 → fastapi_observer-0.3.2}/src/fastapiobserver/fastapi.py +0 -0
  19. {fastapi_observer-0.3.1 → fastapi_observer-0.3.2}/src/fastapiobserver/integrations/__init__.py +0 -0
  20. {fastapi_observer-0.3.1 → fastapi_observer-0.3.2}/src/fastapiobserver/integrations/strawberry.py +0 -0
  21. {fastapi_observer-0.3.1 → fastapi_observer-0.3.2}/src/fastapiobserver/logging.py +0 -0
  22. {fastapi_observer-0.3.1 → fastapi_observer-0.3.2}/src/fastapiobserver/loguru.py +0 -0
  23. {fastapi_observer-0.3.1 → fastapi_observer-0.3.2}/src/fastapiobserver/metrics.py +0 -0
  24. {fastapi_observer-0.3.1 → fastapi_observer-0.3.2}/src/fastapiobserver/otel/__init__.py +0 -0
  25. {fastapi_observer-0.3.1 → fastapi_observer-0.3.2}/src/fastapiobserver/otel/lifecycle.py +0 -0
  26. {fastapi_observer-0.3.1 → fastapi_observer-0.3.2}/src/fastapiobserver/otel/logs.py +0 -0
  27. {fastapi_observer-0.3.1 → fastapi_observer-0.3.2}/src/fastapiobserver/otel/metrics.py +0 -0
  28. {fastapi_observer-0.3.1 → fastapi_observer-0.3.2}/src/fastapiobserver/otel/resource.py +0 -0
  29. {fastapi_observer-0.3.1 → fastapi_observer-0.3.2}/src/fastapiobserver/otel/settings.py +0 -0
  30. {fastapi_observer-0.3.1 → fastapi_observer-0.3.2}/src/fastapiobserver/otel/tracing.py +0 -0
  31. {fastapi_observer-0.3.1 → fastapi_observer-0.3.2}/src/fastapiobserver/otel/types.py +0 -0
  32. {fastapi_observer-0.3.1 → fastapi_observer-0.3.2}/src/fastapiobserver/plugins.py +0 -0
  33. {fastapi_observer-0.3.1 → fastapi_observer-0.3.2}/src/fastapiobserver/propagation.py +0 -0
  34. {fastapi_observer-0.3.1 → fastapi_observer-0.3.2}/src/fastapiobserver/py.typed +0 -0
  35. {fastapi_observer-0.3.1 → fastapi_observer-0.3.2}/src/fastapiobserver/request_context.py +0 -0
  36. {fastapi_observer-0.3.1 → fastapi_observer-0.3.2}/src/fastapiobserver/sinks.py +0 -0
  37. {fastapi_observer-0.3.1 → fastapi_observer-0.3.2}/src/fastapiobserver/utils.py +0 -0
  38. {fastapi_observer-0.3.1 → fastapi_observer-0.3.2}/tests/test_body_media_allowlist.py +0 -0
  39. {fastapi_observer-0.3.1 → fastapi_observer-0.3.2}/tests/test_config_settings.py +0 -0
  40. {fastapi_observer-0.3.1 → fastapi_observer-0.3.2}/tests/test_excluded_url_precedence.py +0 -0
  41. {fastapi_observer-0.3.1 → fastapi_observer-0.3.2}/tests/test_fastapi_install.py +0 -0
  42. {fastapi_observer-0.3.1 → fastapi_observer-0.3.2}/tests/test_graphql_integrations.py +0 -0
  43. {fastapi_observer-0.3.1 → fastapi_observer-0.3.2}/tests/test_log_queue_controls.py +0 -0
  44. {fastapi_observer-0.3.1 → fastapi_observer-0.3.2}/tests/test_logging_schema_contract.py +0 -0
  45. {fastapi_observer-0.3.1 → fastapi_observer-0.3.2}/tests/test_logtail_dlq.py +0 -0
  46. {fastapi_observer-0.3.1 → fastapi_observer-0.3.2}/tests/test_loguru_bridge.py +0 -0
  47. {fastapi_observer-0.3.1 → fastapi_observer-0.3.2}/tests/test_metrics_optional_dependency.py +0 -0
  48. {fastapi_observer-0.3.1 → fastapi_observer-0.3.2}/tests/test_middleware.py +0 -0
  49. {fastapi_observer-0.3.1 → fastapi_observer-0.3.2}/tests/test_negotiate_content_type.py +0 -0
  50. {fastapi_observer-0.3.1 → fastapi_observer-0.3.2}/tests/test_otel_integration.py +0 -0
  51. {fastapi_observer-0.3.1 → fastapi_observer-0.3.2}/tests/test_otel_lifecycle.py +0 -0
  52. {fastapi_observer-0.3.1 → fastapi_observer-0.3.2}/tests/test_otel_log_correlation.py +0 -0
  53. {fastapi_observer-0.3.1 → fastapi_observer-0.3.2}/tests/test_otel_logs_from_env.py +0 -0
  54. {fastapi_observer-0.3.1 → fastapi_observer-0.3.2}/tests/test_otel_logs_pipeline.py +0 -0
  55. {fastapi_observer-0.3.1 → fastapi_observer-0.3.2}/tests/test_otel_metrics_from_env.py +0 -0
  56. {fastapi_observer-0.3.1 → fastapi_observer-0.3.2}/tests/test_otlp_export_integration.py +0 -0
  57. {fastapi_observer-0.3.1 → fastapi_observer-0.3.2}/tests/test_plugin_isolation.py +0 -0
  58. {fastapi_observer-0.3.1 → fastapi_observer-0.3.2}/tests/test_propagation.py +0 -0
  59. {fastapi_observer-0.3.1 → fastapi_observer-0.3.2}/tests/test_redaction_presets.py +0 -0
  60. {fastapi_observer-0.3.1 → fastapi_observer-0.3.2}/tests/test_route_template_cardinality.py +0 -0
  61. {fastapi_observer-0.3.1 → fastapi_observer-0.3.2}/tests/test_runtime_control_auth.py +0 -0
  62. {fastapi_observer-0.3.1 → fastapi_observer-0.3.2}/tests/test_security_redaction.py +0 -0
  63. {fastapi_observer-0.3.1 → fastapi_observer-0.3.2}/tests/test_sink_circuit_breaker.py +0 -0
  64. {fastapi_observer-0.3.1 → fastapi_observer-0.3.2}/tests/test_sinks.py +0 -0
  65. {fastapi_observer-0.3.1 → fastapi_observer-0.3.2}/tests/test_trust_boundary.py +0 -0
  66. {fastapi_observer-0.3.1 → fastapi_observer-0.3.2}/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.1
3
+ Version: 0.3.2
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
@@ -65,6 +65,7 @@ Requires-Dist: pytest>=9.0.2; extra == "dev"
65
65
  Requires-Dist: pytest-randomly>=3.16.0; extra == "dev"
66
66
  Requires-Dist: ruff>=0.15.1; extra == "dev"
67
67
  Requires-Dist: cyclonedx-bom>=7.2.1; extra == "dev"
68
+ Requires-Dist: uvicorn>=0.30.0; extra == "dev"
68
69
  Dynamic: license-file
69
70
 
70
71
  # fastapi-observer
@@ -570,11 +571,57 @@ kubectl -n observability rollout status deployment/traffic-generator
570
571
  kubectl -n observability port-forward svc/grafana 3000:3000
571
572
  ```
572
573
 
573
- Open [http://localhost:3000](http://localhost:3000).
574
+ Open [http://localhost:3000](http://localhost:3000).
574
575
  Full guide: [`kubernetes.md`](kubernetes.md)
575
576
 
576
577
  ---
577
578
 
579
+ ## Low-Overhead & Production Tuning (Advanced)
580
+
581
+ `fastapi-observer` integrates natively with the core OpenTelemetry Python SDK, meaning you can aggressively tune its resource usage purely via standard environment variables without altering your application code.
582
+
583
+ For high-throughput services (e.g. `10k+ RPS`), apply these exact variables to minimize the observer footprint:
584
+
585
+ ### 1. Head-Based Sampling
586
+
587
+ Tracing 100% of requests is too expensive at scale. You should configure `fastapi-observer` to respect upstream trace flags, while only sampling a fraction of net-new requests:
588
+
589
+ ```bash
590
+ # Keep the parent's sample decision if it exists, otherwise sample 5%
591
+ export OTEL_TRACES_SAMPLER="parentbased_traceidratio"
592
+ export OTEL_TRACES_SAMPLER_ARG="0.05"
593
+ ```
594
+
595
+ ### 2. Exclude Noisy URLs from the SDK
596
+
597
+ Do not waste cycles generating spans for health checks or static assets. `fastapi-observer` will auto-derive metrics exclusions, but you can explicitly drop them from tracing at the C-extension level:
598
+
599
+ ```bash
600
+ export OTEL_PYTHON_FASTAPI_EXCLUDED_URLS="healthz,metrics,favicon.ico"
601
+ ```
602
+
603
+ ### 3. Cap Span Attributes
604
+
605
+ Prevent large, unmanageable spans from consuming excessive memory in the `BatchSpanProcessor`:
606
+
607
+ ```bash
608
+ export OTEL_SPAN_ATTRIBUTE_COUNT_LIMIT="128"
609
+ export OTEL_SPAN_EVENT_COUNT_LIMIT="128"
610
+ export OTEL_SPAN_LINK_COUNT_LIMIT="128"
611
+ ```
612
+
613
+ ### 4. Optimize Output Buffers
614
+
615
+ The default OpenTelemetry batch limits are too conservative for high-throughput ASGI microservices. Increase the max queue limits so spikes aren't dropped, but decrease the timeout so the process memory is flushed faster:
616
+
617
+ ```bash
618
+ export OTEL_BSP_MAX_QUEUE_SIZE="10000"
619
+ export OTEL_BSP_MAX_EXPORT_BATCH_SIZE="5000"
620
+ export OTEL_BSP_SCHEDULE_DELAY="1000"
621
+ ```
622
+
623
+ ---
624
+
578
625
  ## Examples
579
626
 
580
627
  The `examples/` directory contains runnable demos:
@@ -729,7 +776,36 @@ install_observability(app, settings, security_policy=SecurityPolicy(log_request_
729
776
  app.add_middleware(CORSMiddleware, allow_origins=["*"])
730
777
  ```
731
778
 
732
- ### Multi-worker Gunicorn with Prometheus
779
+ ### Multi-worker Gunicorn
780
+
781
+ > [!WARNING]
782
+ > `--preload-app` is dangerous if visibility is initialized at the module level.
783
+ >
784
+ > When `--preload-app` is used, the application is loaded into the master process *before* forking the worker processes. `fastapi-observer` uses a highly-performant background thread (`QueueListener`) for non-blocking logging, and OpenTelemetry similarly spawns background export threads.
785
+ >
786
+ > Threads **do not survive** a process fork. If you call `install_observability()` at the module level (e.g., right under `app = FastAPI()`), the background threads will be created in the master process, and all workers will silently drop logs because their logging threads never started.
787
+ >
788
+ > **How to fix:** Either remove `--preload-app`, OR initialize observability inside the FastAPI `lifespan` context manager so it starts safely after the fork inside each worker:
789
+ >
790
+ > ```python
791
+ > from contextlib import asynccontextmanager
792
+ > from fastapi import FastAPI
793
+ > from fastapiobserver import ObservabilitySettings, install_observability
794
+ >
795
+ > settings = ObservabilitySettings(service="api")
796
+ >
797
+ > @asynccontextmanager
798
+ > async def lifespan(app: FastAPI):
799
+ > install_observability(app, settings) # Safely initializes per-worker
800
+ > yield
801
+ > # fastapiobserver automatically registers shutdown hooks so no teardown needed here
802
+ >
803
+ > app = FastAPI(lifespan=lifespan)
804
+ > ```
805
+
806
+ #### Prometheus Multiprocess Mode
807
+
808
+ If you are using Prometheus with multiple Gunicorn workers, you must configure a shared metrics directory:
733
809
 
734
810
  ```bash
735
811
  export PROMETHEUS_MULTIPROC_DIR=/tmp/prometheus-metrics
@@ -501,11 +501,57 @@ kubectl -n observability rollout status deployment/traffic-generator
501
501
  kubectl -n observability port-forward svc/grafana 3000:3000
502
502
  ```
503
503
 
504
- Open [http://localhost:3000](http://localhost:3000).
504
+ Open [http://localhost:3000](http://localhost:3000).
505
505
  Full guide: [`kubernetes.md`](kubernetes.md)
506
506
 
507
507
  ---
508
508
 
509
+ ## Low-Overhead & Production Tuning (Advanced)
510
+
511
+ `fastapi-observer` integrates natively with the core OpenTelemetry Python SDK, meaning you can aggressively tune its resource usage purely via standard environment variables without altering your application code.
512
+
513
+ For high-throughput services (e.g. `10k+ RPS`), apply these exact variables to minimize the observer footprint:
514
+
515
+ ### 1. Head-Based Sampling
516
+
517
+ Tracing 100% of requests is too expensive at scale. You should configure `fastapi-observer` to respect upstream trace flags, while only sampling a fraction of net-new requests:
518
+
519
+ ```bash
520
+ # Keep the parent's sample decision if it exists, otherwise sample 5%
521
+ export OTEL_TRACES_SAMPLER="parentbased_traceidratio"
522
+ export OTEL_TRACES_SAMPLER_ARG="0.05"
523
+ ```
524
+
525
+ ### 2. Exclude Noisy URLs from the SDK
526
+
527
+ Do not waste cycles generating spans for health checks or static assets. `fastapi-observer` will auto-derive metrics exclusions, but you can explicitly drop them from tracing at the C-extension level:
528
+
529
+ ```bash
530
+ export OTEL_PYTHON_FASTAPI_EXCLUDED_URLS="healthz,metrics,favicon.ico"
531
+ ```
532
+
533
+ ### 3. Cap Span Attributes
534
+
535
+ Prevent large, unmanageable spans from consuming excessive memory in the `BatchSpanProcessor`:
536
+
537
+ ```bash
538
+ export OTEL_SPAN_ATTRIBUTE_COUNT_LIMIT="128"
539
+ export OTEL_SPAN_EVENT_COUNT_LIMIT="128"
540
+ export OTEL_SPAN_LINK_COUNT_LIMIT="128"
541
+ ```
542
+
543
+ ### 4. Optimize Output Buffers
544
+
545
+ The default OpenTelemetry batch limits are too conservative for high-throughput ASGI microservices. Increase the max queue limits so spikes aren't dropped, but decrease the timeout so the process memory is flushed faster:
546
+
547
+ ```bash
548
+ export OTEL_BSP_MAX_QUEUE_SIZE="10000"
549
+ export OTEL_BSP_MAX_EXPORT_BATCH_SIZE="5000"
550
+ export OTEL_BSP_SCHEDULE_DELAY="1000"
551
+ ```
552
+
553
+ ---
554
+
509
555
  ## Examples
510
556
 
511
557
  The `examples/` directory contains runnable demos:
@@ -660,7 +706,36 @@ install_observability(app, settings, security_policy=SecurityPolicy(log_request_
660
706
  app.add_middleware(CORSMiddleware, allow_origins=["*"])
661
707
  ```
662
708
 
663
- ### Multi-worker Gunicorn with Prometheus
709
+ ### Multi-worker Gunicorn
710
+
711
+ > [!WARNING]
712
+ > `--preload-app` is dangerous if visibility is initialized at the module level.
713
+ >
714
+ > When `--preload-app` is used, the application is loaded into the master process *before* forking the worker processes. `fastapi-observer` uses a highly-performant background thread (`QueueListener`) for non-blocking logging, and OpenTelemetry similarly spawns background export threads.
715
+ >
716
+ > Threads **do not survive** a process fork. If you call `install_observability()` at the module level (e.g., right under `app = FastAPI()`), the background threads will be created in the master process, and all workers will silently drop logs because their logging threads never started.
717
+ >
718
+ > **How to fix:** Either remove `--preload-app`, OR initialize observability inside the FastAPI `lifespan` context manager so it starts safely after the fork inside each worker:
719
+ >
720
+ > ```python
721
+ > from contextlib import asynccontextmanager
722
+ > from fastapi import FastAPI
723
+ > from fastapiobserver import ObservabilitySettings, install_observability
724
+ >
725
+ > settings = ObservabilitySettings(service="api")
726
+ >
727
+ > @asynccontextmanager
728
+ > async def lifespan(app: FastAPI):
729
+ > install_observability(app, settings) # Safely initializes per-worker
730
+ > yield
731
+ > # fastapiobserver automatically registers shutdown hooks so no teardown needed here
732
+ >
733
+ > app = FastAPI(lifespan=lifespan)
734
+ > ```
735
+
736
+ #### Prometheus Multiprocess Mode
737
+
738
+ If you are using Prometheus with multiple Gunicorn workers, you must configure a shared metrics directory:
664
739
 
665
740
  ```bash
666
741
  export PROMETHEUS_MULTIPROC_DIR=/tmp/prometheus-metrics
@@ -86,6 +86,7 @@ dev = [
86
86
  "pytest-randomly>=3.16.0",
87
87
  "ruff>=0.15.1",
88
88
  "cyclonedx-bom>=7.2.1",
89
+ "uvicorn>=0.30.0",
89
90
  ]
90
91
 
91
92
  [tool.setuptools]
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fastapi-observer
3
- Version: 0.3.1
3
+ Version: 0.3.2
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
@@ -65,6 +65,7 @@ Requires-Dist: pytest>=9.0.2; extra == "dev"
65
65
  Requires-Dist: pytest-randomly>=3.16.0; extra == "dev"
66
66
  Requires-Dist: ruff>=0.15.1; extra == "dev"
67
67
  Requires-Dist: cyclonedx-bom>=7.2.1; extra == "dev"
68
+ Requires-Dist: uvicorn>=0.30.0; extra == "dev"
68
69
  Dynamic: license-file
69
70
 
70
71
  # fastapi-observer
@@ -570,11 +571,57 @@ kubectl -n observability rollout status deployment/traffic-generator
570
571
  kubectl -n observability port-forward svc/grafana 3000:3000
571
572
  ```
572
573
 
573
- Open [http://localhost:3000](http://localhost:3000).
574
+ Open [http://localhost:3000](http://localhost:3000).
574
575
  Full guide: [`kubernetes.md`](kubernetes.md)
575
576
 
576
577
  ---
577
578
 
579
+ ## Low-Overhead & Production Tuning (Advanced)
580
+
581
+ `fastapi-observer` integrates natively with the core OpenTelemetry Python SDK, meaning you can aggressively tune its resource usage purely via standard environment variables without altering your application code.
582
+
583
+ For high-throughput services (e.g. `10k+ RPS`), apply these exact variables to minimize the observer footprint:
584
+
585
+ ### 1. Head-Based Sampling
586
+
587
+ Tracing 100% of requests is too expensive at scale. You should configure `fastapi-observer` to respect upstream trace flags, while only sampling a fraction of net-new requests:
588
+
589
+ ```bash
590
+ # Keep the parent's sample decision if it exists, otherwise sample 5%
591
+ export OTEL_TRACES_SAMPLER="parentbased_traceidratio"
592
+ export OTEL_TRACES_SAMPLER_ARG="0.05"
593
+ ```
594
+
595
+ ### 2. Exclude Noisy URLs from the SDK
596
+
597
+ Do not waste cycles generating spans for health checks or static assets. `fastapi-observer` will auto-derive metrics exclusions, but you can explicitly drop them from tracing at the C-extension level:
598
+
599
+ ```bash
600
+ export OTEL_PYTHON_FASTAPI_EXCLUDED_URLS="healthz,metrics,favicon.ico"
601
+ ```
602
+
603
+ ### 3. Cap Span Attributes
604
+
605
+ Prevent large, unmanageable spans from consuming excessive memory in the `BatchSpanProcessor`:
606
+
607
+ ```bash
608
+ export OTEL_SPAN_ATTRIBUTE_COUNT_LIMIT="128"
609
+ export OTEL_SPAN_EVENT_COUNT_LIMIT="128"
610
+ export OTEL_SPAN_LINK_COUNT_LIMIT="128"
611
+ ```
612
+
613
+ ### 4. Optimize Output Buffers
614
+
615
+ The default OpenTelemetry batch limits are too conservative for high-throughput ASGI microservices. Increase the max queue limits so spikes aren't dropped, but decrease the timeout so the process memory is flushed faster:
616
+
617
+ ```bash
618
+ export OTEL_BSP_MAX_QUEUE_SIZE="10000"
619
+ export OTEL_BSP_MAX_EXPORT_BATCH_SIZE="5000"
620
+ export OTEL_BSP_SCHEDULE_DELAY="1000"
621
+ ```
622
+
623
+ ---
624
+
578
625
  ## Examples
579
626
 
580
627
  The `examples/` directory contains runnable demos:
@@ -729,7 +776,36 @@ install_observability(app, settings, security_policy=SecurityPolicy(log_request_
729
776
  app.add_middleware(CORSMiddleware, allow_origins=["*"])
730
777
  ```
731
778
 
732
- ### Multi-worker Gunicorn with Prometheus
779
+ ### Multi-worker Gunicorn
780
+
781
+ > [!WARNING]
782
+ > `--preload-app` is dangerous if visibility is initialized at the module level.
783
+ >
784
+ > When `--preload-app` is used, the application is loaded into the master process *before* forking the worker processes. `fastapi-observer` uses a highly-performant background thread (`QueueListener`) for non-blocking logging, and OpenTelemetry similarly spawns background export threads.
785
+ >
786
+ > Threads **do not survive** a process fork. If you call `install_observability()` at the module level (e.g., right under `app = FastAPI()`), the background threads will be created in the master process, and all workers will silently drop logs because their logging threads never started.
787
+ >
788
+ > **How to fix:** Either remove `--preload-app`, OR initialize observability inside the FastAPI `lifespan` context manager so it starts safely after the fork inside each worker:
789
+ >
790
+ > ```python
791
+ > from contextlib import asynccontextmanager
792
+ > from fastapi import FastAPI
793
+ > from fastapiobserver import ObservabilitySettings, install_observability
794
+ >
795
+ > settings = ObservabilitySettings(service="api")
796
+ >
797
+ > @asynccontextmanager
798
+ > async def lifespan(app: FastAPI):
799
+ > install_observability(app, settings) # Safely initializes per-worker
800
+ > yield
801
+ > # fastapiobserver automatically registers shutdown hooks so no teardown needed here
802
+ >
803
+ > app = FastAPI(lifespan=lifespan)
804
+ > ```
805
+
806
+ #### Prometheus Multiprocess Mode
807
+
808
+ If you are using Prometheus with multiple Gunicorn workers, you must configure a shared metrics directory:
733
809
 
734
810
  ```bash
735
811
  export PROMETHEUS_MULTIPROC_DIR=/tmp/prometheus-metrics
@@ -23,6 +23,7 @@ pytest>=9.0.2
23
23
  pytest-randomly>=3.16.0
24
24
  ruff>=0.15.1
25
25
  cyclonedx-bom>=7.2.1
26
+ uvicorn>=0.30.0
26
27
 
27
28
  [fast-json]
28
29
  orjson>=3.11.7
@@ -0,0 +1 @@
1
+ __version__ = "0.3.2"
@@ -70,15 +70,15 @@ class RequestLoggingMiddleware:
70
70
  return
71
71
 
72
72
  start = time.perf_counter()
73
- headers = _decode_headers(scope.get("headers", []))
74
- client_ip, trusted_source = self.ip_resolver.resolve(scope, headers)
75
- request_id = self.context_manager.initialize(headers, trusted_source)
73
+ raw_headers = scope.get("headers", [])
74
+ client_ip, trusted_source = self.ip_resolver.resolve(scope, raw_headers)
75
+ request_id = self.context_manager.initialize(raw_headers, trusted_source)
76
76
 
77
77
  path = scope.get("path", "")
78
78
  method = scope.get("method", "UNKNOWN")
79
79
  status_code = 500
80
80
  captured_error: Exception | None = None
81
- request_content_type = headers.get("content-type")
81
+ request_content_type = _get_header(raw_headers, b"content-type")
82
82
  request_body_enabled = self.security_policy.log_request_body and is_body_capturable(
83
83
  request_content_type,
84
84
  self.security_policy,
@@ -102,17 +102,17 @@ class RequestLoggingMiddleware:
102
102
  nonlocal status_code
103
103
  if message["type"] == "http.response.start":
104
104
  status_code = int(message.get("status", 500))
105
- response_headers = _decode_headers(message.get("headers", []))
105
+ resp_headers = message.get("headers", [])
106
106
  if (
107
107
  self.security_policy.log_response_body
108
108
  and self.security_policy.body_capture_media_types is not None
109
109
  ):
110
- response_content_type = response_headers.get("content-type")
110
+ response_content_type = _get_header(resp_headers, b"content-type")
111
111
  response_body_capture.set_enabled(
112
112
  is_body_capturable(response_content_type, self.security_policy)
113
113
  )
114
114
  updated_headers = _upsert_header(
115
- message.get("headers", []),
115
+ resp_headers,
116
116
  self.settings.response_request_id_header,
117
117
  request_id,
118
118
  )
@@ -206,7 +206,11 @@ class _IpResolver:
206
206
  def __init__(self, policy: TrustedProxyPolicy) -> None:
207
207
  self.policy = policy
208
208
 
209
- def resolve(self, scope: Scope, headers: dict[str, str]) -> tuple[str | None, bool]:
209
+ def resolve(
210
+ self,
211
+ scope: Scope,
212
+ headers: list[tuple[bytes, bytes]],
213
+ ) -> tuple[str | None, bool]:
210
214
  scope_client_ip = _extract_scope_client_ip(scope)
211
215
  trusted_source = (
212
216
  is_trusted_client_ip(scope_client_ip, self.policy)
@@ -220,16 +224,16 @@ class _IpResolver:
220
224
  class _RequestContextManager:
221
225
  def __init__(self, settings: ObservabilitySettings) -> None:
222
226
  self.settings = settings
227
+ self._request_id_header_bytes = settings.request_id_header.lower().encode("latin1")
223
228
 
224
- def initialize(self, headers: dict[str, str], trusted_source: bool) -> str:
225
- request_id = _resolve_request_id(
226
- headers.get(self.settings.request_id_header),
227
- trusted_source,
228
- )
229
+ def initialize(self, headers: list[tuple[bytes, bytes]], trusted_source: bool) -> str:
230
+ request_id_header = _get_header(headers, self._request_id_header_bytes)
231
+ request_id = _resolve_request_id(request_id_header, trusted_source)
229
232
  set_request_id(request_id)
230
233
 
231
234
  if trusted_source:
232
- trace_id, span_id = _parse_traceparent(headers.get("traceparent"))
235
+ traceparent = _get_header(headers, b"traceparent")
236
+ trace_id, span_id = _parse_traceparent(traceparent)
233
237
  if trace_id:
234
238
  set_trace_id(trace_id)
235
239
  if span_id:
@@ -400,11 +404,11 @@ def _upsert_header(
400
404
  return next_headers
401
405
 
402
406
 
403
- def _decode_headers(headers: list[tuple[bytes, bytes]]) -> dict[str, str]:
404
- return {
405
- key.decode("latin1").lower(): value.decode("latin1")
406
- for key, value in headers
407
- }
407
+ def _get_header(headers: list[tuple[bytes, bytes]], target: bytes) -> str | None:
408
+ for k, v in headers:
409
+ if k.lower() == target:
410
+ return v.decode("latin1")
411
+ return None
408
412
 
409
413
 
410
414
  def _classify_error(status_code: int, error: Exception | None) -> str:
@@ -1,5 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import functools
3
4
  import hashlib
4
5
  import ipaddress
5
6
  import os
@@ -464,6 +465,7 @@ def is_body_capturable(content_type: str | None, policy: SecurityPolicy) -> bool
464
465
  return False
465
466
 
466
467
 
468
+ @functools.lru_cache(maxsize=1024)
467
469
  def is_trusted_client_ip(client_ip: str | None, policy: TrustedProxyPolicy) -> bool:
468
470
  if not policy.enabled:
469
471
  return True
@@ -486,14 +488,20 @@ def is_trusted_client_ip(client_ip: str | None, policy: TrustedProxyPolicy) -> b
486
488
 
487
489
  def resolve_client_ip(
488
490
  client_ip: str | None,
489
- headers: Mapping[str, str],
491
+ headers: list[tuple[bytes, bytes]],
490
492
  policy: TrustedProxyPolicy,
491
493
  ) -> str | None:
492
494
  if not policy.honor_forwarded_headers:
493
495
  return client_ip
494
496
  if not is_trusted_client_ip(client_ip, policy):
495
497
  return client_ip
496
- forwarded_for = headers.get("x-forwarded-for")
498
+
499
+ forwarded_for = None
500
+ for k, v in headers:
501
+ if k.lower() == b"x-forwarded-for":
502
+ forwarded_for = v.decode("latin1")
503
+ break
504
+
497
505
  if not forwarded_for:
498
506
  return client_ip
499
507
  first_hop = forwarded_for.split(",")[0].strip()
@@ -1 +0,0 @@
1
- __version__ = "0.3.1"