diff --git a/ckanext/analytics/event.py b/ckanext/analytics/event.py index 7aa8af7..e7ac953 100644 --- a/ckanext/analytics/event.py +++ b/ckanext/analytics/event.py @@ -10,6 +10,10 @@ which is why this hangs off ``request_finished`` and not ``action_succeeded`` - the latter fires only on success, so every 403, 409 and 500 would be missing. +A request carrying the configured ignore header/value (``CKANEXT_ANALYTICS_IGNORE_HEADER`` +/ ``CKANEXT_ANALYTICS_IGNORE_VALUES``) is skipped entirely - not logged with a +distinguishing field, just never recorded. + ``Attribution`` holds the rules, ``RequestEvent`` turns a request into a dict, and ``record_request`` is the listener CKAN calls. Flask only, no CKAN import, so the whole lifecycle can be driven against a stub app and the real signal. @@ -36,6 +40,18 @@ #: Named in every event, so one stream can carry more than one service. SERVICE = os.environ.get("CKANEXT_ANALYTICS_SERVICE", "ckan") +#: A request whose ``IGNORE_HEADER`` value is in ``IGNORE_VALUES`` skips +#: analytics entirely - not logged with a distinguishing field, just never +#: recorded. Known internal callers (e.g. the data explorer) set this so +#: their UI-driven traffic never counts as API usage. Either empty disables +#: the check. Comma-separated, matched case-insensitively. +IGNORE_HEADER = os.environ.get("CKANEXT_ANALYTICS_IGNORE_HEADER", "") +IGNORE_VALUES = frozenset( + v.strip().lower() + for v in os.environ.get("CKANEXT_ANALYTICS_IGNORE_VALUES", "").split(",") + if v.strip() +) + class Attribution: """Which entity an action refers to, and in which parameter. @@ -152,8 +168,18 @@ def from_request(cls, request: Any, response: Any) -> RequestEvent | None: request.endpoint ): return None + if cls.is_ignored(request): + return None return cls(request, response) + @staticmethod + def is_ignored(request: Any) -> bool: + """Whether ``IGNORE_HEADER`` / ``IGNORE_VALUES`` say to skip this request.""" + if not IGNORE_HEADER or not IGNORE_VALUES: + return False + value = request.headers.get(IGNORE_HEADER) + return value is not None and value.strip().lower() in IGNORE_VALUES + @staticmethod def is_download_endpoint(endpoint: str | None) -> bool: """A resource blueprint's download view, whoever provides it. diff --git a/ckanext/analytics/tests/test_event.py b/ckanext/analytics/tests/test_event.py index 757abda..dec0fb7 100644 --- a/ckanext/analytics/tests/test_event.py +++ b/ckanext/analytics/tests/test_event.py @@ -215,6 +215,49 @@ def test_the_ip_falls_back_to_the_peer_for_a_direct_call(client, recorded): assert recorded[0]["request_ip"] == "127.0.0.1" +def test_a_request_with_the_ignore_header_is_not_recorded(client, recorded, monkeypatch): + monkeypatch.setattr(event, "IGNORE_HEADER", "Request-Source") + monkeypatch.setattr(event, "IGNORE_VALUES", frozenset({"data-explorer"})) + + client.get("/api/3/action/package_show", headers={"Request-Source": "data-explorer"}) + + assert recorded == [] + + +def test_the_ignore_header_match_is_case_insensitive(client, recorded, monkeypatch): + monkeypatch.setattr(event, "IGNORE_HEADER", "Request-Source") + monkeypatch.setattr(event, "IGNORE_VALUES", frozenset({"data-explorer"})) + + client.get("/api/3/action/package_show", headers={"request-source": "Data-Explorer"}) + + assert recorded == [] + + +def test_a_request_without_the_ignore_header_is_still_recorded(client, recorded, monkeypatch): + monkeypatch.setattr(event, "IGNORE_HEADER", "Request-Source") + monkeypatch.setattr(event, "IGNORE_VALUES", frozenset({"data-explorer"})) + + client.get("/api/3/action/package_show") + + assert len(recorded) == 1 + + +def test_a_request_with_a_different_header_value_is_still_recorded(client, recorded, monkeypatch): + monkeypatch.setattr(event, "IGNORE_HEADER", "Request-Source") + monkeypatch.setattr(event, "IGNORE_VALUES", frozenset({"data-explorer"})) + + client.get("/api/3/action/package_show", headers={"Request-Source": "some-other-tool"}) + + assert len(recorded) == 1 + + +def test_the_ignore_check_is_disabled_when_unconfigured(client, recorded): + """Default (module-level IGNORE_HEADER/IGNORE_VALUES empty) never skips.""" + client.get("/api/3/action/package_show", headers={"Request-Source": "data-explorer"}) + + assert len(recorded) == 1 + + def test_user_agent_is_none_when_the_client_sends_none(client, recorded): client.get("/api/3/action/package_show", headers={"User-Agent": ""})