Skip to content

Commit 49c7e91

Browse files
committed
feat: emit the X-Wherobots-Client attribution header (WBC-819)
DAG-submitted work was indistinguishable from any other unattributed traffic, so Airflow usage could not be measured or debugged. Both hooks now identify themselves on the shared, advisory `X-Wherobots-Client` chain with the origin hop `client=airflow;ver=<provider version>`. The REST hook talks to the platform directly and sends the hop on every call. The SQL hook goes through the Python DB-API driver, so it passes the hop down via the driver's `extra_headers`; the driver appends its own hop on the right, yielding `client=airflow;..., client=dbapi;...` and keeping Airflow as the origin. `extra_headers` postdates the `wherobots-python-dbapi>=0.28.0` floor, so it is probed rather than assumed and an older driver degrades to today's behaviour. The version falls back to `unknown` when the distribution metadata is missing, and hop values are stripped of the `,` and `;` delimiters so the grammar can never be broken.
1 parent 5bb21b0 commit 49c7e91

6 files changed

Lines changed: 239 additions & 2 deletions

File tree

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
"""
2+
Client attribution via the shared ``X-Wherobots-Client`` header.
3+
4+
``X-Wherobots-Client`` is an ordered, append-only, comma-separated list of
5+
hops modelled on ``X-Forwarded-For``: the leftmost hop is the ORIGIN client
6+
and every component appends its own hop on the right. It lets Wherobots
7+
services attribute a request to the client it came from.
8+
9+
This provider is an origin client: an Airflow DAG is where the request enters
10+
the Wherobots client ecosystem, so it emits ``client=airflow;ver=<version>``
11+
as the leftmost hop. Where the provider goes through another Wherobots client
12+
(the Python DB-API driver, for the SQL hook), that client appends its own hop
13+
to the right, producing e.g.
14+
``client=airflow;ver=1.7.0, client=dbapi;ver=0.28.1``.
15+
16+
The header is advisory: it is client-asserted and informational only, and must
17+
never influence authentication or authorization.
18+
"""
19+
20+
from importlib import metadata
21+
from typing import Dict, Final
22+
23+
from airflow_providers_wherobots.hooks.base import PACKAGE_NAME
24+
25+
# Canonical name of the shared, cross-service client-chain header.
26+
WHEROBOTS_CLIENT_HEADER: Final[str] = "X-Wherobots-Client"
27+
28+
# Canonical, stable vocabulary token for this client. Renaming it splits its
29+
# history in the platform's attribution analytics, so it must not change.
30+
CLIENT_TOKEN: Final[str] = "airflow"
31+
32+
# Sentinel used when the installed distribution's version can't be resolved
33+
# (e.g. the provider is imported from a source tree that was never installed).
34+
UNKNOWN_VERSION: Final[str] = "unknown"
35+
36+
# Commas separate hops and semicolons separate a hop's fields, so neither may
37+
# appear inside a value.
38+
_DELIMITERS = str.maketrans({",": "_", ";": "_"})
39+
40+
41+
def _resolve_provider_version() -> str:
42+
"""Return the installed provider version, or ``unknown`` if unavailable."""
43+
try:
44+
return metadata.version(PACKAGE_NAME)
45+
except metadata.PackageNotFoundError:
46+
return UNKNOWN_VERSION
47+
48+
49+
# Resolved once at import: `importlib.metadata.version` scans the installed
50+
# package database on each call, and the version can't change within a process.
51+
PROVIDER_VERSION: Final[str] = _resolve_provider_version()
52+
53+
# This provider's single hop, e.g. `client=airflow;ver=1.7.0`.
54+
CLIENT_HOP: Final[str] = (
55+
f"client={CLIENT_TOKEN};ver={PROVIDER_VERSION.translate(_DELIMITERS)}"
56+
)
57+
58+
59+
def client_attribution_header() -> Dict[str, str]:
60+
"""Return the ``X-Wherobots-Client`` header carrying this provider's hop."""
61+
return {WHEROBOTS_CLIENT_HEADER: CLIENT_HOP}

airflow_providers_wherobots/hooks/rest_api.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
from requests.auth import AuthBase
1717
from wherobots.db import Region
1818

19+
from airflow_providers_wherobots.client_attribution import client_attribution_header
1920
from airflow_providers_wherobots.hooks.base import (
2021
DEFAULT_CONN_ID,
2122
PACKAGE_NAME,
@@ -85,6 +86,15 @@ def user_agent_header(self):
8586
)
8687
return {"User-Agent": header_value}
8788

89+
@cached_property
90+
def default_headers(self) -> Dict[str, str]:
91+
"""Headers sent on every REST API request.
92+
93+
The hook talks to the platform directly, so it is the origin hop of the
94+
advisory ``X-Wherobots-Client`` chain.
95+
"""
96+
return {**self.user_agent_header, **client_attribution_header()}
97+
8898
def _api_call(
8999
self,
90100
method: str,
@@ -100,7 +110,7 @@ def _api_call(
100110
json=payload,
101111
auth=auth,
102112
params=params,
103-
headers=self.user_agent_header,
113+
headers=self.default_headers,
104114
)
105115
try:
106116
resp.raise_for_status()

airflow_providers_wherobots/hooks/sql.py

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@
22
Hook for Wherobots' Spatial SQL API interface.
33
"""
44

5-
from typing import Optional, Union
5+
import inspect
6+
from typing import Any, Dict, Final, Optional, Union
67

78
from airflow.providers.common.sql.hooks.sql import DbApiHook
89
from wherobots.db import Connection as WDBConnection, connect
@@ -15,8 +16,24 @@
1516
from wherobots.db.runtime import Runtime
1617
from wherobots.db.session_type import SessionType
1718

19+
from airflow_providers_wherobots.client_attribution import client_attribution_header
1820
from airflow_providers_wherobots.hooks.base import DEFAULT_CONN_ID
1921

22+
# This hook reaches the platform through the Python DB-API driver rather than
23+
# directly. The driver appends its own `client=dbapi;ver=...` hop to any
24+
# inbound `X-Wherobots-Client` chain, so handing it our hop via `extra_headers`
25+
# keeps Airflow as the leftmost (origin) hop instead of letting the driver look
26+
# like the origin.
27+
#
28+
# `extra_headers` is newer than this provider's `wherobots-python-dbapi>=0.28.0`
29+
# floor, so it is probed rather than assumed: against an older driver the
30+
# provider still works and simply loses the Airflow hop. Delete this probe and
31+
# pass the argument unconditionally once the floor is raised to a release that
32+
# has it.
33+
_CONNECT_SUPPORTS_EXTRA_HEADERS: Final[bool] = (
34+
"extra_headers" in inspect.signature(connect).parameters
35+
)
36+
2037

2138
class WherobotsSqlHook(DbApiHook): # type: ignore[misc]
2239
conn_name_attr = "wherobots_conn_id"
@@ -50,6 +67,11 @@ def _create_or_get_sql_session(
5067
self,
5168
runtime: Optional[Union[str, Runtime]] = None,
5269
) -> WDBConnection:
70+
attribution: Dict[str, Any] = (
71+
{"extra_headers": client_attribution_header()}
72+
if _CONNECT_SUPPORTS_EXTRA_HEADERS
73+
else {}
74+
)
5375
return connect(
5476
host=self._conn.host,
5577
api_key=self._conn.password,
@@ -60,6 +82,7 @@ def _create_or_get_sql_session(
6082
read_timeout=self.read_timeout,
6183
force_new=self.force_new,
6284
session_type=self.session_type,
85+
**attribution,
6386
)
6487

6588
def get_conn(self) -> WDBConnection:

tests/unit_tests/hooks/test_rest_api.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
import json
66
from http import HTTPStatus
7+
from importlib import metadata
78

89
import airflow
910
import requests
@@ -13,10 +14,15 @@
1314
from responses import matchers
1415
from wherobots.db import Runtime, Region
1516

17+
from airflow_providers_wherobots.client_attribution import (
18+
CLIENT_HOP,
19+
WHEROBOTS_CLIENT_HEADER,
20+
)
1621
from airflow_providers_wherobots.hooks.rest_api import (
1722
WherobotsAuth,
1823
WherobotsRestAPIHook,
1924
)
25+
from airflow_providers_wherobots.hooks.base import PACKAGE_NAME
2026
from airflow_providers_wherobots.wherobots.models import (
2127
Run,
2228
LogsResponse,
@@ -195,6 +201,31 @@ def test_cancel_run(self, test_default_conn) -> None:
195201
with WherobotsRestAPIHook() as hook:
196202
hook.cancel_run(run_id=test_run.ext_id)
197203

204+
@responses.activate
205+
def test_api_call_sends_client_attribution_header(self, test_default_conn) -> None:
206+
"""Every REST call carries this provider's origin hop."""
207+
url = f"https://{test_default_conn.host}/test"
208+
responses.add(
209+
responses.GET,
210+
url,
211+
json={},
212+
status=HTTPStatus.OK,
213+
match=[matchers.header_matcher({WHEROBOTS_CLIENT_HEADER: CLIENT_HOP})],
214+
)
215+
with WherobotsRestAPIHook() as hook:
216+
hook._api_call("GET", "/test")
217+
218+
sent = responses.calls[0].request.headers[WHEROBOTS_CLIENT_HEADER]
219+
assert sent == f"client=airflow;ver={metadata.version(PACKAGE_NAME)}"
220+
221+
def test_default_headers_keep_the_user_agent(self, test_default_conn) -> None:
222+
"""Client attribution is additive: the User-Agent is still sent."""
223+
with WherobotsRestAPIHook() as hook:
224+
assert hook.default_headers == {
225+
**hook.user_agent_header,
226+
WHEROBOTS_CLIENT_HEADER: CLIENT_HOP,
227+
}
228+
198229
def test_user_agent(self, test_default_conn, mocker: MockerFixture) -> None:
199230
"""
200231
Test the user_agent_header property

tests/unit_tests/hooks/test_sql.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,15 @@
66
from unittest.mock import MagicMock
77

88
from airflow.models import Connection
9+
from pytest_mock import MockerFixture
910
from wherobots.db.region import Region
1011
from wherobots.db.runtime import Runtime
1112
from wherobots.db.session_type import SessionType
1213

14+
from airflow_providers_wherobots.client_attribution import (
15+
CLIENT_HOP,
16+
WHEROBOTS_CLIENT_HEADER,
17+
)
1318
from airflow_providers_wherobots.hooks.sql import WherobotsSqlHook
1419

1520

@@ -37,3 +42,35 @@ def test_get_conn(self, mock_connect: MagicMock, test_default_conn: Connection):
3742
force_new=True,
3843
session_type=SessionType.SINGLE,
3944
)
45+
46+
@mock.patch("airflow_providers_wherobots.hooks.sql.connect")
47+
def test_get_conn_passes_client_attribution_to_the_driver(
48+
self,
49+
mock_connect: MagicMock,
50+
test_default_conn: Connection,
51+
mocker: MockerFixture,
52+
):
53+
"""Our hop goes to the driver, which appends its own hop to the right."""
54+
mocker.patch(
55+
"airflow_providers_wherobots.hooks.sql._CONNECT_SUPPORTS_EXTRA_HEADERS",
56+
True,
57+
)
58+
WherobotsSqlHook().get_conn()
59+
assert mock_connect.call_args.kwargs["extra_headers"] == {
60+
WHEROBOTS_CLIENT_HEADER: CLIENT_HOP
61+
}
62+
63+
@mock.patch("airflow_providers_wherobots.hooks.sql.connect")
64+
def test_get_conn_omits_client_attribution_on_older_drivers(
65+
self,
66+
mock_connect: MagicMock,
67+
test_default_conn: Connection,
68+
mocker: MockerFixture,
69+
):
70+
"""A driver without `extra_headers` still connects, just unattributed."""
71+
mocker.patch(
72+
"airflow_providers_wherobots.hooks.sql._CONNECT_SUPPORTS_EXTRA_HEADERS",
73+
False,
74+
)
75+
WherobotsSqlHook().get_conn()
76+
assert "extra_headers" not in mock_connect.call_args.kwargs
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
"""
2+
Test the shared ``X-Wherobots-Client`` client-attribution hop.
3+
"""
4+
5+
import importlib
6+
import re
7+
from importlib import metadata
8+
9+
from pytest_mock import MockerFixture
10+
11+
from airflow_providers_wherobots import client_attribution
12+
from airflow_providers_wherobots.client_attribution import (
13+
CLIENT_HOP,
14+
UNKNOWN_VERSION,
15+
WHEROBOTS_CLIENT_HEADER,
16+
client_attribution_header,
17+
)
18+
from airflow_providers_wherobots.hooks.base import PACKAGE_NAME
19+
20+
# One hop: `client=<token>` plus zero or more `;key=value` params, where no
21+
# value may contain the `,` / `;` delimiters.
22+
HOP_PATTERN = re.compile(r"^client=[^,;]+(?:;[^,;=]+=[^,;]*)*$")
23+
24+
25+
def test_header_name_and_hop_format() -> None:
26+
"""The hook emits exactly one canonical header carrying one well-formed hop."""
27+
header = client_attribution_header()
28+
assert list(header) == [WHEROBOTS_CLIENT_HEADER]
29+
assert WHEROBOTS_CLIENT_HEADER == "X-Wherobots-Client"
30+
31+
hop = header[WHEROBOTS_CLIENT_HEADER]
32+
assert HOP_PATTERN.match(hop)
33+
assert hop == f"client=airflow;ver={metadata.version(PACKAGE_NAME)}"
34+
35+
36+
def test_hop_is_within_the_length_bound() -> None:
37+
"""The chain is bounded to 512 bytes, and tokens to 64 characters."""
38+
assert len(CLIENT_HOP.encode("utf-8")) <= 512
39+
assert len("airflow") <= 64
40+
41+
42+
def test_unresolvable_version_falls_back_to_unknown(mocker: MockerFixture) -> None:
43+
"""A provider that isn't installed still emits a well-formed hop."""
44+
mocker.patch.object(
45+
client_attribution.metadata,
46+
"version",
47+
side_effect=metadata.PackageNotFoundError(PACKAGE_NAME),
48+
)
49+
assert client_attribution._resolve_provider_version() == UNKNOWN_VERSION
50+
51+
# The hop is built once at import, so reload the module under the patch to
52+
# observe the header a never-installed provider would actually send.
53+
try:
54+
reloaded = importlib.reload(client_attribution)
55+
assert reloaded.client_attribution_header() == {
56+
WHEROBOTS_CLIENT_HEADER: "client=airflow;ver=unknown"
57+
}
58+
assert HOP_PATTERN.match(reloaded.CLIENT_HOP)
59+
finally:
60+
mocker.stopall()
61+
importlib.reload(client_attribution)
62+
63+
64+
def test_delimiters_are_stripped_from_the_version(mocker: MockerFixture) -> None:
65+
"""A version can never smuggle a hop or field separator into the grammar."""
66+
mocker.patch.object(
67+
client_attribution.metadata, "version", return_value="1.0;0,dev"
68+
)
69+
try:
70+
reloaded = importlib.reload(client_attribution)
71+
assert reloaded.CLIENT_HOP == "client=airflow;ver=1.0_0_dev"
72+
assert HOP_PATTERN.match(reloaded.CLIENT_HOP)
73+
finally:
74+
mocker.stopall()
75+
importlib.reload(client_attribution)

0 commit comments

Comments
 (0)