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
2 changes: 2 additions & 0 deletions Umami.Net.Test/Consts.cs
Original file line number Diff line number Diff line change
Expand Up @@ -24,4 +24,6 @@ public class Consts
public const string UserName = "Test User";

public const string SessionId = "B41A9964-FD33-4108-B6EC-9A6B68150763";

public const string DistinctId = "B41A9964-FD33-4108-B6EC-9A6B68150764";
}
2 changes: 1 addition & 1 deletion Umami.Net.Test/UmamiBackgroundSender_Tests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -401,7 +401,7 @@ public async Task IdentifySession()
var cancellationToken = new CancellationToken();
await hostedService.StartAsync(cancellationToken);

await backgroundSender.IdentifySession(sessionId, new UmamiEventData { { key, value } });
await backgroundSender.IdentifySession(sessionId, eventData: new UmamiEventData { { key, value } });

var completedTask = await Task.WhenAny(tcs.Task, Task.Delay(1000, cancellationToken));
if (completedTask != tcs.Task) throw new TimeoutException("The background task did not complete in time.");
Expand Down
25 changes: 25 additions & 0 deletions Umami.Net.Test/UmamiClientTests/UmamiClient_IdentifyTests.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
using System.Net.Http.Json;
using System.Text.Json;
using System.Text.Json.Serialization;
using Umami.Net.Models;
using Umami.Net.Test.Extensions;
using Umami.Net.Test.MessageHandlers;
Expand Down Expand Up @@ -60,6 +62,29 @@ public async Task Identify()
Assert.Equal(Consts.UserId, content.Payload.Data["userId"].ToString());
}

[Fact]
public async Task IdentifySession_WithDistinctId_FlowsToPayloadIdField()
{
var umamiClient = SetupExtensions.GetUmamiClient();
var response = await umamiClient.IdentifySession(Consts.SessionId, Consts.DistinctId);

// Read response as string to validate JSON serialization
var responseString = await response.Content.ReadAsStringAsync();
using var jsonDocument = JsonDocument.Parse(responseString);
var idField = jsonDocument.RootElement
.GetProperty("payload")
.GetProperty("id")
.GetString();
Assert.Equal(Consts.DistinctId, idField);

var content = await response.Content.ReadFromJsonAsync<EchoedRequest>();
Assert.NotNull(response);
Assert.NotNull(content);
Assert.NotNull(content.Payload);
Assert.Equal(Consts.DistinctId, content.Payload.DistinctId);

}


private UmamiEventData BuildEventData(string? email, string? username, string? userId, UmamiEventData? eventData)
{
Expand Down
19 changes: 19 additions & 0 deletions Umami.Net.Test/UmamiClientTests/UmamiClient_SendTests.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using Umami.Net.Models;
using Umami.Net.Test.Extensions;
using Umami.Net.Test.MessageHandlers;

namespace Umami.Net.Test.UmamiClientTests;

Expand All @@ -19,4 +23,19 @@ public async Task Send_Empty_Success()
var response = await umamiClient.Send();
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
}

[Fact]
public async Task Send_WithDistinctId_FlowsToPayloadIdField()
{
var payload = new UmamiPayload { DistinctId = Consts.DistinctId };
var umamiClient = SetupExtensions.GetUmamiClient();
var response = await umamiClient.Send(payload);


var content = await response.Content.ReadFromJsonAsync<EchoedRequest>();
Assert.NotNull(response);
Assert.NotNull(content);
Assert.NotNull(content.Payload);
Assert.Equal(Consts.DistinctId, content.Payload.DistinctId);
}
}
56 changes: 56 additions & 0 deletions Umami.Net.Test/UmamiClientTests/UmamiClient_TrackTests.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using Umami.Net.Models;
using Umami.Net.Test.Extensions;
using Umami.Net.Test.MessageHandlers;
Expand Down Expand Up @@ -65,4 +66,59 @@ public async Task Track_FullEvent()
Assert.NotNull(content.Payload.Data);
Assert.Equal("value", content.Payload.Data["string"].ToString());
}

[Fact]
public async Task TrackPageView_WithDistinctId_FlowsToPayloadIdField()
{
var umamiClient = SetupExtensions.GetUmamiClient();

var response = await umamiClient.TrackPageView("https://example.com", "Example Page", distinctId: Consts.DistinctId);

// Assert - Read response as string to validate JSON serialization
var responseString = await response.Content.ReadAsStringAsync();
using var jsonDocument = JsonDocument.Parse(responseString);

var idField = jsonDocument.RootElement
.GetProperty("payload")
.GetProperty("id")
.GetString();

Assert.NotNull(response);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.Equal(Consts.DistinctId, idField);


var content = await response.Content.ReadFromJsonAsync<EchoedRequest>();
Assert.NotNull(response);
Assert.NotNull(content);
Assert.NotNull(content.Payload);
Assert.Equal(Consts.DistinctId, content.Payload.DistinctId);
}

[Fact]
public async Task Track_WithDistinctId_FlowsToPayloadIdField()
{
var umamiClient = SetupExtensions.GetUmamiClient();

var response = await umamiClient.Track(Consts.DefaultName, distinctId: Consts.DistinctId);

var responseString = await response.Content.ReadAsStringAsync();
using var jsonDocument = JsonDocument.Parse(responseString);

var idField = jsonDocument.RootElement
.GetProperty("payload")
.GetProperty("id")
.GetString();

Assert.NotNull(response);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.Equal(Consts.DistinctId, idField);


var content = await response.Content.ReadFromJsonAsync<EchoedRequest>();
Assert.NotNull(response);
Assert.NotNull(content);
Assert.NotNull(content.Payload);
Assert.Equal(Consts.DistinctId, content.Payload.DistinctId);
}
}
2 changes: 2 additions & 0 deletions Umami.Net/Models/UmamiPayload.cs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ public class UmamiPayload
public string? Name { get; set; }

public string? SessionId { get; set; }

[JsonPropertyName("id")] public string? DistinctId { get; set; }

Comment on lines 20 to 23
Copy link

Copilot AI Mar 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

UmamiPayload.Id is very generic and doesn’t communicate that it represents Umami’s distinct/unique identifier (issue #88). Consider renaming this to something explicit like DistinctId/UniqueId and mapping it to the expected JSON field name via [JsonPropertyName("id")] (or the exact key Umami expects), so the public API is self-describing without relying on the global naming policy.

Copilot uses AI. Check for mistakes.
[JsonPropertyName("ip")] public string? IpAddress { get; set; }

Expand Down
2 changes: 2 additions & 0 deletions Umami.Net/PayloadService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,8 @@ public UmamiPayload PopulateFromPayload(UmamiPayload? payload, UmamiEventData? d
if (payload.SessionId != null)
newPayload.SessionId = payload.SessionId;

if (payload.DistinctId != null)
newPayload.DistinctId = payload.DistinctId;

newPayload.UserAgent = payload.UserAgent ?? DefaultUserAgent;

Comment on lines 59 to 66
Copy link

Copilot AI Mar 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PayloadService.PopulateFromPayload now propagates payload.Id, but there’s no test coverage validating that this propagation works end-to-end (i.e., a provided distinct/unique id survives population and is sent). Consider adding a test that sets UmamiPayload.Id (or the renamed property) and verifies it is present in the final request JSON.

Copilot uses AI. Check for mistakes.
Expand Down
6 changes: 4 additions & 2 deletions Umami.Net/UmamiBackgroundSender.cs
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ public async Task TrackPageView(string url, string title, UmamiPayload? payload

public async Task Identify(string? email = null, string? username = null,
string? sessionId = null, string? userId = null, UmamiEventData? eventData = null,
bool useDefaultUserAgent = false)
bool useDefaultUserAgent = false, string? distinctId = null)
{
await using var scope = scopeFactory.CreateAsyncScope();
eventData ??= new UmamiEventData();
Expand All @@ -71,6 +71,7 @@ public async Task Identify(string? email = null, string? username = null,
{
Data = eventData,
SessionId = sessionId,
DistinctId = distinctId,
UseDefaultUserAgent = useDefaultUserAgent
};
var payloadService = scope.ServiceProvider.GetRequiredService<PayloadService>();
Expand All @@ -79,12 +80,13 @@ public async Task Identify(string? email = null, string? username = null,
}

public async Task IdentifySession(string sessionId, UmamiEventData? eventData = null,
bool useDefaultUserAgent = false)
bool useDefaultUserAgent = false, string? distinctId = null)
{
await using var scope = scopeFactory.CreateAsyncScope();
var thisPayload = new UmamiPayload
{
SessionId = sessionId,
DistinctId = distinctId,
Data = eventData ?? new UmamiEventData(),
UseDefaultUserAgent = useDefaultUserAgent
};
Expand Down
44 changes: 29 additions & 15 deletions Umami.Net/UmamiClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -71,9 +71,10 @@ public async Task<HttpResponseMessage> Send(
string? url = "",
string? title = "",
UmamiPayload? payload = null,
UmamiEventData? eventData = null)
UmamiEventData? eventData = null,
string? distinctId = null)
{
var response = await TrackPageView(url, title, payload, eventData);
var response = await TrackPageView(url, title, payload, eventData, distinctId);
return await DecodeResponse(response);
}

Expand All @@ -82,52 +83,62 @@ public async Task<HttpResponseMessage> TrackPageView(
string? url = "",
string? title = "",
UmamiPayload? payload = null,
UmamiEventData? eventData = null)
UmamiEventData? eventData = null,
string? distinctId = null)
{
var sendPayload = payload ?? new UmamiPayload();
sendPayload.Data = eventData;
if (!string.IsNullOrEmpty(url))
sendPayload.Url = url;
if (!string.IsNullOrEmpty(title))
sendPayload.Title = title;
if (!string.IsNullOrEmpty(distinctId))
sendPayload.DistinctId = distinctId;
return await Send(sendPayload);
}


public async Task<UmamiDataResponse?> TrackAndDecode(
string eventName,
UmamiEventData? eventData = null)
UmamiEventData? eventData = null,
string? distinctId = null)
{
var response = await Track(eventName, eventData);
var response = await Track(eventName, eventData, distinctId);
return await DecodeResponse(response);
}

public async Task<UmamiDataResponse?> TrackAndDecode(
UmamiPayload eventObj,
UmamiEventData? eventData = null)
UmamiEventData? eventData = null,
string? distinctId = null)
{
var response = await Track(eventObj, eventData);
var response = await Track(eventObj, eventData, distinctId);
return await DecodeResponse(response);
}

public async Task<HttpResponseMessage> Track(
string eventName,
UmamiEventData? eventData = null)
UmamiEventData? eventData = null,
string? distinctId = null)
{
var thisPayload = new UmamiPayload
{
Name = eventName,
Data = eventData ?? new UmamiEventData()
Data = eventData ?? new UmamiEventData(),
DistinctId = distinctId
};
return await Track(thisPayload);
}

public async Task<HttpResponseMessage> Track(UmamiPayload eventObj,
UmamiEventData? eventData = null)
UmamiEventData? eventData = null,
string? distinctId = null)
{
var payload = eventObj;
payload.Data = eventData ?? new UmamiEventData();
payload.Website = settings.WebsiteId;
if (!string.IsNullOrEmpty(distinctId))
payload.DistinctId = distinctId;
return await Send(payload);
}

Expand Down Expand Up @@ -172,9 +183,11 @@ public async Task<HttpResponseMessage> Track(UmamiPayload eventObj,

public async Task<UmamiDataResponse?> IdentifyAndDecode(string sessionId, string? email = null,
string? username = null,
string? userId = null, UmamiEventData? eventData = null)
string? userId = null,
UmamiEventData? eventData = null,
string? distinctId = null)
{
var response = await Identify(email, username, sessionId, userId, eventData);
var response = await Identify(email, username, sessionId, userId, eventData, distinctId);
return await DecodeResponse(response);
}

Expand All @@ -186,21 +199,22 @@ public async Task<HttpResponseMessage> Identify(UmamiPayload payload, UmamiEvent
}

public async Task<HttpResponseMessage> Identify(string? email = null, string? username = null,
string? sessionId = null, string? userId = null, UmamiEventData? eventData = null)
string? sessionId = null, string? userId = null, UmamiEventData? eventData = null, string? distinctId = null)
{
Comment on lines 201 to 203
Copy link

Copilot AI Mar 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adding the new optional parameter distinctId before eventData is a source/binary breaking change for any callers using positional arguments (e.g., Identify(..., eventData) will no longer compile). To maintain backward compatibility, keep the existing parameter order and add an overload (or move distinctId to the end) so existing positional call sites remain valid.

Copilot uses AI. Check for mistakes.
var emailData = BuildEventData(email, username, userId, eventData);
var payload = new UmamiPayload
{
SessionId = sessionId,
DistinctId = distinctId,
Data = emailData
};
Comment on lines 205 to 210
Copy link

Copilot AI Mar 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

New behavior: distinctId is now forwarded into the payload (Id = distinctId), but there are no assertions in the test suite that this value is actually serialized/sent when provided. Add/extend tests (client and/or background sender) to pass a non-null distinctId and assert it appears in the outgoing payload, so regressions are caught.

Copilot uses AI. Check for mistakes.

return await Identify(payload, eventData);
}

public async Task<HttpResponseMessage> IdentifySession(string sessionId)
public async Task<HttpResponseMessage> IdentifySession(string sessionId, string? distinctId = null)
{
return await Identify(sessionId: sessionId);
return await Identify(sessionId: sessionId, distinctId: distinctId);
}

public async Task<UmamiDataResponse?> IdentifySessionAndDecode(string sessionId)
Expand Down