Skip to content

Commit 0e0b416

Browse files
feat(audience-unity): add Unity integration layer for context, lifecycle, and performance (SDK-146)
AudienceUnityHooks installs on SubsystemRegistration and wires: - DefaultPersistentDataPathProvider from Application.persistentDataPath - LaunchContextProvider with DeviceCollector's one-shot game_launch fields (platform, version, buildGuid, unityVersion, osFamily, deviceModel, gpu*, cpu*, ramMb, screenDpi) - ContextProvider with userAgent / locale / timezone / screen, merged into every outgoing message.context - PerformanceSnapshotProvider returns the fpsAvg / fpsMin / memoryUsedMb / memoryReservedMb accumulated since the last read; Session.OnHeartbeat reads and merges it - Application.quitting -> ImmutableAudience.Shutdown - UnityLifecycleBridge forwards OnApplicationPause / OnApplicationFocus to ImmutableAudience.OnPause / OnResume New Unity-layer files (Runtime/Unity/): - DeviceCollector.cs — IL2CPP-safe SystemInfo / Screen / Application readers. Strings capped at 256 chars to mirror the Web SDK identifier cap (core/src/validation.ts MAX_STRING_LENGTH) - PerformanceCollector.cs — per-frame fps + memory accumulator read off the main thread via SnapshotAndReset - UnityLifecycleBridge.cs — lifecycle forwarder + per-frame tick Core changes in ImmutableAudience: - LaunchContextProvider / ContextProvider typed as Func<IReadOnlyDictionary<string, object>>? so the cached snapshot cannot be mutated by any downstream reader - PerformanceSnapshotProvider added with the same one-shot-capture contract - All three Session constructor sites (Init, Reset-creates-session, None -> Anonymous/Full upgrade in SetConsent) pass PerformanceSnapshotProvider as the second arg - MergeUnityContext merges ContextProvider output into every outgoing message.context before EnqueueChecked; throws and null returns are swallowed so a misbehaving layer cannot drop events Build and test infrastructure: - Directory.Build.props redirects bin/obj to repo-root artifacts/ so dotnet output doesn't leak into Unity's asset importer scan path - ConstantsTests walks up from the test binary to find the package root so the Directory.Build.props redirect doesn't break it - com.immutable.audience / .unity asmdefs updated - link.xml preserves Unity hooks from IL2CPP stripping - .gitignore adds artifacts/ Tests: - ContextProvider_Set / _Throwing / _ReturnsNull pin the merge + swallow behaviour - PerformanceSnapshotProvider_ReachesHeartbeat variants pin Init, consent upgrade, and Reset ctor sites — dropping the provider from any single site fails a distinct test Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent e9ad00a commit 0e0b416

12 files changed

Lines changed: 524 additions & 32 deletions

.gitignore

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,16 @@
1010
[Ll]ogs/
1111
[Uu]ser[Ss]ettings/
1212

13+
# dotnet build outputs redirected here via Directory.Build.props files
14+
# so bin/obj don't sit inside Unity package folders and get scanned.
15+
/artifacts/
16+
17+
# IDE and MSBuild extensibility sidecars Unity emits .meta for when the
18+
# files land inside the package root.
19+
*.DotSettings.user.meta
20+
Directory.Build.props.meta
21+
Directory.Build.targets.meta
22+
1323
# MemoryCaptures can get excessive in size.
1424
# They also could contain extremely sensitive data
1525
/[Mm]emoryCaptures/
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
<Project>
2+
<!--
3+
Redirect dotnet build outputs to a repo-root artifacts/ folder so they
4+
don't leak into the Unity package directory. When the package is
5+
referenced from a Unity project via a `file:` path, Unity scans every
6+
folder under the package root. Finding bin/obj/*.dll trips
7+
"Multiple precompiled assemblies" errors and the locale-specific
8+
resource DLLs also confuse Unity's asset importer.
9+
10+
Targets:
11+
artifacts/Audience.Runtime/bin|obj/
12+
artifacts/Audience.Tests/bin|obj/
13+
14+
$(MSBuildThisFileDirectory) here = src/Packages/Audience/
15+
../../../artifacts/ = <repo-root>/artifacts/
16+
-->
17+
<PropertyGroup>
18+
<BaseOutputPath>$(MSBuildThisFileDirectory)../../../artifacts/$(MSBuildProjectName)/bin/</BaseOutputPath>
19+
<BaseIntermediateOutputPath>$(MSBuildThisFileDirectory)../../../artifacts/$(MSBuildProjectName)/obj/</BaseIntermediateOutputPath>
20+
</PropertyGroup>
21+
</Project>

src/Packages/Audience/Runtime/ImmutableAudience.cs

Lines changed: 45 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -38,12 +38,14 @@ public static class ImmutableAudience
3838
// Gate against overlapping timer ticks (Timer callbacks run on independent ThreadPool threads).
3939
private static int _sendInFlight;
4040

41-
// AudienceUnityHooks sets these at SubsystemRegistration.
42-
// DefaultPersistentDataPathProvider fills PersistentDataPath from
43-
// Application.persistentDataPath. LaunchContextProvider supplies
44-
// Unity context for game_launch without Core referencing UnityEngine.
45-
internal static Func<string>? DefaultPersistentDataPathProvider;
46-
internal static Func<Dictionary<string, object>>? LaunchContextProvider;
41+
// volatile: assigned on the Unity main thread at SubsystemRegistration,
42+
// read from the drain thread in Track / Identify / heartbeat paths.
43+
// The assignments happen before any event can fire in practice, but
44+
// volatile documents the cross-thread publish contract explicitly.
45+
internal static volatile Func<string>? DefaultPersistentDataPathProvider;
46+
internal static volatile Func<IReadOnlyDictionary<string, object>>? LaunchContextProvider;
47+
internal static volatile Func<IReadOnlyDictionary<string, object>>? ContextProvider;
48+
internal static volatile Func<Dictionary<string, object>>? PerformanceSnapshotProvider;
4749

4850
// Active session. Created at Init (or on upgrade from None) and disposed
4951
// on Shutdown or SetConsent(None). Volatile so OnPause/OnResume see
@@ -100,7 +102,7 @@ public static void Init(AudienceConfig config)
100102
// Session created under the lock; Start() deferred until after
101103
// release because session_start → Track takes its own locks.
102104
if (initialLevel.CanTrack())
103-
_session = new Session(Track);
105+
_session = new Session(Track, PerformanceSnapshotProvider);
104106

105107
// Captured reference: a later SetConsent(None) may dispose this
106108
// Session (Start then no-ops on _disposed). Either way no duplicate
@@ -300,7 +302,7 @@ public static void Reset()
300302

301303
// Swap under the lock so racing SetConsent/OnPause/OnResume see
302304
// either the old, the new, or null — never a torn reference.
303-
_session = _state.Level.CanTrack() ? new Session(Track) : null;
305+
_session = _state.Level.CanTrack() ? new Session(Track, PerformanceSnapshotProvider) : null;
304306
newSession = _session;
305307
}
306308

@@ -454,7 +456,7 @@ public static void SetConsent(ConsentLevel level)
454456
// Upgrade from None: allocate + publish the new Session under
455457
// the lock so a concurrent SetConsent / Init sees the new
456458
// reference and the double-allocation guard above fires.
457-
newSession = new Session(Track);
459+
newSession = new Session(Track, PerformanceSnapshotProvider);
458460
_session = newSession;
459461
}
460462
}
@@ -701,9 +703,7 @@ public static void Shutdown()
701703
// Internal — shared with tests and AudienceUnityHooks
702704
// -----------------------------------------------------------------
703705

704-
// Shuts down (if initialised) and clears per-session state. Used on
705-
// test teardown and Unity SubsystemRegistration to survive "disable
706-
// domain reload". LaunchContextProvider is re-assigned by AudienceUnityHooks.
706+
// Providers reassigned by SubsystemRegistration.
707707
internal static void ResetState()
708708
{
709709
// Shutdown manages its own serialisation and releases _initLock before
@@ -743,6 +743,7 @@ internal static void ResetState()
743743
// Anonymous the userId is stripped.
744744
private static void EnqueueTrack(Dictionary<string, object>? msg)
745745
{
746+
MergeUnityContext(msg);
746747
_queue?.EnqueueChecked(msg, m =>
747748
{
748749
var state = _state;
@@ -756,10 +757,41 @@ private static void EnqueueTrack(Dictionary<string, object>? msg)
756757
// Identify / Alias require Full; drop if consent has downgraded.
757758
private static void EnqueueIdentity(Dictionary<string, object>? msg)
758759
{
760+
MergeUnityContext(msg);
759761
_queue?.EnqueueChecked(msg, m =>
760762
_state.Level == ConsentLevel.Full ? m : null);
761763
}
762764

765+
private static void MergeUnityContext(Dictionary<string, object>? msg)
766+
{
767+
if (msg == null) return;
768+
769+
var provider = ContextProvider;
770+
if (provider == null) return;
771+
772+
IReadOnlyDictionary<string, object>? extra;
773+
try
774+
{
775+
extra = provider();
776+
}
777+
catch (Exception ex)
778+
{
779+
Log.Warn($"ContextProvider threw {ex.GetType().Name}: {ex.Message}. " +
780+
"Event ships with base context only.");
781+
return;
782+
}
783+
if (extra == null) return;
784+
785+
if (!(msg.TryGetValue("context", out var ctxObj) && ctxObj is Dictionary<string, object> ctx))
786+
{
787+
ctx = new Dictionary<string, object>();
788+
msg["context"] = ctx;
789+
}
790+
791+
foreach (var kv in extra)
792+
ctx[kv.Key] = kv.Value;
793+
}
794+
763795
private static void SendBatch()
764796
{
765797
// If a previous send is still running, skip this one. That send
@@ -825,7 +857,7 @@ private static void FireGameLaunch(AudienceConfig config, ConsentLevel consentAt
825857
var provider = LaunchContextProvider;
826858
if (provider != null)
827859
{
828-
Dictionary<string, object>? unityContext = null;
860+
IReadOnlyDictionary<string, object>? unityContext = null;
829861
try { unityContext = provider(); }
830862
catch (Exception ex)
831863
{
Lines changed: 17 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,35 +1,40 @@
11
#nullable enable
22

33
using System.Collections.Generic;
4+
using System.Collections.ObjectModel;
45
using UnityEngine;
56

67
namespace Immutable.Audience.Unity
78
{
89
internal static class AudienceUnityHooks
910
{
11+
private static PerformanceCollector? _perf;
12+
1013
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)]
1114
private static void Install()
1215
{
13-
// Clear surviving statics before re-wiring in case "disable domain reload" kept them alive.
1416
ImmutableAudience.ResetState();
1517

16-
// -= then += so repeat SubsystemRegistration cycles don't stack subscriptions.
18+
// Avoid stacked subscriptions on reload.
1719
Application.quitting -= ImmutableAudience.Shutdown;
1820
Application.quitting += ImmutableAudience.Shutdown;
1921

2022
ImmutableAudience.DefaultPersistentDataPathProvider = () => Application.persistentDataPath;
21-
ImmutableAudience.LaunchContextProvider = BuildLaunchContext;
23+
24+
// Captured once on main thread; ReadOnlyDictionary blocks downstream mutation.
25+
IReadOnlyDictionary<string, object> launchProps =
26+
new ReadOnlyDictionary<string, object>(DeviceCollector.CollectGameLaunchProperties());
27+
IReadOnlyDictionary<string, object> contextProps =
28+
new ReadOnlyDictionary<string, object>(DeviceCollector.CollectContext());
29+
ImmutableAudience.LaunchContextProvider = () => launchProps;
30+
ImmutableAudience.ContextProvider = () => contextProps;
31+
32+
UnityLifecycleBridge.EnsureExists();
33+
_perf = new PerformanceCollector();
34+
UnityLifecycleBridge.SetPerformanceCollector(_perf);
35+
ImmutableAudience.PerformanceSnapshotProvider = _perf.SnapshotAndReset;
2236

2337
if (Log.Writer == null) Log.Writer = Debug.Log;
2438
}
25-
26-
private static Dictionary<string, object> BuildLaunchContext() =>
27-
new Dictionary<string, object>
28-
{
29-
["platform"] = Application.platform.ToString(),
30-
["version"] = Application.version,
31-
["buildGuid"] = Application.buildGUID,
32-
["unityVersion"] = Application.unityVersion,
33-
};
3439
}
3540
}
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
#nullable enable
2+
3+
using System;
4+
using System.Collections.Generic;
5+
using System.Globalization;
6+
using UnityEngine;
7+
8+
namespace Immutable.Audience.Unity
9+
{
10+
internal static class DeviceCollector
11+
{
12+
internal static Dictionary<string, object> CollectContext()
13+
{
14+
// 256-char cap mirrors Web SDK's identifier truncation.
15+
var ctx = new Dictionary<string, object>
16+
{
17+
["userAgent"] = Truncate(SystemInfo.operatingSystem, 256),
18+
};
19+
20+
var timezone = SafeTimezone();
21+
if (timezone != null) ctx["timezone"] = Truncate(timezone, 256);
22+
23+
var locale = LocaleString();
24+
if (locale != null) ctx["locale"] = Truncate(locale, 256);
25+
26+
var screen = TryResolveScreenString();
27+
if (screen != null) ctx["screen"] = Truncate(screen, 256);
28+
29+
return ctx;
30+
}
31+
32+
private static string? TryResolveScreenString()
33+
{
34+
var resolution = Screen.currentResolution;
35+
int width = resolution.width;
36+
int height = resolution.height;
37+
38+
if (width <= 0 || height <= 0)
39+
{
40+
width = Screen.width;
41+
height = Screen.height;
42+
}
43+
44+
if (width <= 0 || height <= 0) return null;
45+
return $"{width}x{height}";
46+
}
47+
48+
internal static Dictionary<string, object> CollectGameLaunchProperties()
49+
{
50+
var props = new Dictionary<string, object>
51+
{
52+
["platform"] = Application.platform.ToString(),
53+
["version"] = Truncate(Application.version, 256),
54+
["buildGuid"] = Truncate(Application.buildGUID, 256),
55+
["unityVersion"] = Truncate(Application.unityVersion, 256),
56+
["osFamily"] = SystemInfo.operatingSystemFamily.ToString(),
57+
["deviceModel"] = Truncate(SystemInfo.deviceModel, 256),
58+
["gpu"] = Truncate(SystemInfo.graphicsDeviceName, 256),
59+
["gpuVendor"] = Truncate(SystemInfo.graphicsDeviceVendor, 256),
60+
["cpu"] = Truncate(SystemInfo.processorType, 256),
61+
["cpuCores"] = SystemInfo.processorCount,
62+
["ramMb"] = SystemInfo.systemMemorySize,
63+
};
64+
65+
// Screen.dpi can be 0 on some Linux WMs.
66+
var dpi = (int)Screen.dpi;
67+
if (dpi > 0) props["screenDpi"] = dpi;
68+
69+
return props;
70+
}
71+
72+
private static string? LocaleString()
73+
{
74+
var culture = CultureInfo.CurrentCulture;
75+
if (!string.IsNullOrEmpty(culture?.Name))
76+
return culture.Name;
77+
return null;
78+
}
79+
80+
private static string? SafeTimezone()
81+
{
82+
try
83+
{
84+
return TimeZoneInfo.Local.Id;
85+
}
86+
catch (Exception)
87+
{
88+
return null;
89+
}
90+
}
91+
92+
private static string Truncate(string s, int max)
93+
{
94+
if (string.IsNullOrEmpty(s) || s.Length <= max) return s;
95+
// Step back one if the cut would split a surrogate pair — leaving
96+
// a lone high-surrogate produces invalid UTF-16 on the wire.
97+
var cut = max;
98+
if (char.IsHighSurrogate(s[cut - 1])) cut--;
99+
return s.Substring(0, cut);
100+
}
101+
}
102+
}
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
#nullable enable
2+
3+
using System;
4+
using System.Collections.Generic;
5+
using UnityEngine;
6+
using UnityEngine.Profiling;
7+
8+
namespace Immutable.Audience.Unity
9+
{
10+
// fps + memory for session_heartbeat. RecordFrame: main thread, SnapshotAndReset: thread-safe.
11+
internal sealed class PerformanceCollector
12+
{
13+
private readonly object _lock = new object();
14+
15+
private int _frameCount;
16+
private float _elapsed;
17+
private float _fpsMin = float.MaxValue;
18+
private long _memUsedMb;
19+
private long _memReservedMb;
20+
21+
private bool _memorySampleRequested = true;
22+
23+
internal void RecordFrame()
24+
{
25+
lock (_lock)
26+
{
27+
var dt = Time.unscaledDeltaTime;
28+
_frameCount++;
29+
_elapsed += dt;
30+
31+
if (dt > 0f)
32+
{
33+
var instantFps = 1f / dt;
34+
if (instantFps < _fpsMin) _fpsMin = instantFps;
35+
}
36+
37+
if (_memorySampleRequested)
38+
{
39+
_memUsedMb = Profiler.GetTotalAllocatedMemoryLong() / (1024L * 1024L);
40+
_memReservedMb = Profiler.GetTotalReservedMemoryLong() / (1024L * 1024L);
41+
_memorySampleRequested = false;
42+
}
43+
}
44+
}
45+
46+
internal Dictionary<string, object> SnapshotAndReset()
47+
{
48+
int frames;
49+
float elapsed;
50+
float fpsMin;
51+
long memUsed;
52+
long memReserved;
53+
54+
lock (_lock)
55+
{
56+
frames = _frameCount;
57+
elapsed = _elapsed;
58+
fpsMin = _fpsMin;
59+
memUsed = _memUsedMb;
60+
memReserved = _memReservedMb;
61+
62+
_frameCount = 0;
63+
_elapsed = 0f;
64+
_fpsMin = float.MaxValue;
65+
_memorySampleRequested = true;
66+
}
67+
68+
// Memory is a point-in-time reading — always meaningful. fps
69+
// fields only ship when at least one frame was recorded, so the
70+
// backend can tell "no sample" (fields absent) apart from
71+
// "framerate dropped to zero" (fields present with value 0).
72+
var result = new Dictionary<string, object>
73+
{
74+
["memoryUsedMb"] = memUsed,
75+
["memoryReservedMb"] = memReserved,
76+
};
77+
if (frames > 0 && elapsed > 0f)
78+
result["fpsAvg"] = Math.Round(frames / elapsed, 1);
79+
if (fpsMin != float.MaxValue)
80+
result["fpsMin"] = Math.Round(fpsMin, 1);
81+
return result;
82+
}
83+
}
84+
}

0 commit comments

Comments
 (0)