Skip to content

Commit 44da6db

Browse files
authored
Merge pull request #823 from immutable/feat/sdk-687-reserved-event-validation
feat(audience): validate required properties for reserved events at runtime
2 parents c045e3b + 29d6623 commit 44da6db

10 files changed

Lines changed: 204 additions & 32 deletions

File tree

examples/audience/Assets/SampleApp/Scripts/AudienceSample.Events.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ internal readonly struct EventSpec
5353
new EventSpec("wishlist_remove", new[] { EventField.Text("gameId") }),
5454
new EventSpec("purchase", new[] {
5555
EventField.Text("currency"),
56-
EventField.Number("value"),
56+
EventField.Text("value"),
5757
EventField.Text("itemId", optional: true),
5858
EventField.Text("itemName", optional: true),
5959
EventField.Number("quantity", optional: true),
@@ -129,7 +129,7 @@ internal readonly struct EventSpec
129129
return new Purchase
130130
{
131131
Currency = OptionalString(props, "currency") ?? "",
132-
Value = OptionalDecimal(props, "value") ?? 0m,
132+
Value = OptionalString(props, "value") ?? "0",
133133
ItemId = OptionalString(props, "itemId"),
134134
ItemName = OptionalString(props, "itemName"),
135135
Quantity = OptionalInt(props, "quantity"),

src/Packages/Audience/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ public static class Analytics
3535
Debug = true,
3636
});
3737

38-
ImmutableAudience.Track(new Purchase { Currency = "USD", Value = 9.99m });
38+
ImmutableAudience.Track(new Purchase { Currency = "USD", Value = "9.99" });
3939
}
4040
}
4141
```

src/Packages/Audience/Runtime/Events/IEvent.cs

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,10 @@ namespace Immutable.Audience
1010
/// <see cref="ImmutableAudience.Track(IEvent)"/>.
1111
/// </summary>
1212
/// <remarks>
13-
/// Implementations validate required fields inside
14-
/// <see cref="ToProperties"/>;
15-
/// <see cref="ImmutableAudience.Track(IEvent)"/> catches the throw and
16-
/// drops the event with a warning.
13+
/// Implementations validate required fields inside <see cref="ToProperties"/>.
14+
/// <see cref="ImmutableAudience.Track(IEvent)"/> catches the throw and warns
15+
/// for a consumer-authored <see cref="IEvent"/>; see <see cref="IBuiltInEvent"/>
16+
/// for the exception to that.
1717
/// </remarks>
1818
public interface IEvent
1919
{
@@ -32,4 +32,17 @@ public interface IEvent
3232
/// </exception>
3333
Dictionary<string, object> ToProperties();
3434
}
35+
36+
/// <summary>
37+
/// Marks an <see cref="IEvent"/> implementation as one of Immutable's own
38+
/// built-in event types (<see cref="Purchase"/>, <see cref="Progression"/>,
39+
/// <see cref="Resource"/>, <see cref="AchievementUnlocked"/>,
40+
/// <see cref="MilestoneReached"/>). Not implementable outside this
41+
/// assembly: it exists only so <see cref="ImmutableAudience.Track(IEvent)"/>
42+
/// can tell a validation failure in one of these apart from an exception
43+
/// thrown by a consumer's own custom <see cref="IEvent"/>.
44+
/// </summary>
45+
internal interface IBuiltInEvent : IEvent
46+
{
47+
}
3548
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
#nullable enable
2+
3+
using System.Collections.Generic;
4+
5+
namespace Immutable.Audience
6+
{
7+
/// <summary>
8+
/// Required property names per reserved event, enforced at runtime by
9+
/// <see cref="ImmutableAudience.Track(string, Dictionary{string, object})"/>.
10+
/// The typed <see cref="IBuiltInEvent"/> classes enforce the same rules
11+
/// at compile time (constructor/property level) and again in
12+
/// <c>ToProperties</c>; this closes the gap for callers who use the
13+
/// string overload instead and so bypass all of that.
14+
/// </summary>
15+
internal static class ReservedEvents
16+
{
17+
internal static readonly IReadOnlyDictionary<string, string[]> RequiredProperties =
18+
new Dictionary<string, string[]>
19+
{
20+
["purchase"] = new[] { "currency", "value" },
21+
["progression"] = new[] { "status" },
22+
["resource"] = new[] { "flow", "currency", "amount" },
23+
["achievement_unlocked"] = new[] { "achievement_id", "achievement_name" },
24+
["milestone_reached"] = new[] { "name" },
25+
};
26+
}
27+
}

src/Packages/Audience/Runtime/Events/ReservedEvents.cs.meta

Lines changed: 11 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/Packages/Audience/Runtime/Events/TypedEvents.cs

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ internal static class ProgressionStatusExtensions
4444
/// Player progressing through a world / level / stage. Track via
4545
/// <see cref="ImmutableAudience.Track(IEvent)"/>.
4646
/// </summary>
47-
public class Progression : IEvent
47+
public class Progression : IBuiltInEvent
4848
{
4949
/// <summary>
5050
/// Required. Where the player is in the progression flow.
@@ -134,7 +134,7 @@ internal static class ResourceFlowExtensions
134134
/// In-game currency earned or spent. Track via
135135
/// <see cref="ImmutableAudience.Track(IEvent)"/>.
136136
/// </summary>
137-
public class Resource : IEvent
137+
public class Resource : IBuiltInEvent
138138
{
139139
/// <summary>
140140
/// Required. Whether this is a gain or a spend.
@@ -193,7 +193,7 @@ public Dictionary<string, object> ToProperties()
193193
/// Real-money transaction. Track via
194194
/// <see cref="ImmutableAudience.Track(IEvent)"/>.
195195
/// </summary>
196-
public class Purchase : IEvent
196+
public class Purchase : IBuiltInEvent
197197
{
198198
/// <summary>
199199
/// Required. ISO 4217 three-letter uppercase currency code (for
@@ -202,9 +202,11 @@ public class Purchase : IEvent
202202
public string? Currency { get; set; }
203203

204204
/// <summary>
205-
/// Required. The transaction amount in <see cref="Currency"/>.
205+
/// Required. The transaction amount in <see cref="Currency"/>, as a
206+
/// numeric-looking string (e.g. <c>"9.99"</c>), not a number, to
207+
/// avoid floating-point precision loss.
206208
/// </summary>
207-
public decimal? Value { get; set; }
209+
public string? Value { get; set; }
208210

209211
/// <summary>
210212
/// Optional. Stable identifier of the item purchased.
@@ -247,13 +249,13 @@ public Dictionary<string, object> ToProperties()
247249
if (Currency == null || !IsIso4217(Currency))
248250
throw new ArgumentException(
249251
$"Purchase.Currency '{Currency}' must be a three-letter uppercase ISO 4217 code");
250-
if (Value is null)
252+
if (string.IsNullOrEmpty(Value))
251253
throw new ArgumentException("Purchase.Value is required. Set it before calling Track(IEvent).");
252254

253255
var props = new Dictionary<string, object>
254256
{
255257
["currency"] = Currency,
256-
["value"] = Value.Value
258+
["value"] = Value
257259
};
258260

259261
if (ItemId != null) props["item_id"] = ItemId;
@@ -316,7 +318,7 @@ internal static class AchievementTypeExtensions
316318
/// Player unlocked an achievement. Track via
317319
/// <see cref="ImmutableAudience.Track(IEvent)"/>.
318320
/// </summary>
319-
public class AchievementUnlocked : IEvent
321+
public class AchievementUnlocked : IBuiltInEvent
320322
{
321323
/// <summary>
322324
/// Required. Stable identifier for the achievement (for example,
@@ -362,7 +364,7 @@ public Dictionary<string, object> ToProperties()
362364
/// Named milestone or achievement reached by the player. Track via
363365
/// <see cref="ImmutableAudience.Track(IEvent)"/>.
364366
/// </summary>
365-
public class MilestoneReached : IEvent
367+
public class MilestoneReached : IBuiltInEvent
366368
{
367369
/// <summary>
368370
/// Required. The milestone identifier (for example,

src/Packages/Audience/Runtime/ImmutableAudience.cs

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -328,6 +328,10 @@ internal static void OnResume()
328328
/// </summary>
329329
/// <param name="evt">The event to send. Must not be null, and <see cref="IEvent.EventName"/>
330330
/// must not be empty (both throw).</param>
331+
/// <exception cref="ArgumentException">
332+
/// One of Immutable's own built-in events (<see cref="Purchase"/>,
333+
/// <see cref="Progression"/>, etc.) is missing a required field.
334+
/// </exception>
331335
public static void Track(IEvent evt)
332336
{
333337
if (!_initialized) return;
@@ -339,7 +343,6 @@ public static void Track(IEvent evt)
339343
var config = _config;
340344
if (config == null) return;
341345

342-
// Consumer-supplied impl; catch so a buggy IEvent cannot crash the game.
343346
string eventName;
344347
Dictionary<string, object> properties;
345348
try
@@ -349,6 +352,8 @@ public static void Track(IEvent evt)
349352
}
350353
catch (Exception ex)
351354
{
355+
// See IBuiltInEvent: built-in events throw straight through.
356+
if (evt is IBuiltInEvent) throw;
352357
Log.Warn(AudienceLogs.TrackIEventThrew(evt.GetType().Name, ex));
353358
return;
354359
}
@@ -367,11 +372,17 @@ public static void Track(IEvent evt)
367372
/// </summary>
368373
/// <param name="eventName">The wire-format event name. Must not be empty (throws otherwise).</param>
369374
/// <param name="properties">Optional event properties.</param>
375+
/// <exception cref="ArgumentException">
376+
/// <paramref name="eventName"/> is empty, or is a reserved event
377+
/// (<c>purchase</c>, <c>progression</c>, <c>resource</c>,
378+
/// <c>achievement_unlocked</c>) missing one of its required properties.
379+
/// </exception>
370380
public static void Track(string eventName, Dictionary<string, object>? properties = null)
371381
{
372382
if (!_initialized) return;
373383
if (string.IsNullOrEmpty(eventName))
374384
throw new ArgumentException(AudienceLogs.TrackStringEmptyName, nameof(eventName));
385+
ValidateReservedEventProperties(eventName, properties);
375386

376387
var state = _state;
377388
if (!state.Level.CanTrack()) return;
@@ -382,6 +393,20 @@ public static void Track(string eventName, Dictionary<string, object>? propertie
382393
EnqueueTrackedEvent(eventName, SnapshotCallerDict(properties), _session?.SessionId, state, config);
383394
}
384395

396+
// See ReservedEvents. Unrecognised event names are never checked here.
397+
private static void ValidateReservedEventProperties(string eventName, Dictionary<string, object>? properties)
398+
{
399+
if (!ReservedEvents.RequiredProperties.TryGetValue(eventName, out var required)) return;
400+
401+
var missing = new List<string>();
402+
foreach (var key in required)
403+
{
404+
if (properties == null || !properties.ContainsKey(key)) missing.Add(key);
405+
}
406+
if (missing.Count > 0)
407+
throw new ArgumentException(AudienceLogs.TrackStringMissingRequiredProps(eventName, missing), nameof(properties));
408+
}
409+
385410
// Shared tail for both Track overloads and TrackFromSession.
386411
private static void EnqueueTrackedEvent(
387412
string eventName,

src/Packages/Audience/Runtime/Utility/Log.cs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
#nullable enable
22

33
using System;
4+
using System.Collections.Generic;
45

56
namespace Immutable.Audience
67
{
@@ -77,6 +78,10 @@ internal static string TrackIEventThrew(string evtTypeName, Exception ex) =>
7778
internal static string TrackIEventEmptyName(string evtTypeName) =>
7879
$"Track(IEvent): {evtTypeName}.EventName returned null or empty.";
7980

81+
internal static string TrackStringMissingRequiredProps(string eventName, IReadOnlyList<string> missing) =>
82+
$"Track(\"{eventName}\", ...) is missing required propert{(missing.Count > 1 ? "ies" : "y")}: "
83+
+ $"{string.Join(", ", missing)}.";
84+
8085
// ---- Identify / Alias ----
8186
// Empty/malformed ids are caller bugs: thrown as ArgumentException, not
8287
// logged (see ImmutableAudience.Identify/Alias). IdentifyDiscarded/

src/Packages/Audience/Tests/Runtime/Events/TypedEventTests.cs

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,30 @@
11
using System;
2+
using System.Linq;
23
using NUnit.Framework;
34

45
namespace Immutable.Audience.Tests
56
{
67
[TestFixture]
78
internal class TypedEventTests
89
{
10+
// Guards against a new built-in typed event being added without a
11+
// matching ReservedEvents entry, which would leave it silently
12+
// unvalidated on the Track(string, Dictionary) path.
13+
[Test]
14+
public void EveryBuiltInEventHasAReservedEventsEntry()
15+
{
16+
var builtInTypes = typeof(IBuiltInEvent).Assembly.GetTypes()
17+
.Where(t => typeof(IBuiltInEvent).IsAssignableFrom(t) && !t.IsInterface && !t.IsAbstract);
18+
19+
foreach (var type in builtInTypes)
20+
{
21+
var evt = (IEvent)Activator.CreateInstance(type)!;
22+
Assert.That(ReservedEvents.RequiredProperties.ContainsKey(evt.EventName), Is.True,
23+
$"{type.Name} implements IBuiltInEvent but ReservedEvents.RequiredProperties has no entry " +
24+
$"for \"{evt.EventName}\" (add one, even []).");
25+
}
26+
}
27+
928
[Test]
1029
public void Progression_EventName_IsProgression()
1130
{
@@ -115,7 +134,7 @@ public void Purchase_ProducesCorrectProperties()
115134
var evt = new Purchase
116135
{
117136
Currency = "USD",
118-
Value = 9.99m,
137+
Value = "9.99",
119138
ItemId = "gem_pack_01",
120139
ItemName = "Starter Gem Pack",
121140
Quantity = 1,
@@ -125,7 +144,7 @@ public void Purchase_ProducesCorrectProperties()
125144
var props = evt.ToProperties();
126145

127146
Assert.AreEqual("USD", props["currency"]);
128-
Assert.AreEqual(9.99m, props["value"]);
147+
Assert.AreEqual("9.99", props["value"]);
129148
Assert.AreEqual("gem_pack_01", props["item_id"]);
130149
Assert.AreEqual("Starter Gem Pack", props["item_name"]);
131150
Assert.AreEqual(1, props["quantity"]);
@@ -135,7 +154,7 @@ public void Purchase_ProducesCorrectProperties()
135154
[Test]
136155
public void Purchase_OptionalFieldsOmitted_WhenNull()
137156
{
138-
var props = new Purchase { Currency = "EUR", Value = 5.00m }.ToProperties();
157+
var props = new Purchase { Currency = "EUR", Value = "5.00" }.ToProperties();
139158

140159
Assert.IsTrue(props.ContainsKey("currency"));
141160
Assert.IsTrue(props.ContainsKey("value"));
@@ -154,7 +173,7 @@ public void Purchase_EventName_IsPurchase()
154173
[Test]
155174
public void Purchase_WithoutCurrency_ThrowsOnToProperties()
156175
{
157-
var evt = new Purchase { Value = 9.99m };
176+
var evt = new Purchase { Value = "9.99" };
158177

159178
var ex = Assert.Throws<ArgumentException>(() => evt.ToProperties());
160179
Assert.That(ex!.Message, Does.Contain("Currency"));

0 commit comments

Comments
 (0)