Skip to content

Commit 128504b

Browse files
feat(audience-unity): add Unity integration layer for context and lifecycle (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 - 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) - UnityLifecycleBridge.cs — lifecycle forwarder Core changes in ImmutableAudience: - LaunchContextProvider / ContextProvider typed as Func<IReadOnlyDictionary<string, object>>? so the cached snapshot cannot be mutated by any downstream reader - 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 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent e9ad00a commit 128504b

13 files changed

Lines changed: 366 additions & 191 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/Core/Session.cs

Lines changed: 2 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,6 @@ internal sealed class Session : IDisposable
2929
internal const int PauseTimeoutMs = 30_000;
3030

3131
private readonly TrackDelegate _track;
32-
private readonly Func<Dictionary<string, object>>? _performanceSnapshot;
3332
private readonly Func<DateTime> _getUtcNow;
3433
private readonly int _heartbeatIntervalMs;
3534
private readonly object _lock = new object();
@@ -48,16 +47,13 @@ internal string? SessionId
4847
get { lock (_lock) return _sessionId; }
4948
}
5049

51-
// track: fires session events. performanceSnapshot: merges fps/memory
52-
// into heartbeats (null on non-Unity). getUtcNow/heartbeatIntervalMs: test seams.
50+
// track: fires session events. getUtcNow/heartbeatIntervalMs: test seams.
5351
internal Session(
5452
TrackDelegate track,
55-
Func<Dictionary<string, object>>? performanceSnapshot = null,
5653
Func<DateTime>? getUtcNow = null,
5754
int heartbeatIntervalMs = HeartbeatIntervalMs)
5855
{
5956
_track = track ?? throw new ArgumentNullException(nameof(track));
60-
_performanceSnapshot = performanceSnapshot;
6157
_getUtcNow = getUtcNow ?? (() => DateTime.UtcNow);
6258
_heartbeatIntervalMs = heartbeatIntervalMs;
6359
}
@@ -252,24 +248,13 @@ internal void OnHeartbeat()
252248
duration = ComputeEngagedSecondsLocked();
253249
}
254250

255-
// Build outside _lock so snapshot + track don't re-enter.
251+
// Build outside _lock so track doesn't re-enter.
256252
var properties = new Dictionary<string, object>
257253
{
258254
["sessionId"] = sessionId,
259255
["durationSec"] = duration
260256
};
261257

262-
var perf = SafePerformanceSnapshot();
263-
if (perf != null)
264-
{
265-
foreach (var kv in perf)
266-
{
267-
// Don't let the provider clobber core fields.
268-
if (properties.ContainsKey(kv.Key)) continue;
269-
properties[kv.Key] = kv.Value;
270-
}
271-
}
272-
273258
SafeTrack("session_heartbeat", properties);
274259
}
275260

@@ -289,22 +274,6 @@ private void SafeTrack(string eventName, Dictionary<string, object> properties)
289274
}
290275
}
291276

292-
// Stops exceptions from the studio-supplied snapshot callback from
293-
// reaching the background timer.
294-
private Dictionary<string, object>? SafePerformanceSnapshot()
295-
{
296-
if (_performanceSnapshot == null) return null;
297-
try
298-
{
299-
return _performanceSnapshot();
300-
}
301-
catch (Exception ex)
302-
{
303-
Log.Warn($"Session: performance snapshot threw {ex.GetType().Name}. Heartbeat ships without performance fields.");
304-
return null;
305-
}
306-
}
307-
308277
// Stops the timer and waits for the in-flight callback. Runs outside
309278
// _lock (OnHeartbeat re-enters). 1s budget (quits must not hang). Warns on timeout.
310279
private void DrainHeartbeatTimer()

src/Packages/Audience/Runtime/ImmutableAudience.cs

Lines changed: 41 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -38,12 +38,13 @@ 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 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;
4748

4849
// Active session. Created at Init (or on upgrade from None) and disposed
4950
// on Shutdown or SetConsent(None). Volatile so OnPause/OnResume see
@@ -701,9 +702,7 @@ public static void Shutdown()
701702
// Internal — shared with tests and AudienceUnityHooks
702703
// -----------------------------------------------------------------
703704

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.
705+
// Providers reassigned by SubsystemRegistration.
707706
internal static void ResetState()
708707
{
709708
// Shutdown manages its own serialisation and releases _initLock before
@@ -743,6 +742,7 @@ internal static void ResetState()
743742
// Anonymous the userId is stripped.
744743
private static void EnqueueTrack(Dictionary<string, object>? msg)
745744
{
745+
MergeUnityContext(msg);
746746
_queue?.EnqueueChecked(msg, m =>
747747
{
748748
var state = _state;
@@ -756,10 +756,41 @@ private static void EnqueueTrack(Dictionary<string, object>? msg)
756756
// Identify / Alias require Full; drop if consent has downgraded.
757757
private static void EnqueueIdentity(Dictionary<string, object>? msg)
758758
{
759+
MergeUnityContext(msg);
759760
_queue?.EnqueueChecked(msg, m =>
760761
_state.Level == ConsentLevel.Full ? m : null);
761762
}
762763

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

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

67
namespace Immutable.Audience.Unity
@@ -10,26 +11,25 @@ internal static class AudienceUnityHooks
1011
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)]
1112
private static void Install()
1213
{
13-
// Clear surviving statics before re-wiring in case "disable domain reload" kept them alive.
1414
ImmutableAudience.ResetState();
1515

16-
// -= then += so repeat SubsystemRegistration cycles don't stack subscriptions.
16+
// Avoid stacked subscriptions on reload.
1717
Application.quitting -= ImmutableAudience.Shutdown;
1818
Application.quitting += ImmutableAudience.Shutdown;
1919

2020
ImmutableAudience.DefaultPersistentDataPathProvider = () => Application.persistentDataPath;
21-
ImmutableAudience.LaunchContextProvider = BuildLaunchContext;
21+
22+
// Captured once on main thread; ReadOnlyDictionary blocks downstream mutation.
23+
IReadOnlyDictionary<string, object> launchProps =
24+
new ReadOnlyDictionary<string, object>(DeviceCollector.CollectGameLaunchProperties());
25+
IReadOnlyDictionary<string, object> contextProps =
26+
new ReadOnlyDictionary<string, object>(DeviceCollector.CollectContext());
27+
ImmutableAudience.LaunchContextProvider = () => launchProps;
28+
ImmutableAudience.ContextProvider = () => contextProps;
29+
30+
UnityLifecycleBridge.EnsureExists();
2231

2332
if (Log.Writer == null) Log.Writer = Debug.Log;
2433
}
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-
};
3434
}
3535
}
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+
}

0 commit comments

Comments
 (0)