Skip to content
Open
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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
Expand Down
1 change: 1 addition & 0 deletions change-log.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 2 additions & 0 deletions umami/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
Expand Down
96 changes: 95 additions & 1 deletion umami/tests/test_revenue.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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']
Expand Down Expand Up @@ -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()
Expand Down
37 changes: 36 additions & 1 deletion umami/umami/impl/__init__.py
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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)

Expand Down Expand Up @@ -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:
Expand All @@ -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
Expand All @@ -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)

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand All @@ -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)

Expand All @@ -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:
Expand All @@ -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
Expand All @@ -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)

Expand All @@ -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)
Expand Down