fastapi-observer 1.6.0__tar.gz → 1.7.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (128) hide show
  1. {fastapi_observer-1.6.0/src/fastapi_observer.egg-info → fastapi_observer-1.7.0}/PKG-INFO +1 -1
  2. {fastapi_observer-1.6.0 → fastapi_observer-1.7.0/src/fastapi_observer.egg-info}/PKG-INFO +1 -1
  3. {fastapi_observer-1.6.0 → fastapi_observer-1.7.0}/src/fastapi_observer.egg-info/SOURCES.txt +1 -14
  4. fastapi_observer-1.7.0/src/fastapiobserver/_version.py +1 -0
  5. {fastapi_observer-1.6.0 → fastapi_observer-1.7.0}/src/fastapiobserver/db_tracing.py +35 -23
  6. {fastapi_observer-1.6.0 → fastapi_observer-1.7.0}/src/fastapiobserver/fastapi.py +3 -1
  7. {fastapi_observer-1.6.0 → fastapi_observer-1.7.0}/src/fastapiobserver/logging/circuit_breaker.py +16 -1
  8. fastapi_observer-1.7.0/src/fastapiobserver/metrics/__init__.py +144 -0
  9. fastapi_observer-1.7.0/src/fastapiobserver/metrics/prometheus.py +325 -0
  10. {fastapi_observer-1.6.0 → fastapi_observer-1.7.0}/src/fastapiobserver/middleware/__init__.py +0 -2
  11. {fastapi_observer-1.6.0 → fastapi_observer-1.7.0}/src/fastapiobserver/middleware/request_logging.py +0 -3
  12. {fastapi_observer-1.6.0 → fastapi_observer-1.7.0}/src/fastapiobserver/otel/__init__.py +0 -19
  13. fastapi_observer-1.7.0/src/fastapiobserver/otel/exclusions.py +29 -0
  14. fastapi_observer-1.7.0/src/fastapiobserver/otel/lifecycle.py +57 -0
  15. {fastapi_observer-1.6.0 → fastapi_observer-1.7.0}/src/fastapiobserver/otel/logs.py +45 -107
  16. {fastapi_observer-1.6.0 → fastapi_observer-1.7.0}/src/fastapiobserver/otel/metrics.py +10 -31
  17. {fastapi_observer-1.6.0 → fastapi_observer-1.7.0}/src/fastapiobserver/otel/resource.py +27 -111
  18. {fastapi_observer-1.6.0 → fastapi_observer-1.7.0}/src/fastapiobserver/otel/tracing.py +15 -41
  19. {fastapi_observer-1.6.0 → fastapi_observer-1.7.0}/tests/test_db_tracing.py +40 -5
  20. {fastapi_observer-1.6.0 → fastapi_observer-1.7.0}/tests/test_excluded_url_precedence.py +8 -8
  21. fastapi_observer-1.7.0/tests/test_metrics_contracts.py +102 -0
  22. {fastapi_observer-1.6.0 → fastapi_observer-1.7.0}/tests/test_metrics_optional_dependency.py +9 -21
  23. {fastapi_observer-1.6.0 → fastapi_observer-1.7.0}/tests/test_middleware.py +16 -24
  24. {fastapi_observer-1.6.0 → fastapi_observer-1.7.0}/tests/test_negotiate_content_type.py +41 -13
  25. {fastapi_observer-1.6.0 → fastapi_observer-1.7.0}/tests/test_otel_integration.py +81 -6
  26. fastapi_observer-1.7.0/tests/test_otel_lifecycle.py +67 -0
  27. {fastapi_observer-1.6.0 → fastapi_observer-1.7.0}/tests/test_otel_log_correlation.py +1 -2
  28. fastapi_observer-1.7.0/tests/test_otel_logs_pipeline.py +144 -0
  29. {fastapi_observer-1.6.0 → fastapi_observer-1.7.0}/tests/test_otlp_export_integration.py +0 -2
  30. {fastapi_observer-1.6.0 → fastapi_observer-1.7.0}/tests/test_sink_circuit_breaker.py +28 -0
  31. fastapi_observer-1.6.0/src/fastapiobserver/_version.py +0 -1
  32. fastapi_observer-1.6.0/src/fastapiobserver/metrics/__init__.py +0 -41
  33. fastapi_observer-1.6.0/src/fastapiobserver/metrics/builder.py +0 -48
  34. fastapi_observer-1.6.0/src/fastapiobserver/metrics/contracts.py +0 -39
  35. fastapi_observer-1.6.0/src/fastapiobserver/metrics/endpoint.py +0 -180
  36. fastapi_observer-1.6.0/src/fastapiobserver/metrics/noop.py +0 -15
  37. fastapi_observer-1.6.0/src/fastapiobserver/metrics/pathing.py +0 -18
  38. fastapi_observer-1.6.0/src/fastapiobserver/metrics/prometheus/__init__.py +0 -21
  39. fastapi_observer-1.6.0/src/fastapiobserver/metrics/prometheus/backend.py +0 -126
  40. fastapi_observer-1.6.0/src/fastapiobserver/metrics/prometheus/client.py +0 -19
  41. fastapi_observer-1.6.0/src/fastapiobserver/metrics/prometheus/collector.py +0 -170
  42. fastapi_observer-1.6.0/src/fastapiobserver/metrics/prometheus/exemplars.py +0 -19
  43. fastapi_observer-1.6.0/src/fastapiobserver/metrics/prometheus/multiprocess.py +0 -68
  44. fastapi_observer-1.6.0/src/fastapiobserver/metrics/registry.py +0 -42
  45. fastapi_observer-1.6.0/src/fastapiobserver/middleware/span_errors.py +0 -34
  46. fastapi_observer-1.6.0/src/fastapiobserver/otel/exclusions.py +0 -45
  47. fastapi_observer-1.6.0/src/fastapiobserver/otel/lifecycle.py +0 -159
  48. fastapi_observer-1.6.0/src/fastapiobserver/otel/types.py +0 -49
  49. fastapi_observer-1.6.0/tests/test_metrics_contracts.py +0 -182
  50. fastapi_observer-1.6.0/tests/test_otel_lifecycle.py +0 -98
  51. fastapi_observer-1.6.0/tests/test_otel_logs_pipeline.py +0 -73
  52. {fastapi_observer-1.6.0 → fastapi_observer-1.7.0}/LICENSE +0 -0
  53. {fastapi_observer-1.6.0 → fastapi_observer-1.7.0}/README.md +0 -0
  54. {fastapi_observer-1.6.0 → fastapi_observer-1.7.0}/pyproject.toml +0 -0
  55. {fastapi_observer-1.6.0 → fastapi_observer-1.7.0}/setup.cfg +0 -0
  56. {fastapi_observer-1.6.0 → fastapi_observer-1.7.0}/src/fastapi_observer.egg-info/dependency_links.txt +0 -0
  57. {fastapi_observer-1.6.0 → fastapi_observer-1.7.0}/src/fastapi_observer.egg-info/requires.txt +0 -0
  58. {fastapi_observer-1.6.0 → fastapi_observer-1.7.0}/src/fastapi_observer.egg-info/top_level.txt +0 -0
  59. {fastapi_observer-1.6.0 → fastapi_observer-1.7.0}/src/fastapiobserver/__init__.py +0 -0
  60. {fastapi_observer-1.6.0 → fastapi_observer-1.7.0}/src/fastapiobserver/audit/__init__.py +0 -0
  61. {fastapi_observer-1.6.0 → fastapi_observer-1.7.0}/src/fastapiobserver/audit/formatter.py +0 -0
  62. {fastapi_observer-1.6.0 → fastapi_observer-1.7.0}/src/fastapiobserver/audit/providers.py +0 -0
  63. {fastapi_observer-1.6.0 → fastapi_observer-1.7.0}/src/fastapiobserver/audit/verify.py +0 -0
  64. {fastapi_observer-1.6.0 → fastapi_observer-1.7.0}/src/fastapiobserver/config.py +0 -0
  65. {fastapi_observer-1.6.0 → fastapi_observer-1.7.0}/src/fastapiobserver/control_plane.py +0 -0
  66. {fastapi_observer-1.6.0 → fastapi_observer-1.7.0}/src/fastapiobserver/integrations/__init__.py +0 -0
  67. {fastapi_observer-1.6.0 → fastapi_observer-1.7.0}/src/fastapiobserver/integrations/strawberry.py +0 -0
  68. {fastapi_observer-1.6.0 → fastapi_observer-1.7.0}/src/fastapiobserver/logging/__init__.py +0 -0
  69. {fastapi_observer-1.6.0 → fastapi_observer-1.7.0}/src/fastapiobserver/logging/filters.py +0 -0
  70. {fastapi_observer-1.6.0 → fastapi_observer-1.7.0}/src/fastapiobserver/logging/formatter.py +0 -0
  71. {fastapi_observer-1.6.0 → fastapi_observer-1.7.0}/src/fastapiobserver/logging/queueing.py +0 -0
  72. {fastapi_observer-1.6.0 → fastapi_observer-1.7.0}/src/fastapiobserver/logging/setup.py +0 -0
  73. {fastapi_observer-1.6.0 → fastapi_observer-1.7.0}/src/fastapiobserver/logging/state.py +0 -0
  74. {fastapi_observer-1.6.0 → fastapi_observer-1.7.0}/src/fastapiobserver/loguru.py +0 -0
  75. {fastapi_observer-1.6.0 → fastapi_observer-1.7.0}/src/fastapiobserver/middleware/body_capture.py +0 -0
  76. {fastapi_observer-1.6.0 → fastapi_observer-1.7.0}/src/fastapiobserver/middleware/context.py +0 -0
  77. {fastapi_observer-1.6.0 → fastapi_observer-1.7.0}/src/fastapiobserver/middleware/events.py +0 -0
  78. {fastapi_observer-1.6.0 → fastapi_observer-1.7.0}/src/fastapiobserver/middleware/headers.py +0 -0
  79. {fastapi_observer-1.6.0 → fastapi_observer-1.7.0}/src/fastapiobserver/middleware/ip.py +0 -0
  80. {fastapi_observer-1.6.0 → fastapi_observer-1.7.0}/src/fastapiobserver/middleware/metrics.py +0 -0
  81. {fastapi_observer-1.6.0 → fastapi_observer-1.7.0}/src/fastapiobserver/otel/settings.py +0 -0
  82. {fastapi_observer-1.6.0 → fastapi_observer-1.7.0}/src/fastapiobserver/plugins.py +0 -0
  83. {fastapi_observer-1.6.0 → fastapi_observer-1.7.0}/src/fastapiobserver/profiles.py +0 -0
  84. {fastapi_observer-1.6.0 → fastapi_observer-1.7.0}/src/fastapiobserver/propagation.py +0 -0
  85. {fastapi_observer-1.6.0 → fastapi_observer-1.7.0}/src/fastapiobserver/py.typed +0 -0
  86. {fastapi_observer-1.6.0 → fastapi_observer-1.7.0}/src/fastapiobserver/request_context.py +0 -0
  87. {fastapi_observer-1.6.0 → fastapi_observer-1.7.0}/src/fastapiobserver/security/__init__.py +0 -0
  88. {fastapi_observer-1.6.0 → fastapi_observer-1.7.0}/src/fastapiobserver/security/normalize.py +0 -0
  89. {fastapi_observer-1.6.0 → fastapi_observer-1.7.0}/src/fastapiobserver/security/policy.py +0 -0
  90. {fastapi_observer-1.6.0 → fastapi_observer-1.7.0}/src/fastapiobserver/security/proxies.py +0 -0
  91. {fastapi_observer-1.6.0 → fastapi_observer-1.7.0}/src/fastapiobserver/security/redaction.py +0 -0
  92. {fastapi_observer-1.6.0 → fastapi_observer-1.7.0}/src/fastapiobserver/sinks/__init__.py +0 -0
  93. {fastapi_observer-1.6.0 → fastapi_observer-1.7.0}/src/fastapiobserver/sinks/builtin.py +0 -0
  94. {fastapi_observer-1.6.0 → fastapi_observer-1.7.0}/src/fastapiobserver/sinks/discovery.py +0 -0
  95. {fastapi_observer-1.6.0 → fastapi_observer-1.7.0}/src/fastapiobserver/sinks/factory.py +0 -0
  96. {fastapi_observer-1.6.0 → fastapi_observer-1.7.0}/src/fastapiobserver/sinks/logtail/__init__.py +0 -0
  97. {fastapi_observer-1.6.0 → fastapi_observer-1.7.0}/src/fastapiobserver/sinks/logtail/dlq.py +0 -0
  98. {fastapi_observer-1.6.0 → fastapi_observer-1.7.0}/src/fastapiobserver/sinks/logtail/handler.py +0 -0
  99. {fastapi_observer-1.6.0 → fastapi_observer-1.7.0}/src/fastapiobserver/sinks/logtail/sink.py +0 -0
  100. {fastapi_observer-1.6.0 → fastapi_observer-1.7.0}/src/fastapiobserver/sinks/protocol.py +0 -0
  101. {fastapi_observer-1.6.0 → fastapi_observer-1.7.0}/src/fastapiobserver/sinks/registry.py +0 -0
  102. {fastapi_observer-1.6.0 → fastapi_observer-1.7.0}/src/fastapiobserver/sinks/stats.py +0 -0
  103. {fastapi_observer-1.6.0 → fastapi_observer-1.7.0}/src/fastapiobserver/utils.py +0 -0
  104. {fastapi_observer-1.6.0 → fastapi_observer-1.7.0}/tests/test_audit_chain.py +0 -0
  105. {fastapi_observer-1.6.0 → fastapi_observer-1.7.0}/tests/test_body_media_allowlist.py +0 -0
  106. {fastapi_observer-1.6.0 → fastapi_observer-1.7.0}/tests/test_config_settings.py +0 -0
  107. {fastapi_observer-1.6.0 → fastapi_observer-1.7.0}/tests/test_dx_parity.py +0 -0
  108. {fastapi_observer-1.6.0 → fastapi_observer-1.7.0}/tests/test_fastapi_install.py +0 -0
  109. {fastapi_observer-1.6.0 → fastapi_observer-1.7.0}/tests/test_fork_queue_listener.py +0 -0
  110. {fastapi_observer-1.6.0 → fastapi_observer-1.7.0}/tests/test_graphql_integrations.py +0 -0
  111. {fastapi_observer-1.6.0 → fastapi_observer-1.7.0}/tests/test_lifespan_teardown.py +0 -0
  112. {fastapi_observer-1.6.0 → fastapi_observer-1.7.0}/tests/test_log_queue_controls.py +0 -0
  113. {fastapi_observer-1.6.0 → fastapi_observer-1.7.0}/tests/test_logging_schema_contract.py +0 -0
  114. {fastapi_observer-1.6.0 → fastapi_observer-1.7.0}/tests/test_logtail_dlq.py +0 -0
  115. {fastapi_observer-1.6.0 → fastapi_observer-1.7.0}/tests/test_loguru_bridge.py +0 -0
  116. {fastapi_observer-1.6.0 → fastapi_observer-1.7.0}/tests/test_otel_logs_from_env.py +0 -0
  117. {fastapi_observer-1.6.0 → fastapi_observer-1.7.0}/tests/test_otel_metrics_from_env.py +0 -0
  118. {fastapi_observer-1.6.0 → fastapi_observer-1.7.0}/tests/test_plugin_isolation.py +0 -0
  119. {fastapi_observer-1.6.0 → fastapi_observer-1.7.0}/tests/test_profiles.py +0 -0
  120. {fastapi_observer-1.6.0 → fastapi_observer-1.7.0}/tests/test_propagation.py +0 -0
  121. {fastapi_observer-1.6.0 → fastapi_observer-1.7.0}/tests/test_redaction_presets.py +0 -0
  122. {fastapi_observer-1.6.0 → fastapi_observer-1.7.0}/tests/test_route_template_cardinality.py +0 -0
  123. {fastapi_observer-1.6.0 → fastapi_observer-1.7.0}/tests/test_runtime_control_auth.py +0 -0
  124. {fastapi_observer-1.6.0 → fastapi_observer-1.7.0}/tests/test_security_audit_fixes.py +0 -0
  125. {fastapi_observer-1.6.0 → fastapi_observer-1.7.0}/tests/test_security_redaction.py +0 -0
  126. {fastapi_observer-1.6.0 → fastapi_observer-1.7.0}/tests/test_sinks.py +0 -0
  127. {fastapi_observer-1.6.0 → fastapi_observer-1.7.0}/tests/test_trust_boundary.py +0 -0
  128. {fastapi_observer-1.6.0 → fastapi_observer-1.7.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: 1.6.0
3
+ Version: 1.7.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
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fastapi-observer
3
- Version: 1.6.0
3
+ Version: 1.7.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
@@ -33,18 +33,7 @@ src/fastapiobserver/logging/queueing.py
33
33
  src/fastapiobserver/logging/setup.py
34
34
  src/fastapiobserver/logging/state.py
35
35
  src/fastapiobserver/metrics/__init__.py
36
- src/fastapiobserver/metrics/builder.py
37
- src/fastapiobserver/metrics/contracts.py
38
- src/fastapiobserver/metrics/endpoint.py
39
- src/fastapiobserver/metrics/noop.py
40
- src/fastapiobserver/metrics/pathing.py
41
- src/fastapiobserver/metrics/registry.py
42
- src/fastapiobserver/metrics/prometheus/__init__.py
43
- src/fastapiobserver/metrics/prometheus/backend.py
44
- src/fastapiobserver/metrics/prometheus/client.py
45
- src/fastapiobserver/metrics/prometheus/collector.py
46
- src/fastapiobserver/metrics/prometheus/exemplars.py
47
- src/fastapiobserver/metrics/prometheus/multiprocess.py
36
+ src/fastapiobserver/metrics/prometheus.py
48
37
  src/fastapiobserver/middleware/__init__.py
49
38
  src/fastapiobserver/middleware/body_capture.py
50
39
  src/fastapiobserver/middleware/context.py
@@ -53,7 +42,6 @@ src/fastapiobserver/middleware/headers.py
53
42
  src/fastapiobserver/middleware/ip.py
54
43
  src/fastapiobserver/middleware/metrics.py
55
44
  src/fastapiobserver/middleware/request_logging.py
56
- src/fastapiobserver/middleware/span_errors.py
57
45
  src/fastapiobserver/otel/__init__.py
58
46
  src/fastapiobserver/otel/exclusions.py
59
47
  src/fastapiobserver/otel/lifecycle.py
@@ -62,7 +50,6 @@ src/fastapiobserver/otel/metrics.py
62
50
  src/fastapiobserver/otel/resource.py
63
51
  src/fastapiobserver/otel/settings.py
64
52
  src/fastapiobserver/otel/tracing.py
65
- src/fastapiobserver/otel/types.py
66
53
  src/fastapiobserver/security/__init__.py
67
54
  src/fastapiobserver/security/normalize.py
68
55
  src/fastapiobserver/security/policy.py
@@ -0,0 +1 @@
1
+ __version__ = "1.7.0"
@@ -12,7 +12,7 @@ Usage::
12
12
 
13
13
  engine = create_engine("postgresql://...")
14
14
  instrument_sqlalchemy(engine)
15
- # SELECT * FROM users /*traceparent='00-abc123...',route='/api/users'*/
15
+ # SELECT * FROM users /*db_driver='psycopg2',traceparent='00-abc123...'*/
16
16
 
17
17
  Or for async engines::
18
18
 
@@ -35,9 +35,12 @@ _DEFAULT_COMMENTER_OPTIONS: dict[str, bool] = {
35
35
  "opentelemetry_values": True, # injects traceparent
36
36
  "db_driver": True,
37
37
  "db_framework": False, # noisy — omit by default
38
- "route": True,
39
38
  }
40
39
 
40
+ # OpenTelemetry's instrumentor is a singleton that ignores a second
41
+ # ``instrument()`` call, so every call re-instruments all engines seen so far.
42
+ _ENGINES: list[Any] = []
43
+
41
44
  _PACKAGE_HINT = "fastapi-observer[otel-sqlalchemy]"
42
45
 
43
46
 
@@ -58,38 +61,46 @@ def instrument_sqlalchemy(
58
61
  context to every query.
59
62
  commenter_options:
60
63
  Key-value pairs controlling which fields appear in the SQL
61
- comment. Defaults to ``traceparent``, ``db_driver``, and
62
- ``route``.
64
+ comment (``opentelemetry_values``, ``db_driver``, ``db_framework``).
65
+ Defaults to the trace context and ``db_driver``.
66
+
67
+ Call it once per engine. The commenter settings of the latest call apply
68
+ to every instrumented engine.
63
69
  """
64
70
  try:
65
71
  instrumentor_module = lazy_import(
66
72
  "opentelemetry.instrumentation.sqlalchemy",
67
73
  package_hint=_PACKAGE_HINT,
68
74
  )
69
- instrumentor = instrumentor_module.SQLAlchemyInstrumentor()
70
-
71
- options = commenter_options or _DEFAULT_COMMENTER_OPTIONS
72
-
73
- instrumentor.instrument(
74
- engine=engine,
75
- enable_commenter=enable_commenter,
76
- commenter_options=options,
77
- )
78
- _LOGGER.info(
79
- "db_tracing.instrumented",
80
- extra={
81
- "event": {
82
- "engine_url": str(engine.url),
83
- "commenter_enabled": enable_commenter,
84
- },
85
- "_skip_enrichers": True,
86
- },
87
- )
88
75
  except RuntimeError as exc:
89
76
  raise RuntimeError(
90
77
  "SQLAlchemy instrumentation requires "
91
78
  f"`pip install {_PACKAGE_HINT}`"
92
79
  ) from exc
80
+ instrumentor = instrumentor_module.SQLAlchemyInstrumentor()
81
+
82
+ if not any(known is engine for known in _ENGINES):
83
+ _ENGINES.append(engine)
84
+ if instrumentor.is_instrumented_by_opentelemetry:
85
+ instrumentor.uninstrument()
86
+ instrumentor.instrument(
87
+ engines=list(_ENGINES),
88
+ enable_commenter=enable_commenter,
89
+ commenter_options=commenter_options or _DEFAULT_COMMENTER_OPTIONS,
90
+ )
91
+
92
+ event = {"engine_url": str(engine.url), "commenter_enabled": enable_commenter}
93
+ if not instrumentor.is_instrumented_by_opentelemetry:
94
+ # OpenTelemetry refuses unsupported SQLAlchemy versions and only logs it.
95
+ _LOGGER.warning(
96
+ "db_tracing.not_instrumented",
97
+ extra={"event": event, "_skip_enrichers": True},
98
+ )
99
+ return
100
+ _LOGGER.info(
101
+ "db_tracing.instrumented",
102
+ extra={"event": event, "_skip_enrichers": True},
103
+ )
93
104
 
94
105
 
95
106
  def instrument_sqlalchemy_async(
@@ -134,6 +145,7 @@ def uninstrument_sqlalchemy() -> None:
134
145
  instrumentor_module.SQLAlchemyInstrumentor().uninstrument()
135
146
  except (ModuleNotFoundError, RuntimeError):
136
147
  pass
148
+ _ENGINES.clear()
137
149
 
138
150
 
139
151
  __all__ = [
@@ -146,6 +146,9 @@ def _setup_structured_logging(
146
146
  security_policy: SecurityPolicy,
147
147
  audit_key_provider: object | None,
148
148
  ) -> None:
149
+ # Hook the lifespan before install_otel_logs does, so on shutdown the log
150
+ # queue drains into the OTLP handler before its provider is shut down.
151
+ _register_logging_shutdown_hook(app)
149
152
  otel_log_handler = None
150
153
  logs_mode: Literal["local_json", "otlp", "both"] = "local_json"
151
154
  if otel_logs_settings and otel_logs_settings.enabled:
@@ -172,7 +175,6 @@ def _setup_structured_logging(
172
175
  extra_handlers=extra_handlers,
173
176
  audit_key_provider=audit_key_provider,
174
177
  )
175
- _register_logging_shutdown_hook(app)
176
178
 
177
179
 
178
180
  def _setup_metrics_backend(
@@ -69,10 +69,22 @@ class SinkCircuitBreakerHandler(logging.Handler):
69
69
  self._half_open_total = 0
70
70
  self._closes_total = 0
71
71
 
72
+ # Stdlib handlers catch their own write errors and only report them
73
+ # through handleError(), so count those reports as failures too.
74
+ self._delegate_failed = False
75
+ report_error = delegate.handleError
76
+
77
+ def _count_and_report(record: logging.LogRecord) -> None:
78
+ self._delegate_failed = True
79
+ report_error(record)
80
+
81
+ delegate.handleError = _count_and_report # type: ignore[method-assign]
82
+
72
83
  def emit(self, record: logging.LogRecord) -> None:
73
84
  if self._should_skip():
74
85
  return
75
86
 
87
+ self._delegate_failed = False
76
88
  try:
77
89
  self._delegate.handle(record)
78
90
  except Exception:
@@ -80,7 +92,10 @@ class SinkCircuitBreakerHandler(logging.Handler):
80
92
  self.handleError(record)
81
93
  return
82
94
 
83
- self._record_success()
95
+ if self._delegate_failed:
96
+ self._record_failure()
97
+ else:
98
+ self._record_success()
84
99
 
85
100
  def setFormatter(self, fmt: logging.Formatter | None) -> None: # noqa: N802
86
101
  super().setFormatter(fmt)
@@ -0,0 +1,144 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from typing import Callable, Protocol
5
+
6
+ from fastapi import FastAPI
7
+
8
+ from ..config import MetricsFormat
9
+ from .prometheus import (
10
+ PrometheusMetricsBackend,
11
+ _build_prometheus_metrics_backend,
12
+ mark_prometheus_process_dead,
13
+ mount_metrics_endpoint,
14
+ )
15
+
16
+
17
+ class MetricsBackend(Protocol):
18
+ """Interface for metrics recording backends."""
19
+
20
+ def observe(
21
+ self,
22
+ method: str,
23
+ path: str,
24
+ status_code: int,
25
+ duration_seconds: float,
26
+ ) -> None: ...
27
+
28
+
29
+ class MountableMetricsBackend(Protocol):
30
+ """Optional extension for backends that can mount their endpoint."""
31
+
32
+ def mount_endpoint(
33
+ self,
34
+ app: FastAPI,
35
+ *,
36
+ path: str = "/metrics",
37
+ metrics_format: MetricsFormat = "negotiate",
38
+ ) -> None: ...
39
+
40
+
41
+ MetricsBackendFactory = Callable[..., MetricsBackend]
42
+
43
+
44
+ class NoopMetricsBackend:
45
+ """No-op backend that silently discards all observations."""
46
+
47
+ def observe(
48
+ self,
49
+ method: str,
50
+ path: str,
51
+ status_code: int,
52
+ duration_seconds: float,
53
+ ) -> None:
54
+ return None
55
+
56
+
57
+ _UUID_RE = re.compile(
58
+ r"/[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-5][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}"
59
+ )
60
+ _NUMBER_RE = re.compile(r"/\d+")
61
+ _HEX_RE = re.compile(r"/[0-9a-fA-F]{16,}")
62
+
63
+
64
+ def collapse_dynamic_segments(path: str) -> str:
65
+ """Replace dynamic path segments with ``/:id`` to control label cardinality."""
66
+ normalized = _UUID_RE.sub("/:id", path)
67
+ normalized = _HEX_RE.sub("/:id", normalized)
68
+ return _NUMBER_RE.sub("/:id", normalized)
69
+
70
+
71
+ _BACKEND_FACTORIES: dict[str, MetricsBackendFactory] = {}
72
+
73
+
74
+ def register_metrics_backend(name: str, factory: MetricsBackendFactory) -> None:
75
+ normalized_name = name.strip().lower()
76
+ if not normalized_name:
77
+ raise ValueError("Metrics backend name cannot be empty")
78
+ _BACKEND_FACTORIES[normalized_name] = factory
79
+
80
+
81
+ def unregister_metrics_backend(name: str) -> None:
82
+ _BACKEND_FACTORIES.pop(name.strip().lower(), None)
83
+
84
+
85
+ def get_registered_metrics_backends() -> dict[str, MetricsBackendFactory]:
86
+ return dict(_BACKEND_FACTORIES)
87
+
88
+
89
+ def build_metrics_backend(
90
+ enabled: bool,
91
+ *,
92
+ service: str = "api",
93
+ environment: str = "development",
94
+ exemplars_enabled: bool = False,
95
+ backend: str = "prometheus",
96
+ ) -> MetricsBackend:
97
+ """Factory for selecting the appropriate metrics backend."""
98
+ if not enabled:
99
+ return NoopMetricsBackend()
100
+ factory = _BACKEND_FACTORIES.get(backend.strip().lower())
101
+ if factory is None:
102
+ available_csv = ", ".join(sorted(_BACKEND_FACTORIES)) or "(none)"
103
+ raise ValueError(
104
+ f"Unknown metrics backend: {backend!r}. Available backends: {available_csv}"
105
+ )
106
+ return factory(
107
+ service=service,
108
+ environment=environment,
109
+ exemplars_enabled=exemplars_enabled,
110
+ )
111
+
112
+
113
+ def mount_backend_metrics_endpoint(
114
+ app: FastAPI,
115
+ backend: MetricsBackend,
116
+ *,
117
+ path: str = "/metrics",
118
+ metrics_format: MetricsFormat = "negotiate",
119
+ ) -> bool:
120
+ mount_endpoint = getattr(backend, "mount_endpoint", None)
121
+ if not callable(mount_endpoint):
122
+ return False
123
+ mount_endpoint(app, path=path, metrics_format=metrics_format)
124
+ return True
125
+
126
+
127
+ register_metrics_backend("prometheus", _build_prometheus_metrics_backend)
128
+
129
+ __all__ = [
130
+ "MetricsBackend",
131
+ "MountableMetricsBackend",
132
+ "MetricsBackendFactory",
133
+ "MetricsFormat",
134
+ "NoopMetricsBackend",
135
+ "collapse_dynamic_segments",
136
+ "register_metrics_backend",
137
+ "unregister_metrics_backend",
138
+ "get_registered_metrics_backends",
139
+ "build_metrics_backend",
140
+ "mount_backend_metrics_endpoint",
141
+ "PrometheusMetricsBackend",
142
+ "mark_prometheus_process_dead",
143
+ "mount_metrics_endpoint",
144
+ ]
@@ -0,0 +1,325 @@
1
+ """Prometheus backend, ``/metrics`` endpoint and multiprocess helpers.
2
+
3
+ ``prometheus_client`` is an optional dependency, so it is imported lazily.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import gzip
9
+ import logging
10
+ import os
11
+ import threading
12
+ from typing import Any, Callable, Iterator
13
+
14
+ from fastapi import FastAPI
15
+ from starlette.requests import Request
16
+ from starlette.responses import Response
17
+ from starlette.routing import Route
18
+
19
+ from ..config import MetricsFormat
20
+ from ..utils import normalize_path
21
+
22
+ _LOGGER = logging.getLogger("fastapiobserver.metrics")
23
+ _HTTP_LABELS = ("service", "environment", "method", "path", "status_code")
24
+ # Accept header pinned for the non-negotiating formats; prometheus_client picks the encoder.
25
+ _FORCED_ACCEPT = {
26
+ "openmetrics": "application/openmetrics-text",
27
+ "prometheus": "text/plain",
28
+ }
29
+ _SINK_COUNTERS = {
30
+ "failures_total": "Total sink handler failures observed by circuit breakers.",
31
+ "skipped_total": "Total records skipped while sink circuit breaker was open.",
32
+ "opens_total": "Total transitions into open state for sink circuit breakers.",
33
+ "half_open_total": "Total transitions into half-open state for sink circuit breakers.",
34
+ "closes_total": "Total transitions back to closed state for sink circuit breakers.",
35
+ }
36
+
37
+
38
+ def _import_prometheus_client() -> Any:
39
+ try:
40
+ import prometheus_client
41
+ except ModuleNotFoundError as exc:
42
+ raise RuntimeError(
43
+ "Prometheus support requires `pip install fastapi-observer[prometheus]`"
44
+ ) from exc
45
+ return prometheus_client
46
+
47
+
48
+ def _multiprocess_dir() -> str:
49
+ return os.getenv("PROMETHEUS_MULTIPROC_DIR", "").strip()
50
+
51
+
52
+ def _validate_multiprocess_dir() -> None:
53
+ path = _multiprocess_dir()
54
+ if path and not (os.path.isdir(path) and os.access(path, os.W_OK)):
55
+ raise RuntimeError(
56
+ f"PROMETHEUS_MULTIPROC_DIR={path!r} must be an existing, writable directory. "
57
+ "Create it before starting workers."
58
+ )
59
+
60
+
61
+ def mark_prometheus_process_dead(pid: int) -> None:
62
+ """Clean up a dead worker's files in multiprocess mode (gunicorn ``child_exit``)."""
63
+ if not _multiprocess_dir():
64
+ return
65
+ _import_prometheus_client()
66
+ from prometheus_client import multiprocess
67
+
68
+ multiprocess.mark_process_dead(pid)
69
+
70
+
71
+ def _get_trace_exemplar() -> dict[str, str] | None:
72
+ """Current OTel trace ID as a Prometheus exemplar, or ``None`` without a valid span."""
73
+ try:
74
+ from opentelemetry import trace as otel_trace
75
+
76
+ ctx = otel_trace.get_current_span().get_span_context()
77
+ if ctx and ctx.is_valid:
78
+ return {"TraceID": f"{ctx.trace_id:032x}"}
79
+ except Exception:
80
+ pass
81
+ return None
82
+
83
+
84
+ class _LogQueueMetricsCollector:
85
+ """Exposes log-queue, Logtail DLQ and sink circuit-breaker stats at scrape time."""
86
+
87
+ def collect(self) -> Iterator[Any]:
88
+ from prometheus_client.core import CounterMetricFamily, GaugeMetricFamily
89
+
90
+ from ..logging import get_log_queue_stats, get_sink_circuit_breaker_stats
91
+ from ..sinks import get_logtail_dlq_stats
92
+
93
+ queue = get_log_queue_stats()
94
+ yield GaugeMetricFamily(
95
+ "fastapiobserver_log_queue_size",
96
+ "Current number of log records waiting in the core queue.",
97
+ value=float(queue["queue_size"]),
98
+ )
99
+ yield GaugeMetricFamily(
100
+ "fastapiobserver_log_queue_capacity",
101
+ "Configured capacity of the core logging queue.",
102
+ value=float(queue["queue_capacity"]),
103
+ )
104
+ policy = GaugeMetricFamily(
105
+ "fastapiobserver_log_queue_overflow_policy_info",
106
+ "Current overflow policy for the core logging queue.",
107
+ labels=["policy"],
108
+ )
109
+ policy.add_metric([str(queue["overflow_policy"])], 1.0)
110
+ yield policy
111
+ yield CounterMetricFamily(
112
+ "fastapiobserver_log_queue_enqueued_total",
113
+ "Total log records accepted into the core queue.",
114
+ value=float(queue["enqueued_total"]),
115
+ )
116
+ dropped = CounterMetricFamily(
117
+ "fastapiobserver_log_queue_dropped_total",
118
+ "Total log records dropped due to queue pressure.",
119
+ labels=["reason"],
120
+ )
121
+ dropped.add_metric(["drop_oldest"], float(queue["dropped_oldest_total"]))
122
+ dropped.add_metric(["drop_newest"], float(queue["dropped_newest_total"]))
123
+ yield dropped
124
+ yield CounterMetricFamily(
125
+ "fastapiobserver_log_queue_blocked_total",
126
+ "Total times producers entered blocking mode while queue was full.",
127
+ value=float(queue["blocked_total"]),
128
+ )
129
+ yield CounterMetricFamily(
130
+ "fastapiobserver_log_queue_block_timeouts_total",
131
+ "Total blocking enqueue attempts that timed out and dropped the newest record.",
132
+ value=float(queue["block_timeout_total"]),
133
+ )
134
+
135
+ dlq = get_logtail_dlq_stats()
136
+ written = CounterMetricFamily(
137
+ "fastapiobserver_dlq_written_total",
138
+ "Total payloads written to local DLQ.",
139
+ labels=["reason"],
140
+ )
141
+ written.add_metric(["queue_overflow"], float(dlq["written_queue_overflow"]))
142
+ written.add_metric(["send_failed"], float(dlq["written_send_failed"]))
143
+ yield written
144
+ yield CounterMetricFamily(
145
+ "fastapiobserver_dlq_write_failures_total",
146
+ "Total failures when attempting to write payloads to the DLQ disk.",
147
+ value=float(dlq["failures"]),
148
+ )
149
+ yield CounterMetricFamily(
150
+ "fastapiobserver_dlq_bytes_total",
151
+ "Total bytes written to the DLQ disk.",
152
+ value=float(dlq["bytes"]),
153
+ )
154
+
155
+ sinks = sorted(get_sink_circuit_breaker_stats().items())
156
+ if not sinks:
157
+ return
158
+ state = GaugeMetricFamily(
159
+ "fastapiobserver_sink_circuit_breaker_state_info",
160
+ "Current sink circuit-breaker state.",
161
+ labels=["sink", "state"],
162
+ )
163
+ for sink_name, sink in sinks:
164
+ state.add_metric([sink_name, str(sink["state"])], 1.0)
165
+ yield state
166
+ for key, documentation in _SINK_COUNTERS.items():
167
+ counter = CounterMetricFamily(
168
+ f"fastapiobserver_sink_circuit_breaker_{key}", documentation, labels=["sink"]
169
+ )
170
+ for sink_name, sink in sinks:
171
+ counter.add_metric([sink_name], float(sink[key]))
172
+ yield counter
173
+
174
+
175
+ def _register_log_queue_metrics_collector() -> None:
176
+ # Per-worker queue stats would be meaningless once aggregated across processes.
177
+ if _multiprocess_dir():
178
+ return
179
+ try:
180
+ _import_prometheus_client().REGISTRY.register(_LogQueueMetricsCollector())
181
+ except ValueError:
182
+ pass # Already registered by an earlier backend build in this process.
183
+
184
+
185
+ class PrometheusMetricsBackend:
186
+ """Prometheus request counter and latency histogram.
187
+
188
+ With *exemplars_enabled*, observations carry the current OTel trace ID as an
189
+ exemplar (visible in OpenMetrics output), enabling the metrics -> traces jump
190
+ in Grafana. Exemplars are not supported by the multiprocess collector
191
+ (``PROMETHEUS_MULTIPROC_DIR``), so they are disabled there with a warning.
192
+ """
193
+
194
+ _LOCK = threading.Lock()
195
+ _REQUEST_COUNT: Any = None
196
+ _REQUEST_LATENCY: Any = None
197
+
198
+ def __init__(
199
+ self,
200
+ *,
201
+ service: str,
202
+ environment: str,
203
+ exemplars_enabled: bool = False,
204
+ ) -> None:
205
+ self.service = service
206
+ self.environment = environment
207
+ self._exemplars_enabled = exemplars_enabled
208
+
209
+ if self._exemplars_enabled and _multiprocess_dir():
210
+ _LOGGER.warning(
211
+ "metrics.exemplars.disabled_in_multiprocess",
212
+ extra={
213
+ "event": {
214
+ "message": (
215
+ "Prometheus exemplars are not compatible with "
216
+ "multiprocess mode. Exemplars have been disabled."
217
+ ),
218
+ },
219
+ "_skip_enrichers": True,
220
+ },
221
+ )
222
+ self._exemplars_enabled = False
223
+
224
+ prometheus_client = _import_prometheus_client()
225
+ cls = PrometheusMetricsBackend
226
+ with cls._LOCK:
227
+ if cls._REQUEST_COUNT is None:
228
+ cls._REQUEST_COUNT = prometheus_client.Counter(
229
+ "http_requests_total",
230
+ "Total count of HTTP requests",
231
+ _HTTP_LABELS,
232
+ )
233
+ cls._REQUEST_LATENCY = prometheus_client.Histogram(
234
+ "http_request_duration_seconds",
235
+ "HTTP request latency",
236
+ _HTTP_LABELS,
237
+ buckets=(0.005, 0.01, 0.025, 0.05, 0.1, 0.3, 0.5, 1, 3, 5, 10),
238
+ )
239
+
240
+ def observe(
241
+ self,
242
+ method: str,
243
+ path: str,
244
+ status_code: int,
245
+ duration_seconds: float,
246
+ ) -> None:
247
+ exemplar = _get_trace_exemplar() if self._exemplars_enabled else None
248
+ labels = (self.service, self.environment, method, path, str(status_code))
249
+ self._REQUEST_COUNT.labels(*labels).inc(exemplar=exemplar)
250
+ self._REQUEST_LATENCY.labels(*labels).observe(duration_seconds, exemplar=exemplar)
251
+
252
+ def mount_endpoint(
253
+ self,
254
+ app: FastAPI,
255
+ *,
256
+ path: str = "/metrics",
257
+ metrics_format: MetricsFormat = "negotiate",
258
+ ) -> None:
259
+ mount_metrics_endpoint(app, path=path, metrics_format=metrics_format)
260
+
261
+
262
+ def _build_prometheus_metrics_backend(
263
+ *,
264
+ service: str,
265
+ environment: str,
266
+ exemplars_enabled: bool,
267
+ ) -> PrometheusMetricsBackend:
268
+ _validate_multiprocess_dir()
269
+ backend = PrometheusMetricsBackend(
270
+ service=service,
271
+ environment=environment,
272
+ exemplars_enabled=exemplars_enabled,
273
+ )
274
+ _register_log_queue_metrics_collector()
275
+ return backend
276
+
277
+
278
+ def _metrics_endpoint(registry: Any, accept: str | None) -> Callable[[Request], Response]:
279
+ """Serve *registry* the way ``prometheus_client``'s own apps do.
280
+
281
+ A plain function, so Starlette runs it in its thread pool: rendering takes
282
+ tens to hundreds of milliseconds on large apps and must not block the event
283
+ loop, which ``make_asgi_app`` does. A pinned *accept* overrides negotiation.
284
+ """
285
+ _import_prometheus_client()
286
+ from prometheus_client.exposition import choose_encoder, gzip_accepted
287
+
288
+ def metrics(request: Request) -> Response:
289
+ encoder, content_type = choose_encoder(accept or request.headers.get("accept", ""))
290
+ names = request.query_params.getlist("name[]")
291
+ body = encoder(registry.restricted_registry(names) if names else registry)
292
+ headers = {"Content-Type": content_type}
293
+ if gzip_accepted(request.headers.get("accept-encoding", "")):
294
+ body = gzip.compress(body)
295
+ headers["Content-Encoding"] = "gzip"
296
+ return Response(body, headers=headers)
297
+
298
+ return metrics
299
+
300
+
301
+ def mount_metrics_endpoint(
302
+ app: FastAPI,
303
+ path: str = "/metrics",
304
+ *,
305
+ metrics_format: MetricsFormat = "negotiate",
306
+ ) -> None:
307
+ """Serve Prometheus metrics at *path* (GET/HEAD, gzip when accepted).
308
+
309
+ * ``"negotiate"`` — ``prometheus_client`` picks OpenMetrics or classic text
310
+ from the ``Accept`` header.
311
+ * ``"openmetrics"`` — always OpenMetrics (exemplars visible).
312
+ * ``"prometheus"`` — always classic Prometheus text format.
313
+ """
314
+ path = normalize_path(path, default="/metrics")
315
+ if any(getattr(route, "path", None) == path for route in app.routes):
316
+ return
317
+ prometheus_client = _import_prometheus_client()
318
+ registry = prometheus_client.REGISTRY
319
+ if _multiprocess_dir():
320
+ from prometheus_client import multiprocess
321
+
322
+ registry = prometheus_client.CollectorRegistry(auto_describe=True)
323
+ multiprocess.MultiProcessCollector(registry)
324
+ endpoint = _metrics_endpoint(registry, _FORCED_ACCEPT.get(metrics_format))
325
+ app.routes.insert(0, Route(path, endpoint=endpoint, methods=["GET"]))
@@ -7,7 +7,6 @@ from .headers import _get_header, _upsert_header
7
7
  from .ip import _IpResolver, _extract_scope_client_ip
8
8
  from .metrics import _MetricsRecorder
9
9
  from .request_logging import RequestLoggingMiddleware
10
- from .span_errors import _SpanErrorRecorder
11
10
 
12
11
  __all__ = [
13
12
  "RequestLoggingMiddleware",
@@ -16,7 +15,6 @@ __all__ = [
16
15
  "_MetricsRecorder",
17
16
  "_RequestContextManager",
18
17
  "_RequestEventBuilder",
19
- "_SpanErrorRecorder",
20
18
  "_classify_error",
21
19
  "_extract_route_template",
22
20
  "_extract_scope_client_ip",