Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions ckanext/analytics/event.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.
Expand Down Expand Up @@ -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.
Expand Down
43 changes: 43 additions & 0 deletions ckanext/analytics/tests/test_event.py
Original file line number Diff line number Diff line change
Expand Up @@ -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": ""})

Expand Down
Loading