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
39 changes: 22 additions & 17 deletions examples/audience/Assets/SampleApp/Scripts/AudienceSample.cs
Original file line number Diff line number Diff line change
Expand Up @@ -160,9 +160,6 @@ private void OnSendCatalogueEvent(EventSpec spec, Dictionary<string, VisualEleme
}, 2);
});

// SDK drops via Log.Warn when name is empty or consent is None; that
// warning surfaces in the pane via Log.Writer, so no sample-side
// check is needed beyond GuardConsentForTrack.
private void OnSendCustomEvent() => RunAndLog("track()", () =>
{
GuardConsentForTrack();
Expand Down Expand Up @@ -205,10 +202,10 @@ private void OnIdentify() => RunAndLog("identify()", () =>
var f = CaptureIdentifyForm();
var traits = ParseTraits(f.RawTraits);
ImmutableAudience.Identify(f.Id, ParseIdentityType(f.Type), traits);
// SDK drops via Log.Warn when id is empty or consent < Full. Mirror
// only when accepted; otherwise the panel would show stale state.
var accepted = !string.IsNullOrEmpty(f.Id)
&& string.Equals(ImmutableAudience.UserId, f.Id, StringComparison.Ordinal);
// Empty ids and malformed passport ids throw, caught by RunAndLog
// before this line runs. The one silent no-op left is consent
// below Full, which the UserId check below still catches.
var accepted = string.Equals(ImmutableAudience.UserId, f.Id, StringComparison.Ordinal);
if (accepted) { _mirrorIdentityType = f.Type; _mirrorTraits = traits; }
OnSdkStateChanged();
var payload = new Dictionary<string, object>
Expand Down Expand Up @@ -237,20 +234,16 @@ private void OnAlias() => RunAndLog("alias()", () =>
{
var f = CaptureAliasForm();
ImmutableAudience.Alias(f.FromId, ParseIdentityType(f.FromType), f.ToId, ParseIdentityType(f.ToType));
// SDK drops via Log.Warn when fromId/toId is empty or consent < Full.
// The IsAliasReady gate keeps empty endpoints unreachable from the
// UI; this post-call check is defense-in-depth.
var accepted = !string.IsNullOrEmpty(f.FromId) && !string.IsNullOrEmpty(f.ToId);
if (accepted)
{
_mirrorAliases.Add($"{f.FromType}:{f.FromId} → {f.ToType}:{f.ToId}");
OnSdkStateChanged();
}
// Empty/identical/malformed ids throw, caught by RunAndLog before this
// line runs. Consent below Full still drops silently inside the SDK,
// with no readable property here to detect it (unlike Identify's UserId).
_mirrorAliases.Add($"{f.FromType}:{f.FromId} → {f.ToType}:{f.ToId}");
OnSdkStateChanged();
return Json.Serialize(new Dictionary<string, object>
{
["from"] = new Dictionary<string, object> { ["id"] = f.FromId, ["identityType"] = f.FromType },
["to"] = new Dictionary<string, object> { ["id"] = f.ToId, ["identityType"] = f.ToType },
["accepted"] = accepted,
["accepted"] = true,
}, 2);
});

Expand Down Expand Up @@ -292,6 +285,18 @@ private void RouteSdkLogToPane(string msg)
{
UnityEngine.Debug.Log($"[Audience] {body}");
}

if (body.StartsWith("flush ok", StringComparison.Ordinal))
{
AppendLog(body, null, LogLevel.Ok, LogSource.Sdk);
return;
}
if (body.StartsWith("flush failed", StringComparison.Ordinal))
{
AppendLog(body, null, LogLevel.Err, LogSource.Sdk);
return;
}

AppendLog("sdk", body, level, LogSource.Sdk);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -253,14 +253,16 @@ public IEnumerator Identify_AndFlush_FlushReportsOk()
}

[UnityTest]
public IEnumerator Identify_PassportWithInvalidIdFormat_WarnsAndDropsCall()
public IEnumerator Identify_PassportWithInvalidIdFormat_Throws()
{
// Identity type dropdown defaults to Passport; "12345" isn't Passport-shaped.
// ImmutableAudience.Identify throws on this; RunAndLog catches it and
// renders an identify()@Err row.
yield return LoadAndInit(initialConsent: SampleAppUi.Consent.Full);

_root!.Q<TextField>(SampleAppUi.IdentityFields.Id).value = "12345";
_root.Q<Button>(SampleAppUi.Buttons.Identify).Click();
yield return SampleAppTestHelpers.WaitForLogEntry(_root, SampleAppUi.LogLabels.Sdk, LogLevels.Warn, 5f);
yield return SampleAppTestHelpers.WaitForLogEntry(_root, SampleAppUi.LogLabels.Identify, LogLevels.Err, 5f);

Assert.IsNull(ImmutableAudience.UserId,
"an invalid Passport ID must be a full no-op, not just logged");
Expand All @@ -280,6 +282,23 @@ public IEnumerator Alias_AndFlush_FlushReportsOk()
yield return FlushAndAssertNoErrors();
}

[UnityTest]
public IEnumerator Alias_SameId_Throws()
{
// Type-independence (same id, different identityType is still
// rejected) is covered by the fast unit test
// Alias_IdenticalIdsDifferentTypes_StillThrows; this only needs to
// prove the UI path wires up to that same check. ImmutableAudience.Alias
// throws on this; RunAndLog catches it and renders an alias()@Err row.
yield return LoadAndInit(initialConsent: SampleAppUi.Consent.Full);

var sameId = "email|same-" + DateTime.UtcNow.Ticks;
_root!.Q<TextField>(SampleAppUi.IdentityFields.AliasFromId).value = sameId;
_root.Q<TextField>(SampleAppUi.IdentityFields.AliasToId).value = sameId;
_root.Q<Button>(SampleAppUi.Buttons.Alias).Click();
yield return SampleAppTestHelpers.WaitForLogEntry(_root, SampleAppUi.LogLabels.Alias, LogLevels.Err, 5f);
}

[UnityTest]
public IEnumerator SetConsent_None_KeepsQueueAndPersists()
{
Expand Down
1 change: 1 addition & 0 deletions src/Packages/Audience/Runtime/Core/Constants.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ internal static class Constants
internal const int DefaultFlushSize = 20;
internal const int MaxBatchSize = 100;
internal const int StaleEventDays = 30;
internal const int MaxClockSkewFutureHours = 24; // Backend rejects eventTimestamp further ahead than this.
internal const int MaxFieldLength = 256; // Backend schema limit.
internal const int ControlPlaneRequestTimeoutSeconds = 30;

Expand Down
72 changes: 28 additions & 44 deletions src/Packages/Audience/Runtime/ImmutableAudience.cs
Original file line number Diff line number Diff line change
Expand Up @@ -326,16 +326,15 @@ internal static void OnResume()
/// Sends a typed event. Prefer over the string overload for
/// compile-time required-field validation.
/// </summary>
/// <param name="evt">The event to send.</param>
/// <param name="evt">The event to send. Must not be null, and <see cref="IEvent.EventName"/>
/// must not be empty (both throw).</param>
public static void Track(IEvent evt)
{
if (!_initialized) return;
if (evt == null) throw new ArgumentNullException(nameof(evt), AudienceLogs.TrackIEventNull);

var state = _state;
if (!_initialized || !state.Level.CanTrack()) return;
if (evt == null)
{
Log.Warn(AudienceLogs.TrackIEventNull);
return;
}
if (!state.Level.CanTrack()) return;

var config = _config;
if (config == null) return;
Expand All @@ -355,10 +354,7 @@ public static void Track(IEvent evt)
}

if (string.IsNullOrEmpty(eventName))
{
Log.Warn(AudienceLogs.TrackIEventEmptyName(evt.GetType().Name));
return;
}
throw new ArgumentException(AudienceLogs.TrackIEventEmptyName(evt.GetType().Name), nameof(evt));

// ToProperties returns a fresh dict per call, so no snapshot needed.
EnqueueTrackedEvent(eventName, properties, _session?.SessionId, state, config);
Expand All @@ -369,17 +365,16 @@ public static void Track(IEvent evt)
/// <c>resource</c>, and <c>milestone_reached</c>, prefer the typed
/// overload.
/// </summary>
/// <param name="eventName">The wire-format event name.</param>
/// <param name="eventName">The wire-format event name. Must not be empty (throws otherwise).</param>
/// <param name="properties">Optional event properties.</param>
public static void Track(string eventName, Dictionary<string, object>? properties = null)
{
var state = _state;
if (!_initialized || !state.Level.CanTrack()) return;
if (!_initialized) return;
if (string.IsNullOrEmpty(eventName))
{
Log.Warn(AudienceLogs.TrackStringEmptyName);
return;
}
throw new ArgumentException(AudienceLogs.TrackStringEmptyName, nameof(eventName));

var state = _state;
if (!state.Level.CanTrack()) return;

var config = _config;
if (config == null) return;
Expand Down Expand Up @@ -423,8 +418,7 @@ private static void TrackFromSession(string eventName, Dictionary<string, object
/// <summary>
/// Attaches a known user ID to subsequent events. When <paramref name="identityType"/> is
/// <see cref="IdentityType.Passport"/>, <paramref name="userId"/> must look like a real
/// Passport ID (<c>connection|id</c> or a UUID) — otherwise the call is dropped and a
/// warning is logged.
/// Passport ID (<c>connection|id</c> or a UUID); otherwise this throws.
/// </summary>
/// <param name="userId">The player's identifier within the chosen provider.</param>
/// <param name="identityType">The identity provider that issued <paramref name="userId"/>.</param>
Expand All @@ -433,21 +427,16 @@ public static void Identify(string userId, IdentityType identityType, Dictionary
{
if (!_initialized) return;

// Validate inputs before consent so null-arg callers get the right warning.
// Validate inputs before consent so a caller bug throws even when
// consent is below Full (a no-op should never mask a bad argument).
if (string.IsNullOrEmpty(userId))
{
Log.Warn(AudienceLogs.IdentifyEmptyUserId);
return;
}
throw new ArgumentException(AudienceLogs.IdentifyEmptyUserId, nameof(userId));

// Trim once: the validated id and the stored/sent id must match.
userId = userId.Trim();

if (identityType == IdentityType.Passport && !IsValidPassportId(userId))
{
Log.Warn(AudienceLogs.IdentifyPassportIdInvalidFormat(userId));
return;
}
throw new ArgumentException(AudienceLogs.IdentifyPassportIdInvalidFormat(userId), nameof(userId));

AudienceConfig? config;
ConsentLevel level;
Expand Down Expand Up @@ -482,10 +471,11 @@ private static bool IsValidPassportId(string id)
}

/// <summary>
/// Links two user IDs for the same player. When either side's identity
/// Links two user IDs for the same player. <paramref name="fromId"/> and
/// <paramref name="toId"/> must differ. When either side's identity
/// type is <see cref="IdentityType.Passport"/>, that side's id must look
/// like a real Passport ID (<c>connection|id</c> or a UUID) otherwise
/// the call is dropped and a warning is logged.
/// like a real Passport ID (<c>connection|id</c> or a UUID); otherwise
/// this throws.
/// </summary>
/// <param name="fromId">The previously-known identifier.</param>
/// <param name="fromType">Identity provider for <paramref name="fromId"/>.</param>
Expand All @@ -496,25 +486,19 @@ public static void Alias(string fromId, IdentityType fromType, string toId, Iden
if (!_initialized) return;

if (string.IsNullOrEmpty(fromId) || string.IsNullOrEmpty(toId))
{
Log.Warn(AudienceLogs.AliasEmptyIds);
return;
}
throw new ArgumentException(AudienceLogs.AliasEmptyIds);

// Trim once: the validated ids and the sent ids must match.
fromId = fromId.Trim();
toId = toId.Trim();

if (fromId == toId)
throw new ArgumentException(AudienceLogs.AliasIdenticalIds);

if (fromType == IdentityType.Passport && !IsValidPassportId(fromId))
{
Log.Warn(AudienceLogs.AliasPassportIdInvalidFormat("from", fromId));
return;
}
throw new ArgumentException(AudienceLogs.AliasPassportIdInvalidFormat("from", fromId), nameof(fromId));
if (toType == IdentityType.Passport && !IsValidPassportId(toId))
{
Log.Warn(AudienceLogs.AliasPassportIdInvalidFormat("to", toId));
return;
}
throw new ArgumentException(AudienceLogs.AliasPassportIdInvalidFormat("to", toId), nameof(toId));

var state = _state;
if (!state.Level.CanIdentify())
Expand Down
15 changes: 10 additions & 5 deletions src/Packages/Audience/Runtime/Transport/DiskStore.cs
Original file line number Diff line number Diff line change
Expand Up @@ -39,16 +39,21 @@ internal void Write(string json)
}
}

// Returns up to maxSize file paths, oldest first. Stale files
// (older than Constants.StaleEventDays) are deleted and excluded.
// Returns up to maxSize file paths, oldest first. Files outside the
// backend's accepted eventTimestamp window (more than StaleEventDays
// in the past, or more than MaxClockSkewFutureHours in the future --
// e.g. from a device with a badly-skewed system clock) are deleted
// and excluded: the backend would reject them anyway.
internal IReadOnlyList<string> ReadBatch(int maxSize)
{
if (maxSize <= 0)
return Array.Empty<string>();

maxSize = Math.Min(maxSize, Constants.MaxBatchSize);

var cutoff = DateTime.UtcNow.AddDays(-Constants.StaleEventDays);
var now = DateTime.UtcNow;
var pastCutoff = now.AddDays(-Constants.StaleEventDays);
var futureCutoff = now.AddHours(Constants.MaxClockSkewFutureHours);

var result = new List<string>();

Expand All @@ -65,13 +70,13 @@ internal IReadOnlyList<string> ReadBatch(int maxSize)
if (result.Count >= maxSize)
break;

// Stale check: parse ticks from filename prefix
// Window check: parse ticks from filename prefix
var name = Path.GetFileNameWithoutExtension(path);
var underscoreIdx = name.IndexOf('_');
if (underscoreIdx > 0 && long.TryParse(name.Substring(0, underscoreIdx), out var ticks))
{
var fileTime = new DateTime(ticks, DateTimeKind.Utc);
if (fileTime < cutoff)
if (fileTime < pastCutoff || fileTime > futureCutoff)
{
TryDelete(path);
continue;
Expand Down
12 changes: 11 additions & 1 deletion src/Packages/Audience/Runtime/Transport/HttpTransport.cs
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,7 @@ internal async Task<bool> SendBatchAsync(CancellationToken ct = default)
NotifyError(AudienceErrorCode.ValidationRejected,
$"Batch partially rejected: {rejected} of {batch.Count} events dropped");
}
LogFlushOutcome(rejected == 0, batch.Count);
}
else if (statusCode == 429)
{
Expand All @@ -125,6 +126,7 @@ internal async Task<bool> SendBatchAsync(CancellationToken ct = default)
SetBackoffUntil(_getUtcNow() + retryAfter.Value);
else
RecordFailure();
LogFlushOutcome(false, batch.Count);
}
else if (statusCode >= 400 && statusCode < 500)
{
Expand All @@ -139,6 +141,7 @@ internal async Task<bool> SendBatchAsync(CancellationToken ct = default)
ResetBackoff();
NotifyError(AudienceErrorCode.ValidationRejected,
FormatHttpError("Batch rejected", statusCode, rejectionBody));
LogFlushOutcome(false, batch.Count);
}
else
{
Expand All @@ -148,6 +151,7 @@ internal async Task<bool> SendBatchAsync(CancellationToken ct = default)
RecordFailure();
NotifyError(AudienceErrorCode.FlushFailed,
FormatHttpError("Server error, will retry", statusCode, serverBody));
LogFlushOutcome(false, batch.Count);
}
}
catch (OperationCanceledException) when (ct.IsCancellationRequested)
Expand All @@ -166,6 +170,7 @@ internal async Task<bool> SendBatchAsync(CancellationToken ct = default)
{
RecordFailure();
NotifyError(AudienceErrorCode.NetworkError, ex.Message);
LogFlushOutcome(false, batch.Count);
}

return true;
Expand Down Expand Up @@ -342,5 +347,10 @@ private void NotifyError(AudienceErrorCode code, string message)
Log.Warn(AudienceLogs.OnErrorThrew(ex));
}
}

// Reports the real per-attempt result (2xx with zero rejections vs
// anything else), distinct from onError which only fires on failure.
private static void LogFlushOutcome(bool ok, int count) =>
Log.Debug(AudienceLogs.FlushOutcome(ok, count));
}
}
}
Loading
Loading