diff --git a/README.md b/README.md index 1c6d7c4..afe6e5a 100644 --- a/README.md +++ b/README.md @@ -96,6 +96,7 @@ event_resp = umami.new_event( hostname='somedomain.com', # Only send if overriding default above. url='/users/actions', custom_data={'client': 'umami-tester-v1'}, + distinct_id='user-123', referrer='https://some_url') # Create a new page view in the pages section of the dashboards. @@ -104,6 +105,7 @@ page_view_resp = umami.new_page_view( page_title='Umami-Test', # Defaults to event_name if omitted. hostname='somedomain.com', # Only send if overriding default above. url='/users/actions', + distinct_id='user-123', referrer='https://some_url') # Track revenue for a transaction diff --git a/change-log.md b/change-log.md index 1c1322f..17a1dd1 100644 --- a/change-log.md +++ b/change-log.md @@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- `distinct_id` support in `new_event` and `new_page_view` payloads, sent to Umami as payload field `id` in both sync and async variants ### Changed diff --git a/umami/README.md b/umami/README.md index 766ca93..5f82145 100644 --- a/umami/README.md +++ b/umami/README.md @@ -96,6 +96,7 @@ event_resp = umami.new_event( hostname='somedomain.com', # Only send if overriding default above. url='/users/actions', custom_data={'client': 'umami-tester-v1'}, + distinct_id='user-123', referrer='https://some_url') # Create a new page view in the pages section of the dashboards. @@ -104,6 +105,7 @@ page_view_resp = umami.new_page_view( page_title='Umami-Test', # Defaults to event_name if omitted. hostname='somedomain.com', # Only send if overriding default above. url='/users/actions', + distinct_id='user-123', referrer='https://some_url') # Track revenue for a transaction diff --git a/umami/tests/test_revenue.py b/umami/tests/test_revenue.py index 24acd7a..03320f7 100644 --- a/umami/tests/test_revenue.py +++ b/umami/tests/test_revenue.py @@ -19,6 +19,46 @@ def _setup_umami(): class TestNewRevenueEvent: """Tests for the sync new_revenue_event function.""" + @patch('umami.impl.httpx.post') + def test_new_event_includes_distinct_id(self, mock_post): + mock_resp = MagicMock() + mock_resp.json.return_value = {} + mock_resp.raise_for_status = MagicMock() + mock_post.return_value = mock_resp + + umami.new_event(event_name='signup', distinct_id='user-123') + + payload = mock_post.call_args.kwargs['json']['payload'] + assert payload['id'] == 'user-123' + + @patch('umami.impl.httpx.post') + def test_new_event_normalizes_integer_distinct_id(self, mock_post): + mock_resp = MagicMock() + mock_resp.json.return_value = {} + mock_resp.raise_for_status = MagicMock() + mock_post.return_value = mock_resp + + umami.new_event(event_name='signup', distinct_id=12345) + + payload = mock_post.call_args.kwargs['json']['payload'] + assert payload['id'] == '12345' + + def test_new_event_rejects_invalid_distinct_id_type(self): + with pytest.raises(ValidationError, match='string or integer'): + umami.new_event(event_name='signup', distinct_id=['bad-type']) # type: ignore[arg-type] + + @patch('umami.impl.httpx.post') + def test_new_page_view_includes_distinct_id(self, mock_post): + mock_resp = MagicMock() + mock_resp.json.return_value = {} + mock_resp.raise_for_status = MagicMock() + mock_post.return_value = mock_resp + + umami.new_page_view(page_title='Account', url='/account', distinct_id='user-456') + + payload = mock_post.call_args.kwargs['json']['payload'] + assert payload['id'] == 'user-456' + @patch('umami.impl.httpx.post') def test_default_revenue_event(self, mock_post): mock_resp = MagicMock() @@ -83,7 +123,10 @@ def test_revenue_currency_override_custom_data(self, mock_post): umami.new_revenue_event( revenue=30.00, currency='GBP', - custom_data={'revenue': 'should_be_overridden', 'currency': 'should_be_overridden'}, + custom_data={ + 'revenue': 'should_be_overridden', + 'currency': 'should_be_overridden', + }, ) payload = mock_post.call_args.kwargs['json']['payload'] @@ -136,6 +179,57 @@ def test_integer_revenue(self, mock_post): class TestNewRevenueEventAsync: """Tests for the async new_revenue_event_async function.""" + @pytest.mark.asyncio + async def test_new_event_async_includes_distinct_id(self): + mock_resp = MagicMock() + mock_resp.json.return_value = {} + mock_resp.raise_for_status = MagicMock() + + mock_client = AsyncMock() + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock(return_value=False) + mock_client.post = AsyncMock(return_value=mock_resp) + + with patch('umami.impl.httpx.AsyncClient', return_value=mock_client): + await umami.new_event_async(event_name='signup', distinct_id='user-123') + + payload = mock_client.post.call_args.kwargs['json']['payload'] + assert payload['id'] == 'user-123' + + @pytest.mark.asyncio + async def test_new_page_view_async_includes_distinct_id(self): + mock_resp = MagicMock() + mock_resp.json.return_value = {} + mock_resp.raise_for_status = MagicMock() + + mock_client = AsyncMock() + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock(return_value=False) + mock_client.post = AsyncMock(return_value=mock_resp) + + with patch('umami.impl.httpx.AsyncClient', return_value=mock_client): + await umami.new_page_view_async(page_title='Account', url='/account', distinct_id='user-456') + + payload = mock_client.post.call_args.kwargs['json']['payload'] + assert payload['id'] == 'user-456' + + @pytest.mark.asyncio + async def test_new_page_view_async_normalizes_integer_distinct_id(self): + mock_resp = MagicMock() + mock_resp.json.return_value = {} + mock_resp.raise_for_status = MagicMock() + + mock_client = AsyncMock() + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock(return_value=False) + mock_client.post = AsyncMock(return_value=mock_resp) + + with patch('umami.impl.httpx.AsyncClient', return_value=mock_client): + await umami.new_page_view_async(page_title='Account', url='/account', distinct_id=67890) + + payload = mock_client.post.call_args.kwargs['json']['payload'] + assert payload['id'] == '67890' + @pytest.mark.asyncio async def test_default_revenue_event_async(self): mock_resp = MagicMock() diff --git a/umami/umami/impl/__init__.py b/umami/umami/impl/__init__.py index 0193f24..9baf93b 100644 --- a/umami/umami/impl/__init__.py +++ b/umami/umami/impl/__init__.py @@ -1,6 +1,6 @@ import sys from datetime import datetime -from typing import Any, Dict, Optional +from typing import Any, Dict, Optional, Union import httpx @@ -34,6 +34,17 @@ ) +def normalize_distinct_id(distinct_id: Optional[Union[str, int]]) -> Optional[str]: + if distinct_id is None: + return None + + if isinstance(distinct_id, bool) or not isinstance(distinct_id, (str, int)): + raise ValidationError('distinct_id must be a string or integer.') + + normalized_distinct_id = str(distinct_id).strip() + return normalized_distinct_id or None + + def set_url_base(url: str) -> None: """ Each Umami instance lives somewhere. This is where yours lives. @@ -216,6 +227,7 @@ async def new_event_async( language: str = 'en-US', screen: str = '1920x1080', ip_address: Optional[str] = None, + distinct_id: Optional[Union[str, int]] = None, ) -> dict: """ Creates a new custom event in Umami for the given website_id and hostname (both use the default @@ -234,6 +246,7 @@ async def new_event_async( language: The language of the event / client. screen: The screen resolution of the client. ip_address: OPTIONAL: The true IP address of the user, used when handling requests in APIs, etc. on the server. + distinct_id: OPTIONAL: The Umami distinct ID for the user as a string or integer. Sent to the API as payload field id. Returns: The data returned from the Umami API. """ # noqa @@ -242,6 +255,7 @@ async def new_event_async( hostname = hostname or default_hostname title = title or event_name custom_data = custom_data or {} + normalized_distinct_id = normalize_distinct_id(distinct_id) validate_event_data(event_name, hostname, website_id) @@ -270,6 +284,9 @@ async def new_event_async( if ip_address and ip_address.strip(): payload['ip'] = ip_address + if normalized_distinct_id: + payload['id'] = normalized_distinct_id + event_data = {'payload': payload, 'type': 'event'} async with httpx.AsyncClient() as client: @@ -290,6 +307,7 @@ def new_event( language: str = 'en-US', screen: str = '1920x1080', ip_address: Optional[str] = None, + distinct_id: Optional[Union[str, int]] = None, ): """ Creates a new custom event in Umami for the given website_id and hostname (both use the default @@ -308,12 +326,14 @@ def new_event( language: The language of the event / client. screen: The screen resolution of the client. ip_address: OPTIONAL: The true IP address of the user, used when handling requests in APIs, etc. on the server. + distinct_id: OPTIONAL: The Umami distinct ID for the user as a string or integer. Sent to the API as payload field id. """ # noqa validate_state(url=True, user=False) website_id = website_id or default_website_id hostname = hostname or default_hostname title = title or event_name custom_data = custom_data or {} + normalized_distinct_id = normalize_distinct_id(distinct_id) validate_event_data(event_name, hostname, website_id) @@ -342,6 +362,9 @@ def new_event( if ip_address and ip_address.strip(): payload['ip'] = ip_address + if normalized_distinct_id: + payload['id'] = normalized_distinct_id + event_data = {'payload': payload, 'type': 'event'} resp = httpx.post(api_url, json=event_data, headers=headers, follow_redirects=True) @@ -474,6 +497,7 @@ async def new_page_view_async( screen: str = '1920x1080', ua: str = event_user_agent, ip_address: Optional[str] = None, + distinct_id: Optional[Union[str, int]] = None, ): """ Creates a new page view event in Umami for the given website_id and hostname (both use the default @@ -490,10 +514,12 @@ async def new_page_view_async( screen: OPTIONAL: The screen resolution of the client. ua: OPTIONAL: The UserAgent resolution of the client. Note umami blocks non browsers by default. ip_address: OPTIONAL: The true IP address of the user, used when handling requests in APIs, etc. on the server. + distinct_id: OPTIONAL: The Umami distinct ID for the user as a string or integer. Sent to the API as payload field id. """ # noqa validate_state(url=True, user=False) website_id = website_id or default_website_id hostname = hostname or default_hostname + normalized_distinct_id = normalize_distinct_id(distinct_id) validate_event_data(event_name='NOT NEEDED', hostname=hostname, website_id=website_id) @@ -520,6 +546,9 @@ async def new_page_view_async( if ip_address and ip_address.strip(): payload['ip'] = ip_address + if normalized_distinct_id: + payload['id'] = normalized_distinct_id + event_data = {'payload': payload, 'type': 'event'} async with httpx.AsyncClient() as client: @@ -537,6 +566,7 @@ def new_page_view( screen: str = '1920x1080', ua: str = event_user_agent, ip_address: Optional[str] = None, + distinct_id: Optional[Union[str, int]] = None, ): """ Creates a new page view event in Umami for the given website_id and hostname (both use the default @@ -553,10 +583,12 @@ def new_page_view( screen: OPTIONAL: The screen resolution of the client. ua: OPTIONAL: The UserAgent resolution of the client. Note umami blocks non browsers by default. ip_address: OPTIONAL: The true IP address of the user, used when handling requests in APIs, etc. on the server. + distinct_id: OPTIONAL: The Umami distinct ID for the user as a string or integer. Sent to the API as payload field id. """ # noqa validate_state(url=True, user=False) website_id = website_id or default_website_id hostname = hostname or default_hostname + normalized_distinct_id = normalize_distinct_id(distinct_id) validate_event_data(event_name='NOT NEEDED', hostname=hostname, website_id=website_id) @@ -583,6 +615,9 @@ def new_page_view( if ip_address and ip_address.strip(): payload['ip'] = ip_address + if normalized_distinct_id: + payload['id'] = normalized_distinct_id + event_data = {'payload': payload, 'type': 'event'} resp = httpx.post(api_url, json=event_data, headers=headers, follow_redirects=True)