-
Notifications
You must be signed in to change notification settings - Fork 16
Expand file tree
/
Copy pathAudienceSample.cs
More file actions
376 lines (337 loc) · 17.4 KB
/
Copy pathAudienceSample.cs
File metadata and controls
376 lines (337 loc) · 17.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
#nullable enable
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using UnityEngine;
using UnityEngine.SceneManagement;
using UnityEngine.UIElements;
namespace Immutable.Audience.Samples.SampleApp
{
// Audience SDK sample — UI Toolkit port of the web sample-app. Exercises
// every public ImmutableAudience API plus an event log that mirrors SDK
// debug output.
//
// Partial layout (single source of truth):
//
// AudienceSample.cs SDK calls, On* handlers, mirror state, SDK
// callbacks, config builders. Reads UXML
// state ONLY via UI's Capture*Form accessors
// — never touches a UXML field directly.
// AudienceSample.UI.cs UXML fields, binding, rendering, log pane,
// Refresh* methods, Capture*Form accessors.
// No SDK calls, no mirror-state knowledge.
// AudienceSample.Events.cs Catalogue, typed-event factory, props
// builder. Pure factory — no UXML, no SDK.
public sealed partial class AudienceSample : MonoBehaviour
{
// ---- State ----
private bool _initialised;
private Action<string>? _priorSdkLogWriter;
// Sample-side identity mirror. SDK owns UserId; type, traits, and
// aliases are tracked here for the Identity panel.
private string? _mirrorIdentityType;
private Dictionary<string, object>? _mirrorTraits;
private readonly List<string> _mirrorAliases = new List<string>();
// ---- Lifecycle ----
private void Awake()
{
// InitializeUi must precede the Log.Writer swap — _logView has
// to be bound before any Log.Warn can land in RouteSdkLogToPane.
InitializeUi();
_priorSdkLogWriter = Immutable.Audience.Log.Writer;
Immutable.Audience.Log.Writer = RouteSdkLogToPane;
}
private void OnDestroy()
{
Immutable.Audience.Log.Writer = _priorSdkLogWriter;
}
// ---- SDK action handlers: SDK lifecycle ----
private void OnInit() => RunAndLog(SampleAppUi.LogLabels.Init, () =>
{
var form = CaptureInitForm();
var config = BuildAudienceConfig(form, OnSdkError);
ImmutableAudience.Init(config);
_initialised = true;
OnSdkStateChanged();
return Json.Serialize(BuildConfigEcho(config), 2);
});
private void OnShutdown() => RunAndLog(SampleAppUi.LogLabels.Shutdown, () =>
{
ImmutableAudience.Shutdown();
_initialised = false;
ResetIdentityMirror();
OnSdkStateChanged();
return SampleAppUi.Messages.SdkStopped;
});
private void OnReset() => RunAndLog(SampleAppUi.LogLabels.Reset, () =>
{
ImmutableAudience.Reset();
ResetIdentityMirror();
OnSdkStateChanged();
return SampleAppUi.Messages.AnonymousIdRegeneratedQueueCleared;
});
private async Task OnFlushAsync()
{
try { await ImmutableAudience.FlushAsync(); AppendLog(SampleAppUi.LogLabels.Flush, SampleAppUi.Messages.QueueFlushed, LogLevel.Ok, LogSource.App); OnSdkStateChanged(); }
catch (Exception ex) { AppendLog(SampleAppUi.LogLabels.Flush, ex.Message, LogLevel.Err, LogSource.App); }
}
private async Task OnDeleteDataAsync()
{
AppendLog(SampleAppUi.LogLabels.DeleteData, SampleAppUi.Messages.ErasureRequestDispatched, LogLevel.Info, LogSource.App);
try
{
await ImmutableAudience.DeleteData();
AppendLog(SampleAppUi.LogLabels.DeleteData, SampleAppUi.Messages.BackendAcknowledged, LogLevel.Ok, LogSource.App);
}
catch (Exception ex)
{
AppendLog(SampleAppUi.LogLabels.DeleteData, ex.Message, LogLevel.Err, LogSource.App);
}
}
// ---- SDK action handlers: telemetry ----
// v1 has no typed Screen API; Plan §10 specifies a custom Track call
// as the studio-facing pattern for scene coverage. Demo follows that.
private void OnPage() => RunAndLog(SampleAppUi.LogLabels.Page, () =>
{
GuardConsentForTrack();
var screen = SceneManager.GetActiveScene().name;
var props = new Dictionary<string, object> { [SampleAppCustomEventPropertyKeys.Path] = screen };
ImmutableAudience.Track(SampleAppCustomEvents.ScreenViewed, props);
return Json.Serialize(props, 2);
});
// Prefers the typed overload for the four events with public C#
// classes (Progression, Resource, Purchase, MilestoneReached); the
// rest stay on the string overload. Typed validation errors are
// expected for user input — let them propagate through RunAndLog.
private void OnSendCatalogueEvent(EventSpec spec, Dictionary<string, VisualElement> inputs) =>
RunAndLog(SampleAppUi.LogLabels.Track, () =>
{
GuardConsentForTrack();
var props = BuildPropsDictionary(spec, inputs);
var typed = BuildTypedEvent(spec.Name, props);
if (typed != null)
{
ImmutableAudience.Track(typed);
return Json.Serialize(new Dictionary<string, object>
{
[SampleAppUi.LogPayloadKeys.Event] = spec.Name,
[SampleAppUi.LogPayloadKeys.Overload] = SampleAppUi.LogPayloadKeys.OverloadValues.Typed,
[MessageFields.Properties] = typed.ToProperties(),
}, 2);
}
ImmutableAudience.Track(spec.Name, props.Count > 0 ? props : null);
return Json.Serialize(new Dictionary<string, object>
{
[SampleAppUi.LogPayloadKeys.Event] = spec.Name,
[SampleAppUi.LogPayloadKeys.Overload] = SampleAppUi.LogPayloadKeys.OverloadValues.String,
[MessageFields.Properties] = props,
}, 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(SampleAppUi.LogLabels.Track, () =>
{
GuardConsentForTrack();
var f = CaptureCustomEventForm();
var props = string.IsNullOrEmpty(f.RawProps) ? null : JsonReader.DeserializeObject(f.RawProps);
ImmutableAudience.Track(f.Name, props);
var echo = new Dictionary<string, object> { [SampleAppUi.LogPayloadKeys.Event] = f.Name };
if (props != null) echo[MessageFields.Properties] = props;
return Json.Serialize(echo, 2);
});
// ---- SDK action handlers: consent ----
// None purges the queue + clears the anonymous ID; dropping below Full
// clears UserId. Mirror is reset whenever the new level can no longer
// identify.
private void OnSetConsent(ConsentLevel level) => RunAndLog(SampleAppUi.LogLabels.SetConsent, () =>
{
var previous = ImmutableAudience.CurrentConsent;
ImmutableAudience.SetConsent(level);
if (!level.CanIdentify()) ResetIdentityMirror();
var payload = new Dictionary<string, object>
{
[SampleAppUi.LogPayloadKeys.From] = previous.ToLowercaseString(),
[SampleAppUi.LogPayloadKeys.To] = level.ToLowercaseString(),
};
var effects = new List<string>();
if (previous == ConsentLevel.None && level != ConsentLevel.None) effects.Add(SampleAppUi.Messages.QueueStartedSessionCreated);
if (level == ConsentLevel.None) effects.Add(SampleAppUi.Messages.QueuePurgedAnonymousIdCleared);
if (!level.CanIdentify() && previous.CanIdentify()) effects.Add(SampleAppUi.Messages.UserIdCleared);
if (effects.Count > 0) payload[SampleAppUi.LogPayloadKeys.Effects] = effects;
OnSdkStateChanged();
return Json.Serialize(payload, 2);
});
// ---- SDK action handlers: identity ----
private void OnIdentify() => RunAndLog(SampleAppUi.LogLabels.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);
if (accepted) { _mirrorIdentityType = f.Type; _mirrorTraits = traits; }
OnSdkStateChanged();
var payload = new Dictionary<string, object>
{
[SampleAppUi.LogPayloadKeys.Id] = f.Id,
[MessageFields.IdentityType] = f.Type,
[SampleAppUi.LogPayloadKeys.Accepted] = accepted,
};
if (traits != null) payload[MessageFields.Traits] = traits;
return Json.Serialize(payload, 2);
});
private void OnIdentifyTraits() => RunAndLog(SampleAppUi.LogLabels.IdentifyTraits, () =>
{
var userId = ImmutableAudience.UserId;
if (string.IsNullOrEmpty(userId)) throw new InvalidOperationException(SampleAppUi.Messages.NoActiveIdentity);
var traits = ParseTraits(CaptureTraitsUpdate());
if (traits == null || traits.Count == 0) throw new InvalidOperationException(SampleAppUi.Messages.TraitsRequired);
ImmutableAudience.Identify(userId, ParseIdentityType(_mirrorIdentityType), traits);
_mirrorTraits = traits;
OnSdkStateChanged();
return Json.Serialize(traits, 2);
});
private void OnAlias() => RunAndLog(SampleAppUi.LogLabels.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();
}
return Json.Serialize(new Dictionary<string, object>
{
[SampleAppUi.LogPayloadKeys.From] = new Dictionary<string, object> { [SampleAppUi.LogPayloadKeys.Id] = f.FromId, [MessageFields.IdentityType] = f.FromType },
[SampleAppUi.LogPayloadKeys.To] = new Dictionary<string, object> { [SampleAppUi.LogPayloadKeys.Id] = f.ToId, [MessageFields.IdentityType] = f.ToType },
[SampleAppUi.LogPayloadKeys.Accepted] = accepted,
}, 2);
});
// ---- SDK callbacks (passed to SDK at Init time) ----
// Fires from background flush threads; AppendLog marshals to main.
// Body is JSON for parity with handler "Copy" output.
private void OnSdkError(AudienceError err) =>
AppendLog(SampleAppUi.LogLabels.OnError, Json.Serialize(new Dictionary<string, object>
{
[SampleAppUi.LogPayloadKeys.Code] = err.Code.ToString(),
[SampleAppUi.LogPayloadKeys.Message] = err.Message,
}, 2), LogLevel.Err, LogSource.Sdk);
// SDK Log.Writer adapter. May fire from any thread; AppendLog handles
// the main-thread marshal.
private void RouteSdkLogToPane(string msg)
{
string body = msg;
var level = LogLevel.Debug;
if (msg.StartsWith(Immutable.Audience.Log.WarnPrefix, StringComparison.Ordinal))
{
level = LogLevel.Warn;
body = msg.Substring(Immutable.Audience.Log.WarnPrefix.Length).TrimStart();
}
else if (msg.StartsWith(Immutable.Audience.Log.Prefix, StringComparison.Ordinal))
{
body = msg.Substring(Immutable.Audience.Log.Prefix.Length).TrimStart();
}
AppendLog(SampleAppUi.LogLabels.Sdk, body, level, LogSource.Sdk);
}
// ---- Handler scaffolding ----
private void RunAndLog(string label, Func<string> body)
{
try { AppendLog(label, body(), LogLevel.Ok, LogSource.App); }
catch (Exception ex)
{
var source = ex is InvalidOperationException ? LogSource.App : LogSource.Sdk;
AppendLog(label, ex.Message, LogLevel.Err, source);
}
}
// Track silently drops when consent < Anonymous (CanTrack false, no
// SDK log). Throw here so RunAndLog renders an App-tier error row
// instead of a misleading Ok with a payload that was never queued.
private static void GuardConsentForTrack()
{
var consent = ImmutableAudience.CurrentConsent;
if (!consent.CanTrack())
throw new InvalidOperationException(
string.Format(SampleAppUi.Messages.TrackDroppedConsentFmt, consent.ToLowercaseString()));
}
// Refresh* are idempotent reads, so calling all four every time is
// cheaper than tracking subsets per handler.
private void OnSdkStateChanged()
{
RefreshInitState();
RefreshConsentPills();
RefreshIdentityPanel();
RefreshStatusBar();
}
// ---- Config builders ----
// Maps the captured Setup form to AudienceConfig. BaseUrl null → SDK
// derives the endpoint from the publishable key prefix (test → sandbox,
// else production). The flushInterval clamp emits a warn row when
// the user requests <1s.
private AudienceConfig BuildAudienceConfig(InitForm form, Action<AudienceError> onError)
{
var config = new AudienceConfig
{
PublishableKey = form.PublishableKey,
BaseUrl = string.IsNullOrEmpty(form.BaseUrl) ? null : form.BaseUrl,
Consent = form.Consent,
Debug = form.Debug,
OnError = onError,
};
if (form.FlushIntervalMs is int flushMs && flushMs > 0)
{
if (flushMs < 1000)
AppendLog(SampleAppUi.LogLabels.Init, string.Format(SampleAppUi.Messages.FlushIntervalBelowOneSecondClampedFmt, flushMs), LogLevel.Warn, LogSource.App);
config.FlushIntervalSeconds = Math.Max(1, flushMs / 1000);
}
if (form.FlushSize is int flushSize && flushSize > 0)
config.FlushSize = flushSize;
return config;
}
// For the Init "Ok" row. Nullable fields omitted when unset;
// publishableKey redacted.
private static Dictionary<string, object> BuildConfigEcho(AudienceConfig config)
{
var echo = new Dictionary<string, object>
{
[SampleAppUi.LogPayloadKeys.Consent] = config.Consent.ToString(),
[SampleAppUi.LogPayloadKeys.Debug] = config.Debug,
[SampleAppUi.LogPayloadKeys.FlushIntervalSeconds] = config.FlushIntervalSeconds,
[SampleAppUi.LogPayloadKeys.FlushSize] = config.FlushSize,
[SampleAppUi.LogPayloadKeys.PackageVersion] = config.PackageVersion,
[SampleAppUi.LogPayloadKeys.ShutdownFlushTimeoutMs] = config.ShutdownFlushTimeoutMs,
};
if (!string.IsNullOrEmpty(config.PublishableKey))
echo[SampleAppUi.LogPayloadKeys.PublishableKey] = RedactPublishableKey(config.PublishableKey);
if (!string.IsNullOrEmpty(config.PersistentDataPath))
echo[SampleAppUi.LogPayloadKeys.PersistentDataPath] = config.PersistentDataPath;
return echo;
}
// Keeps the pk_imapik-test- / pk_imapik- prefix visible; masks the rest.
// Caller must guard against null/empty; signature non-nullable so the
// dictionary insertion in BuildInitConfigEcho doesn't trip CS8601.
private static string RedactPublishableKey(string key)
{
const int PrefixChars = 16;
const string Mask = "…****";
return key.Length <= PrefixChars ? Mask : key.Substring(0, PrefixChars) + Mask;
}
// ---- Identity helpers ----
private void ResetIdentityMirror()
{
_mirrorIdentityType = null;
_mirrorTraits = null;
_mirrorAliases.Clear();
}
private static Dictionary<string, object>? ParseTraits(string? raw) =>
string.IsNullOrWhiteSpace(raw) ? null : JsonReader.DeserializeObject(raw!);
private static IdentityType ParseIdentityType(string? value) =>
IdentityTypeExtensions.ParseLowercaseString(value);
}
}