This is an automated email from the ASF dual-hosted git repository.
pierrejeambrun pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airflow.git
The following commit(s) were added to refs/heads/main by this push:
new 64ff9e40a40 Add REST API metrics (#64523)
64ff9e40a40 is described below
commit 64ff9e40a400eee895372759048549456effaacd
Author: Henry Chen <[email protected]>
AuthorDate: Tue Oct 13 17:21:51 2026 +0800
Add REST API metrics (#64523)
* add metrics
* allow user define the mapping themself
* Move API metrics config validation into configuration parser
* Simplify REST API metrics tagging
Remove the configurable path-to-surface mapping and emit metrics only
for core API and UI routes. Use normalized route templates with method
and status family tags to keep metric cardinality bounded.
* Report REST API request duration under a milliseconds metric name
Stats.timing records milliseconds on every supported backend, so publishing
the
timer as http_request_duration_seconds would have left every consumer of the
metric wrong by a factor of 1000.
Route tagging also kept a fallback that reverse-looked-up the endpoint in
scope["router"].routes whenever scope["route"] was absent. Only FastAPI's
APIRoute populates scope["route"], and all 151 routes under /api/v2 and /ui
are
APIRoute, so that branch was unreachable. For a mounted sub-app -- the one
case
it was meant to cover -- scope["router"] stays the root router, so the
lookup
could not have succeeded there either. The tests built a plain Starlette
app,
which never sets scope["route"], and so covered only that dead branch
instead of
the path the API server actually takes.
* Bound the REST API metrics method tag to methods the API serves
The router rejects a method mismatch with 405 before any authentication
dependency runs, and uvicorn's h11 parser accepts any token as a method.
That
let an anonymous client create a new metric series per request just by
inventing method names, defeating the bounded-cardinality goal of these
metrics. Only the five methods registered on API routes now get their own
tag
value; everything else is recorded as OTHER, so the series count is fixed by
the route table rather than by whatever clients send.
* Cover the Task SDK execution API in the REST API metrics
`/execution` carries every task heartbeat, state transition and XCom,
making it
the busiest surface the API server has, and it emitted no metrics at all.
Two things kept it out. The middleware was registered from
`init_middlewares`,
which `create_app` only calls for the core app, so `airflow api-server
--apps
execution` -- the shape used to run the execution API as its own deployment
--
installed nothing, and no configuration could recover the metrics. The path
prefix allowlist also covered only `/api/v2` and `/ui`.
Install the middleware for every `--apps` selection from a new
`init_api_metrics`, the shape #72026 settled on after the identical gap left
execution-only deployments with no access logs, and add `/execution` to the
allowlist. Registering it on the outer app rather than inside the execution
sub-app keeps a single instance, so a request cannot be counted twice, and
keeps it outside `GZipMiddleware` so execution API durations include
compression time the way core API durations already do.
A mounted sub-app declares its routes relative to the mount point, so the
route
tag now carries `root_path`. Without it,
`/execution/task-instances/{id}/run`
would be tagged `/task-instances/{id}/run` and collide with any core route
sharing that template.
* Build the API metrics test app once per module
Review feedback: every test rebuilt the whole FastAPI app just to issue one
request, so the app construction cost was paid once per test case for no
benefit. A module-scoped fixture is enough -- the middleware under test
holds
no state, and each test still gets its own client.
* Document that REST API metric tags need a tagging backend
Review feedback: the metric tables promised a breakdown by method, route and
status_family without saying that classic StatsD drops tags, so an operator
reading the docs would configure statsd_on and then wonder why they only
ever
see one aggregate series.
---
airflow-core/src/airflow/api_fastapi/app.py | 10 +
.../src/airflow/api_fastapi/common/http_metrics.py | 137 ++++++++++
.../src/airflow/api_fastapi/core_api/app.py | 7 +-
.../airflow/observability/metrics/stats_utils.py | 10 +
.../unit/api_fastapi/common/test_http_metrics.py | 297 +++++++++++++++++++++
airflow-core/tests/unit/api_fastapi/test_app.py | 23 ++
.../tests/unit/observability/metrics/test_stats.py | 13 +
.../observability/metrics/metrics_template.yaml | 18 ++
8 files changed, 511 insertions(+), 4 deletions(-)
diff --git a/airflow-core/src/airflow/api_fastapi/app.py
b/airflow-core/src/airflow/api_fastapi/app.py
index 974fb2cee75..49255373024 100644
--- a/airflow-core/src/airflow/api_fastapi/app.py
+++ b/airflow-core/src/airflow/api_fastapi/app.py
@@ -31,6 +31,7 @@ from airflow import settings
from airflow.api_fastapi.common.dagbag import create_dag_bag
from airflow.api_fastapi.common.exceptions import init_error_handlers
from airflow.api_fastapi.common.http_access_log import HttpAccessLogMiddleware
+from airflow.api_fastapi.common.http_metrics import HttpMetricsMiddleware
from airflow.api_fastapi.core_api.app import (
init_config,
init_flask_plugins,
@@ -40,6 +41,7 @@ from airflow.api_fastapi.core_api.app import (
from airflow.api_fastapi.execution_api.app import create_task_execution_api_app
from airflow.configuration import conf
from airflow.exceptions import AirflowConfigException
+from airflow.observability.metrics import stats_utils
from airflow.utils.providers_configuration_loader import
providers_configuration_loaded
if TYPE_CHECKING:
@@ -154,6 +156,7 @@ def create_app(apps: str = "all") -> FastAPI:
init_error_handlers(app)
init_middlewares(app)
+ init_api_metrics(app)
init_access_logging(app)
init_config(app)
@@ -228,6 +231,13 @@ def init_access_logging(app: FastAPI) -> None:
app.add_middleware(HttpAccessLogMiddleware)
+def init_api_metrics(app: FastAPI) -> None:
+ """Install the API metrics middleware, the only producer of API request
metrics."""
+ if not stats_utils.is_metrics_enabled():
+ return
+ app.add_middleware(HttpMetricsMiddleware)
+
+
def init_plugins(app: FastAPI) -> None:
"""Integrate FastAPI app, middlewares and UI plugins."""
from airflow import plugins_manager
diff --git a/airflow-core/src/airflow/api_fastapi/common/http_metrics.py
b/airflow-core/src/airflow/api_fastapi/common/http_metrics.py
new file mode 100644
index 00000000000..feabd66d4d4
--- /dev/null
+++ b/airflow-core/src/airflow/api_fastapi/common/http_metrics.py
@@ -0,0 +1,137 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+"""HTTP API metrics middleware."""
+
+from __future__ import annotations
+
+import time
+from typing import TYPE_CHECKING
+
+import structlog
+
+from airflow._shared.observability.metrics.stats import Stats
+
+if TYPE_CHECKING:
+ from starlette.types import ASGIApp, Message, Receive, Scope, Send
+
+logger = structlog.get_logger(logger_name="http.metrics")
+
+# `/execution` is the Task SDK execution API, mounted as a sub-app. The
middleware runs on the
+# outer app, so a request to it passes through here like any other.
+_API_METRICS_PATH_PREFIXES = ("/api/v2", "/ui", "/execution")
+# Only methods registered on API routes get their own tag value. A method
mismatch is rejected
+# before authentication, so any other value would let anonymous clients mint
metric series.
+_SERVED_HTTP_METHODS = frozenset({"GET", "POST", "PUT", "PATCH", "DELETE"})
+_OTHER_HTTP_METHOD = "OTHER"
+
+
+def _is_api_metrics_path(path: str) -> bool:
+ return any(path == prefix or path.startswith(f"{prefix}/") for prefix in
_API_METRICS_PATH_PREFIXES)
+
+
+def _get_status_family(status_code: int) -> str:
+ return f"{status_code // 100}xx"
+
+
+def _get_method_tag(method: str) -> str:
+ return method if method in _SERVED_HTTP_METHODS else _OTHER_HTTP_METHOD
+
+
+def _get_route_template(scope: Scope) -> str:
+ # FastAPI's APIRoute.matches() stores the matched route in the scope
before the endpoint runs.
+ # Requests matching no route (404s, redirects) share one bucket so tag
cardinality stays bounded.
+ route_path = getattr(scope.get("route"), "path", None)
+ if isinstance(route_path, str) and route_path:
+ # A mounted sub-app declares its routes relative to the mount point,
which Starlette keeps
+ # in root_path. Without it, `/execution/task-instances/{id}` would be
tagged
+ # `/task-instances/{id}` and collide with core routes sharing that
template.
+ return f"{scope.get('root_path', '')}{route_path}"
+ return "unmatched"
+
+
+def _emit_api_metrics(
+ *,
+ scope: Scope,
+ path: str,
+ method: str,
+ status_code: int,
+ duration_us: int,
+) -> None:
+ if not _is_api_metrics_path(path):
+ return
+
+ # Keep tags bounded so API metrics remain usable across supported backends.
+ tags = {
+ "method": _get_method_tag(method),
+ "route": _get_route_template(scope),
+ "status_family": _get_status_family(status_code),
+ }
+ duration_ms = duration_us / 1000.0
+
+ Stats.incr("http_requests_total", tags=tags)
+ Stats.timing("http_request_duration_milliseconds", duration_ms, tags=tags)
+
+
+class HttpMetricsMiddleware:
+ """Emit REST API metrics for completed HTTP requests."""
+
+ def __init__(self, app: ASGIApp) -> None:
+ self.app = app
+
+ async def __call__(self, scope: Scope, receive: Receive, send: Send) ->
None:
+ if scope["type"] != "http":
+ await self.app(scope, receive, send)
+ return
+
+ start = time.monotonic_ns()
+ response: Message | None = None
+
+ async def capture_send(message: Message) -> None:
+ nonlocal response
+ if message["type"] == "http.response.start":
+ response = message
+ await send(message)
+
+ try:
+ await self.app(scope, receive, capture_send)
+ except Exception:
+ if response is None:
+ response = {"status": 500}
+ raise
+ finally:
+ path = scope["path"]
+ duration_us = (time.monotonic_ns() - start) // 1000
+ status = response["status"] if response is not None else 0
+ method = scope["method"]
+
+ # Include health checks because their latency can reveal database
contention before probes fail.
+ # Observability failures must never affect serving the request.
+ try:
+ _emit_api_metrics(
+ scope=scope,
+ path=path,
+ method=method,
+ status_code=status,
+ duration_us=duration_us,
+ )
+ except Exception:
+ logger.exception(
+ "failed to emit API metrics",
+ method=method,
+ path=path,
+ status_code=status,
+ )
diff --git a/airflow-core/src/airflow/api_fastapi/core_api/app.py
b/airflow-core/src/airflow/api_fastapi/core_api/app.py
index 841c46c2291..d3a66a5b68d 100644
--- a/airflow-core/src/airflow/api_fastapi/core_api/app.py
+++ b/airflow-core/src/airflow/api_fastapi/core_api/app.py
@@ -287,9 +287,8 @@ def init_middlewares(app: FastAPI) -> None:
for middleware_cls, middleware_kwargs in
get_auth_manager().get_fastapi_middlewares():
app.add_middleware(middleware_cls, **middleware_kwargs)
- # GZipMiddleware must be inside HttpAccessLogMiddleware so that access
logs capture
- # the full end-to-end duration including compression time.
HttpAccessLogMiddleware is
- # installed by ``init_access_logging`` in ``create_app``, which runs after
this
- # function — do not reorder those calls.
+ # GZipMiddleware must be inside HttpMetricsMiddleware and
HttpAccessLogMiddleware so that both
+ # capture the full end-to-end duration including compression time. Both
are installed by
+ # ``create_app`` after this function returns — do not reorder those calls.
# See https://github.com/apache/airflow/issues/60165
app.add_middleware(GZipMiddleware, minimum_size=1024, compresslevel=5)
diff --git a/airflow-core/src/airflow/observability/metrics/stats_utils.py
b/airflow-core/src/airflow/observability/metrics/stats_utils.py
index 9e0f1263bff..15099850b7d 100644
--- a/airflow-core/src/airflow/observability/metrics/stats_utils.py
+++ b/airflow-core/src/airflow/observability/metrics/stats_utils.py
@@ -23,6 +23,16 @@ from airflow._shared.observability.metrics.base_stats_logger
import NoStatsLogge
from airflow.configuration import conf
+def is_metrics_enabled() -> bool:
+ return any(
+ (
+ conf.getboolean("metrics", "statsd_datadog_enabled",
fallback=False),
+ conf.getboolean("metrics", "statsd_on", fallback=False),
+ conf.getboolean("metrics", "otel_on", fallback=False),
+ )
+ )
+
+
def get_stats_factory() -> Callable:
if conf.getboolean("metrics", "statsd_datadog_enabled"):
from airflow.observability.metrics import datadog_logger
diff --git a/airflow-core/tests/unit/api_fastapi/common/test_http_metrics.py
b/airflow-core/tests/unit/api_fastapi/common/test_http_metrics.py
new file mode 100644
index 00000000000..af508cfb8b1
--- /dev/null
+++ b/airflow-core/tests/unit/api_fastapi/common/test_http_metrics.py
@@ -0,0 +1,297 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+from __future__ import annotations
+
+import asyncio
+from unittest import mock
+
+import pytest
+import structlog.testing
+from fastapi import FastAPI
+from starlette.responses import PlainTextResponse
+from starlette.testclient import TestClient
+
+from airflow.api_fastapi.common.http_metrics import (
+ HttpMetricsMiddleware,
+ _emit_api_metrics,
+ _get_status_family,
+)
+
+
+def _make_execution_app() -> FastAPI:
+ """Stand in for the execution API, which the real app mounts as a
sub-app."""
+ app = FastAPI()
+
+ @app.get("/task-instances/{task_instance_id}/run")
+ async def run_task_instance(task_instance_id: str):
+ return PlainTextResponse("ok")
+
+ return app
+
+
[email protected](scope="module")
+def test_app() -> FastAPI:
+ """Build a FastAPI app so route resolution behaves as it does in the real
API server."""
+ app = FastAPI()
+
+ @app.get("/")
+ async def homepage():
+ return PlainTextResponse("ok")
+
+ @app.get("/api/v2/items/{item_id}")
+ async def api_item(item_id: str):
+ return PlainTextResponse("ok")
+
+
@app.get("/api/v2/dags/{dag_id}/dagRuns/{dag_run_id}/taskInstances/{task_id}")
+ async def task_instance(dag_id: str, dag_run_id: str, task_id: str):
+ return PlainTextResponse("ok")
+
+ @app.get("/ui/items/{item_id}")
+ async def ui_item(item_id: str):
+ return PlainTextResponse("ok")
+
+ @app.get("/plugin/items/{item_id}")
+ async def plugin_item(item_id: str):
+ return PlainTextResponse("ok")
+
+ @app.get("/api/v2/fail")
+ async def api_fail():
+ raise RuntimeError("boom")
+
+ @app.get("/api/v2/monitor/health")
+ async def health():
+ return PlainTextResponse("healthy")
+
+ app.mount("/execution", _make_execution_app())
+ app.add_middleware(HttpMetricsMiddleware)
+ return app
+
+
[email protected]
+def test_client(test_app) -> TestClient:
+ return TestClient(test_app, raise_server_exceptions=False)
+
+
+def test_metric_emission_failure_is_logged(test_client):
+ with (
+ mock.patch(
+ "airflow.api_fastapi.common.http_metrics._emit_api_metrics",
+ side_effect=RuntimeError("metrics boom"),
+ ),
+ structlog.testing.capture_logs() as logs,
+ ):
+ response = test_client.get("/api/v2/items/42")
+
+ assert response.status_code == 200
+ assert [record["event"] for record in logs] == ["failed to emit API
metrics"]
+ assert logs[0]["method"] == "GET"
+ assert logs[0]["path"] == "/api/v2/items/42"
+ assert logs[0]["status_code"] == 200
+
+
+def test_non_http_scope_does_not_emit_metrics():
+ """Non-HTTP scopes (e.g. lifespan) are passed through without metrics
emission."""
+
+ async def lifespan_app(scope, receive, send):
+ pass
+
+ middleware = HttpMetricsMiddleware(lifespan_app)
+
+ with (
+ mock.patch("airflow.api_fastapi.common.http_metrics.Stats.incr") as
mock_incr,
+ mock.patch("airflow.api_fastapi.common.http_metrics.Stats.timing") as
mock_timing,
+ ):
+ asyncio.run(middleware({"type": "lifespan"}, None, None))
+
+ mock_incr.assert_not_called()
+ mock_timing.assert_not_called()
+
+
[email protected](
+ ("status_code", "expected"),
+ [
+ pytest.param(0, "0xx", id="no-response"),
+ pytest.param(200, "2xx", id="success"),
+ pytest.param(404, "4xx", id="client-error"),
+ pytest.param(500, "5xx", id="server-error"),
+ ],
+)
+def test_get_status_family(status_code, expected):
+ assert _get_status_family(status_code) == expected
+
+
+def test_request_duration_is_emitted_in_milliseconds():
+ with (
+ mock.patch("airflow.api_fastapi.common.http_metrics.Stats.incr"),
+ mock.patch("airflow.api_fastapi.common.http_metrics.Stats.timing") as
mock_timing,
+ ):
+ _emit_api_metrics(
+ scope={},
+ path="/api/v2/items/42",
+ method="GET",
+ status_code=200,
+ duration_us=1_500_000,
+ )
+
+ mock_timing.assert_called_once_with("http_request_duration_milliseconds",
1500.0, tags=mock.ANY)
+
+
[email protected](
+ ("request_path", "route_tag"),
+ [
+ pytest.param("/api/v2/items/42", "/api/v2/items/{item_id}",
id="public"),
+ pytest.param(
+
"/api/v2/dags/example/dagRuns/manual__2026-07-13/taskInstances/extract",
+
"/api/v2/dags/{dag_id}/dagRuns/{dag_run_id}/taskInstances/{task_id}",
+ id="route-template-with-parameters",
+ ),
+ pytest.param("/ui/items/42", "/ui/items/{item_id}", id="ui"),
+ pytest.param(
+ "/execution/task-instances/abc/run",
+ "/execution/task-instances/{task_instance_id}/run",
+ id="execution-route-tag-keeps-the-mount-prefix",
+ ),
+ ],
+)
+def test_api_requests_emit_metrics(request_path, route_tag, test_client):
+ with (
+ mock.patch("airflow.api_fastapi.common.http_metrics.Stats.incr") as
mock_incr,
+ mock.patch("airflow.api_fastapi.common.http_metrics.Stats.timing") as
mock_timing,
+ ):
+ response = test_client.get(request_path)
+
+ assert response.status_code == 200
+ expected_tags = {
+ "method": "GET",
+ "route": route_tag,
+ "status_family": "2xx",
+ }
+ mock_incr.assert_called_once_with("http_requests_total",
tags=expected_tags)
+ mock_timing.assert_called_once_with("http_request_duration_milliseconds",
mock.ANY, tags=expected_tags)
+
+
[email protected](
+ ("request_path", "expected_status_code"),
+ [
+ pytest.param("/plugin/items/42", 200, id="plugin"),
+ pytest.param("/api/v20/items/42", 404, id="similar-prefix"),
+ ],
+)
+def test_paths_outside_api_metrics_surfaces_do_not_emit_metrics(
+ request_path, expected_status_code, test_client
+):
+ with (
+ mock.patch("airflow.api_fastapi.common.http_metrics.Stats.incr") as
mock_incr,
+ mock.patch("airflow.api_fastapi.common.http_metrics.Stats.timing") as
mock_timing,
+ ):
+ response = test_client.get(request_path)
+
+ assert response.status_code == expected_status_code
+ mock_incr.assert_not_called()
+ mock_timing.assert_not_called()
+
+
+def test_health_path_emits_metrics(test_client):
+ with (
+ mock.patch("airflow.api_fastapi.common.http_metrics.Stats.incr") as
mock_incr,
+ mock.patch("airflow.api_fastapi.common.http_metrics.Stats.timing") as
mock_timing,
+ ):
+ response = test_client.get("/api/v2/monitor/health")
+
+ assert response.status_code == 200
+ expected_tags = {
+ "method": "GET",
+ "route": "/api/v2/monitor/health",
+ "status_family": "2xx",
+ }
+ mock_incr.assert_called_once_with("http_requests_total",
tags=expected_tags)
+ mock_timing.assert_called_once_with("http_request_duration_milliseconds",
mock.ANY, tags=expected_tags)
+
+
+def
test_failed_api_requests_emit_metrics_with_server_error_status(test_client):
+ with (
+ mock.patch("airflow.api_fastapi.common.http_metrics.Stats.incr") as
mock_incr,
+ mock.patch("airflow.api_fastapi.common.http_metrics.Stats.timing") as
mock_timing,
+ ):
+ response = test_client.get("/api/v2/fail")
+
+ assert response.status_code == 500
+ expected_tags = {
+ "method": "GET",
+ "route": "/api/v2/fail",
+ "status_family": "5xx",
+ }
+ mock_incr.assert_called_once_with("http_requests_total",
tags=expected_tags)
+ mock_timing.assert_called_once_with("http_request_duration_milliseconds",
mock.ANY, tags=expected_tags)
+
+
[email protected](
+ ("method", "expected_status_code", "expected_tags"),
+ [
+ pytest.param(
+ "GET",
+ 200,
+ {"method": "GET", "route": "/api/v2/items/{item_id}",
"status_family": "2xx"},
+ id="served-method",
+ ),
+ pytest.param(
+ "POST",
+ 405,
+ {"method": "POST", "route": "/api/v2/items/{item_id}",
"status_family": "4xx"},
+ id="served-method-not-allowed-on-route",
+ ),
+ pytest.param(
+ "HEAD",
+ 405,
+ {"method": "OTHER", "route": "/api/v2/items/{item_id}",
"status_family": "4xx"},
+ id="standard-method-never-served",
+ ),
+ pytest.param(
+ "METHOD-0001",
+ 405,
+ {"method": "OTHER", "route": "/api/v2/items/{item_id}",
"status_family": "4xx"},
+ id="made-up-method",
+ ),
+ ],
+)
+def test_method_tag_is_bounded_to_served_methods(method, expected_status_code,
expected_tags, test_client):
+ with (
+ mock.patch("airflow.api_fastapi.common.http_metrics.Stats.incr") as
mock_incr,
+ mock.patch("airflow.api_fastapi.common.http_metrics.Stats.timing") as
mock_timing,
+ ):
+ response = test_client.request(method, "/api/v2/items/42")
+
+ assert response.status_code == expected_status_code
+ mock_incr.assert_called_once_with("http_requests_total",
tags=expected_tags)
+ mock_timing.assert_called_once_with("http_request_duration_milliseconds",
mock.ANY, tags=expected_tags)
+
+
+def test_unmatched_api_requests_use_unmatched_route_tag(test_client):
+ with (
+ mock.patch("airflow.api_fastapi.common.http_metrics.Stats.incr") as
mock_incr,
+ mock.patch("airflow.api_fastapi.common.http_metrics.Stats.timing") as
mock_timing,
+ ):
+ response = test_client.get("/api/v2/missing")
+
+ assert response.status_code == 404
+ expected_tags = {
+ "method": "GET",
+ "route": "unmatched",
+ "status_family": "4xx",
+ }
+ mock_incr.assert_called_once_with("http_requests_total",
tags=expected_tags)
+ mock_timing.assert_called_once_with("http_request_duration_milliseconds",
mock.ANY, tags=expected_tags)
diff --git a/airflow-core/tests/unit/api_fastapi/test_app.py
b/airflow-core/tests/unit/api_fastapi/test_app.py
index dcbbc0a593a..c994f90a0d5 100644
--- a/airflow-core/tests/unit/api_fastapi/test_app.py
+++ b/airflow-core/tests/unit/api_fastapi/test_app.py
@@ -26,11 +26,13 @@ from fastapi import FastAPI
from fastapi.testclient import TestClient
from sqlalchemy import event, text
from sqlalchemy.engine import Engine
+from starlette.middleware.gzip import GZipMiddleware
import airflow.api_fastapi.app as app_module
import airflow.plugins_manager as plugins_manager
from airflow import settings
from airflow.api_fastapi.common.http_access_log import HttpAccessLogMiddleware
+from airflow.api_fastapi.common.http_metrics import HttpMetricsMiddleware
from airflow.utils.session import create_session_async
from tests_common.test_utils.config import conf_vars
@@ -189,6 +191,27 @@ def
test_access_log_middleware_installed_outermost_for_every_apps_selection(apps
assert installed[0] is HttpAccessLogMiddleware
[email protected]("apps", ["all", "core", "execution"])
+@conf_vars({("metrics", "statsd_on"): "True"})
+def test_api_metrics_middleware_installed_for_every_apps_selection(apps):
+ """The execution API is mounted as a sub-app and `--apps execution` never
reaches
+ ``init_middlewares``, so installing this middleware there would leave the
surface every
+ running task depends on unmetered. It must also wrap GZipMiddleware so the
recorded
+ duration includes compression time (see #60165)."""
+ installed = [m.cls for m in
app_module.create_app(apps=apps).user_middleware]
+
+ assert installed.count(HttpMetricsMiddleware) == 1
+ if GZipMiddleware in installed:
+ assert installed.index(HttpMetricsMiddleware) <
installed.index(GZipMiddleware)
+
+
[email protected]("apps", ["all", "core", "execution"])
+def test_api_metrics_middleware_skipped_without_a_metrics_backend(apps):
+ installed = [m.cls for m in
app_module.create_app(apps=apps).user_middleware]
+
+ assert HttpMetricsMiddleware not in installed
+
+
def test_catch_all_route_last():
"""
Ensure the catch all route that returns the initial html is the last route
in the fastapi app.
diff --git a/airflow-core/tests/unit/observability/metrics/test_stats.py
b/airflow-core/tests/unit/observability/metrics/test_stats.py
index e417d42e5a9..404920e4a36 100644
--- a/airflow-core/tests/unit/observability/metrics/test_stats.py
+++ b/airflow-core/tests/unit/observability/metrics/test_stats.py
@@ -39,6 +39,19 @@ class InvalidCustomStatsd:
class TestStats:
+ @pytest.mark.parametrize(
+ ("metrics_config", "expected"),
+ [
+ pytest.param({}, False, id="disabled"),
+ pytest.param({("metrics", "statsd_on"): "True"}, True,
id="statsd"),
+ pytest.param({("metrics", "statsd_datadog_enabled"): "True"},
True, id="datadog"),
+ pytest.param({("metrics", "otel_on"): "True"}, True, id="otel"),
+ ],
+ )
+ def test_is_metrics_enabled(self, metrics_config, expected):
+ with conf_vars(metrics_config):
+ assert stats_utils.is_metrics_enabled() is expected
+
@pytest.mark.parametrize(
("extra_conf", "expected_message"),
[
diff --git
a/shared/observability/src/airflow_shared/observability/metrics/metrics_template.yaml
b/shared/observability/src/airflow_shared/observability/metrics/metrics_template.yaml
index 93d09231136..9aa2b7a4a9d 100644
---
a/shared/observability/src/airflow_shared/observability/metrics/metrics_template.yaml
+++
b/shared/observability/src/airflow_shared/observability/metrics/metrics_template.yaml
@@ -703,9 +703,27 @@ metrics:
legacy_name: "-"
name_variables: ["status"]
+ - name: "http_requests_total"
+ description: "Number of completed REST API requests, tagged by method,
route, and status_family.
+ Breaking the total down by those tags needs a backend that carries them
(``otel_on``,
+ ``statsd_influxdb_enabled`` or ``statsd_datadog_enabled``); classic StatsD
drops the tags and
+ reports only the overall request count."
+ type: "counter"
+ legacy_name: "-"
+ name_variables: []
+
# ==========
# Timers
# ==========
+ - name: "http_request_duration_milliseconds"
+ description: "Milliseconds taken to complete a REST API request, tagged by
method, route,
+ and status_family. Breaking the timings down by those tags needs a backend
that carries them
+ (``otel_on``, ``statsd_influxdb_enabled`` or ``statsd_datadog_enabled``);
classic StatsD drops
+ the tags and reports only the overall request duration."
+ type: "timer"
+ legacy_name: "-"
+ name_variables: []
+
- name: "dagrun.dependency-check"
description: "Milliseconds taken to check Dag dependencies"
type: "timer"