Meta and appflyer deeper intregration

This commit is contained in:
Savya Bikram Shah
2026-06-07 14:12:09 +05:45
parent 70b8dd2b95
commit a07f7618ab
200 changed files with 9081 additions and 2300 deletions

View File

@@ -0,0 +1,51 @@
namespace Darkmatter.Core.Contracts.Services.Analytics
{
/// <summary>
/// Canonical analytics event names. Use these constants everywhere — never raw strings — so the
/// Firebase and Facebook sinks stay identical and typo-free. GA4 limit: 500 distinct event names.
/// Names are snake_case and &lt;= 40 chars (Facebook App Events limit). Lives in Core so any feature
/// or service can reference them alongside <see cref="IAnalyticsService"/>.
/// </summary>
public static class AnalyticsEvents
{
// Onboarding / activation funnel
public const string IntroStarted = "intro_started";
public const string IntroCompleted = "intro_completed";
public const string TutorialStarted = "tutorial_started";
public const string TutorialStepCompleted = "tutorial_step_completed";
public const string TutorialCompleted = "tutorial_completed";
public const string PlayClicked = "play_clicked";
public const string FirstDrawingStarted = "first_drawing_started";
// Navigation
public const string ColorbookOpened = "colorbook_opened";
public const string ArtbookOpened = "artbook_opened";
public const string MainMenuReturned = "main_menu_returned";
// Core gameplay loop
public const string DrawingSelected = "drawing_selected";
public const string DrawingStarted = "drawing_started";
public const string DrawingCompleted = "drawing_completed";
public const string DrawingAbandoned = "drawing_abandoned";
public const string DrawingSaved = "drawing_saved";
public const string ShapeBuilderStarted = "shape_builder_started";
public const string ShapeAssembled = "shape_assembled";
public const string ColorApplied = "color_applied";
// Progression & content (GA4 recommended level_start / level_complete)
public const string LevelStart = "level_start";
public const string LevelComplete = "level_complete";
public const string AllContentCompleted = "all_content_completed";
// Gallery capture (pre-existing)
public const string GallerySaveStarted = "gallery_save_started";
public const string GallerySaveCompleted = "gallery_save_completed";
// Monetization (GA4 recommended ad_impression)
public const string AdImpression = "ad_impression";
public const string AdClicked = "ad_clicked";
// Friction
public const string ErrorShown = "error_shown";
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 44ad2e947d64f40a38febad220fa92a1

View File

@@ -0,0 +1,39 @@
namespace Darkmatter.Core.Contracts.Services.Analytics
{
/// <summary>
/// Canonical analytics parameter keys. Pass a consistent set on every gameplay event so reports can
/// answer "which drawing did people quit on", not just "how many quit". Each key used in reports must
/// be registered as a Custom Dimension in the Firebase console. GA4 limit: 25 params per event.
/// GA4-recommended events (level_start/level_complete/ad_impression) reuse GA4's reserved param names
/// (level_name, success, value, currency, ad_platform, ad_format, ad_unit_name) so they're recognised.
/// </summary>
public static class AnalyticsParams
{
// Content identity
public const string DrawingId = "drawing_id";
public const string LevelName = "level_name"; // GA4 reserved (level_start/level_complete)
// Tutorial
public const string StepId = "step_id";
public const string StepIndex = "step_index";
// Gameplay metrics
public const string DurationSeconds = "duration_seconds";
public const string ColorsUsed = "colors_used";
public const string Attempts = "attempts";
public const string ApplyIndex = "apply_index";
public const string CompletionCount = "completion_count";
public const string Success = "success"; // GA4 reserved (level_complete)
// Monetization (GA4 ad_impression reserved names)
public const string Value = "value";
public const string Currency = "currency";
public const string Precision = "precision";
public const string AdPlatform = "ad_platform";
public const string AdFormat = "ad_format";
public const string AdUnitName = "ad_unit_name";
// Friction
public const string ErrorType = "error_type";
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 3a7d4e7d6ad7e4ba39f557ddc970890c

View File

@@ -0,0 +1,7 @@
namespace Darkmatter.Core.Data.Signals.Features.AppBoot;
/// <summary>
/// Raised when the intro/logo video begins playing at app boot. Paired with
/// <see cref="IntroCompletedSignal"/> to bound the top of the activation funnel.
/// </summary>
public record struct IntroStartedSignal();

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: ebc4c1a0dab8d4b4fb95428c30f57f35

View File

@@ -0,0 +1,8 @@
namespace Darkmatter.Core.Data.Signals.Features.GameplayFlow
{
/// <summary>
/// Raised once the player has completed every drawing in the catalog (no content left). Published by
/// the gameplay flow, which owns the catalog + progression; analytics dedupes it to once-ever.
/// </summary>
public record struct AllContentCompletedSignal(int CompletedCount);
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 3dc3c138deba5452ebb0d84ad3d4bf44

View File

@@ -44,6 +44,7 @@ namespace Darkmatter.Features.AppBoot.Flow
player.loopPointReached += OnDone;
player.Play();
_eventBus.Publish(new IntroStartedSignal());
await _sceneService.LoadSceneAsync(nameof(GameScene.MainMenu), null, cancellation);
await tcs.Task.AttachExternalCancellation(cancellation);

View File

@@ -43,6 +43,7 @@ namespace Darkmatter.Features.GameplayFlow.Systems
private IDrawingTemplate _template;
private string _templateId;
private DrawingPhase _phase = DrawingPhase.ShapeBuilding;
private bool _allContentReported;
private IDisposable _assembledSub;
private IDisposable _colorAppliedSub;
@@ -151,6 +152,16 @@ namespace Darkmatter.Features.GameplayFlow.Systems
var progressAfter = _progression.GetProgress(_templateId);
_bus.Publish(new DrawingCompletedSignal(_templateId, progressAfter?.completionCount ?? 1));
// Player has cleared the whole catalog → surface the "ran out of content" milestone. Guarded
// per session here; analytics dedupes it to once-ever.
if (!_allContentReported &&
_catalog.AllTemplateIds.Count > 0 &&
_progression.CompletedTemplateIds.Count >= _catalog.AllTemplateIds.Count)
{
_allContentReported = true;
_bus.Publish(new AllContentCompletedSignal(_progression.CompletedTemplateIds.Count));
}
var nextId = _catalog.GetNextTemplate(_templateId);
if (string.IsNullOrEmpty(nextId))
{

View File

@@ -3,10 +3,12 @@ using System.Collections.Generic;
using System.Threading;
using Cysharp.Threading.Tasks;
using Darkmatter.Core.Contracts.Services.Ads;
using Darkmatter.Core.Contracts.Services.Analytics;
using Darkmatter.Core.Data.Dynamic.Services.Ads;
using Darkmatter.Core.Data.Static.Services.Ads;
using Darkmatter.Core.Enums.Services.Ads;
using UnityEngine;
using VContainer;
using AdFormat = Darkmatter.Core.Enums.Services.Ads.AdFormat;
#if GOOGLE_MOBILE_ADS
using GoogleMobileAds.Api;
@@ -29,6 +31,7 @@ namespace Darkmatter.Services.Ads
public bool IsInitialized => _initialized;
public event Action<AdFormat, AdLoadState> LoadStateChanged;
private IAnalyticsService _analytics;
private bool _initialized;
private bool _hasUserConsent = true;
// Coloring book is a child-directed app, so default to true. SetConsent can still
@@ -53,6 +56,12 @@ namespace Darkmatter.Services.Ads
private BannerView _banner;
#endif
// Method injection: AdMobAdService is a scene MonoBehaviour registered via RegisterComponent, so
// VContainer injects here rather than through a constructor. IAnalyticsService is the Root-scoped
// composite (Firebase + Facebook).
[Inject]
public void Construct(IAnalyticsService analytics) => _analytics = analytics;
private void Awake()
{
_lifetimeCts = new CancellationTokenSource();
@@ -210,6 +219,8 @@ namespace Darkmatter.Services.Ads
_banner?.Destroy();
_banner = new BannerView(unitId, MapBannerSize(size), MapBannerPosition(position));
_banner.OnAdPaid += v => LogAdImpression(AdFormat.Banner, v);
_banner.OnAdClicked += () => LogAdClicked(AdFormat.Banner);
var tcs = new UniTaskCompletionSource<bool>();
_banner.OnBannerAdLoaded += () => tcs.TrySetResult(true);
@@ -560,17 +571,59 @@ namespace Darkmatter.Services.Ads
catch (OperationCanceledException) { }
}
private void WireFullScreenEvents(InterstitialAd ad, AdFormat format) =>
private void WireFullScreenEvents(InterstitialAd ad, AdFormat format)
{
ad.OnAdFullScreenContentClosed += () => SetState(format, AdLoadState.Idle);
ad.OnAdPaid += v => LogAdImpression(format, v);
ad.OnAdClicked += () => LogAdClicked(format);
}
private void WireFullScreenEvents(RewardedAd ad, AdFormat format) =>
private void WireFullScreenEvents(RewardedAd ad, AdFormat format)
{
ad.OnAdFullScreenContentClosed += () => SetState(format, AdLoadState.Idle);
ad.OnAdPaid += v => LogAdImpression(format, v);
ad.OnAdClicked += () => LogAdClicked(format);
}
private void WireFullScreenEvents(RewardedInterstitialAd ad, AdFormat format) =>
private void WireFullScreenEvents(RewardedInterstitialAd ad, AdFormat format)
{
ad.OnAdFullScreenContentClosed += () => SetState(format, AdLoadState.Idle);
ad.OnAdPaid += v => LogAdImpression(format, v);
ad.OnAdClicked += () => LogAdClicked(format);
}
private void WireFullScreenEvents(AppOpenAd ad, AdFormat format) =>
private void WireFullScreenEvents(AppOpenAd ad, AdFormat format)
{
ad.OnAdFullScreenContentClosed += () => SetState(format, AdLoadState.Idle);
ad.OnAdPaid += v => LogAdImpression(format, v);
ad.OnAdClicked += () => LogAdClicked(format);
}
// Ad revenue: GA4-recommended ad_impression carries value+currency from AdMob's paid callback —
// this is what surfaces ad revenue in Firebase/GA4. Ad callbacks fire on the Unity main thread
// (RaiseAdEventsOnUnityMainThread = true), so logging straight to analytics is safe.
private void LogAdImpression(AdFormat format, AdValue value)
{
_analytics?.LogEvent(AnalyticsEvents.AdImpression, new Dictionary<string, object>
{
[AnalyticsParams.AdPlatform] = "AdMob",
[AnalyticsParams.AdFormat] = format.ToString(),
[AnalyticsParams.AdUnitName] = catalog.GetUnitId(format, Application.platform) ?? string.Empty,
[AnalyticsParams.Value] = value.Value / 1_000_000.0, // AdValue.Value is micros
[AnalyticsParams.Currency] = value.CurrencyCode ?? string.Empty,
[AnalyticsParams.Precision] = value.Precision.ToString(),
});
}
private void LogAdClicked(AdFormat format)
{
_analytics?.LogEvent(AnalyticsEvents.AdClicked, new Dictionary<string, object>
{
[AnalyticsParams.AdPlatform] = "AdMob",
[AnalyticsParams.AdFormat] = format.ToString(),
[AnalyticsParams.AdUnitName] = catalog.GetUnitId(format, Application.platform) ?? string.Empty,
});
}
private void ScheduleReload(AdFormat format)
{

View File

@@ -10,8 +10,17 @@ namespace Darkmatter.Services.Analytics
{
public void Register(IContainerBuilder builder)
{
builder.RegisterEntryPoint<FirebaseAnalyticsSystem>().As<IAnalyticsService>();
// Firebase + Facebook are sinks (registered AsSelf so the composite can inject them);
// CompositeAnalyticsService is the single IAnalyticsService the rest of the app consumes.
builder.RegisterEntryPoint<FirebaseAnalyticsSystem>().AsSelf();
builder.RegisterEntryPoint<FacebookAnalyticsSystem>().AsSelf();
builder.Register<CompositeAnalyticsService>(Lifetime.Singleton).As<IAnalyticsService>();
builder.RegisterEntryPoint<AnalyticsTracker>();
builder.RegisterEntryPoint<ErrorAnalyticsTracker>();
// Feeds the FCM token to AppsFlyer for uninstall measurement (Android-only).
builder.RegisterEntryPoint<AppsFlyerUninstallTracker>();
}
}
}

View File

@@ -2,14 +2,12 @@
"name": "Services.Analytics",
"rootNamespace": "Darkmatter.Services.Analytics",
"references": [
"GUID:bd7ea2d41bfe64d229c22616f66e20f7",
"GUID:c1c03c0e5b2f4412b9f2be1c20d6a9b1",
"GUID:f51ebe6a0ceec4240a699833d6309b23",
"GUID:b0214a6008ed146ff8f122a6a9c2f6cc",
"GUID:f8c64bb88d959406689053ae3f31183d",
"GUID:a0b1547602fc44f6da0a5e755ab3a7ef",
"GUID:6a0a834eb41764f12ba55c3fb04a40cb",
"GUID:b4c9f7fbf1e144933a1797dc208ece5f"
"GUID:b4c9f7fbf1e144933a1797dc208ece5f",
"GUID:2a37df438292d4903b4e5159c5de3bf9"
],
"includePlatforms": [],
"excludePlatforms": [],
@@ -20,4 +18,4 @@
"defineConstraints": [],
"versionDefines": [],
"noEngineReferences": false
}
}

View File

@@ -4,22 +4,43 @@ using Darkmatter.Core;
using Darkmatter.Core.Contracts.Services.Analytics;
using Darkmatter.Core.Data.Signals.Features.AppBoot;
using Darkmatter.Core.Data.Signals.Features.Capture;
using Darkmatter.Core.Data.Signals.Features.Coloring;
using Darkmatter.Core.Data.Signals.Features.Drawing;
using Darkmatter.Core.Data.Signals.Features.GameplayFlow;
using Darkmatter.Core.Data.Signals.Features.MainMenu;
using Darkmatter.Core.Data.Signals.Features.ShapeBuilder;
using Darkmatter.Core.Data.Signals.Features.Tutorial;
using Darkmatter.Libs.Observer;
using UnityEngine;
using VContainer.Unity;
namespace Darkmatter.Services.Analytics
{
/// <summary>
/// Bridges gameplay signals to analytics events. Holds light per-drawing state (start time, distinct
/// colors, shape attempts) so completion/abandon events carry duration_seconds, colors_used and
/// attempts — the params that turn "they quit" into "they quit on drawing X after N seconds".
///
/// Logs once to <see cref="IAnalyticsService"/> (the composite), which fans out to Firebase + Facebook.
/// </summary>
public sealed class AnalyticsTracker : IStartable, IDisposable
{
private const int ColorApplySampleEvery = 25; // color_applied is high-frequency → sample
private const string FirstDrawingKey = "analytics_first_drawing_started";
private const string AllContentKey = "analytics_all_content_completed";
private readonly IEventBus _bus;
private readonly IAnalyticsService _analytics;
private readonly List<IDisposable> _subs = new();
// Per-drawing aggregation state.
private string _activeDrawingId;
private float _drawingStartTime = -1f;
private readonly HashSet<int> _colors = new();
private int _colorApplyCount;
private int _shapeAttempts;
private bool _firstDrawingTracked;
public AnalyticsTracker(IEventBus bus, IAnalyticsService analytics)
{
_bus = bus;
@@ -28,40 +49,180 @@ namespace Darkmatter.Services.Analytics
public void Start()
{
_subs.Add(_bus.Subscribe<IntroCompletedSignal>(_ => _analytics.LogEvent("intro_completed")));
_subs.Add(_bus.Subscribe<PlayBtnClickedSignal>(_ => _analytics.LogEvent("play_clicked")));
_subs.Add(_bus.Subscribe<OpenColorBookSignal>(_ => _analytics.LogEvent("colorbook_opened")));
_subs.Add(_bus.Subscribe<OpenArtBookSignal>(_ => _analytics.LogEvent("artbook_opened")));
_subs.Add(_bus.Subscribe<ReturnToMainMenuSignal>(_ => _analytics.LogEvent("main_menu_returned")));
_firstDrawingTracked = PlayerPrefs.GetInt(FirstDrawingKey, 0) == 1;
_subs.Add(_bus.Subscribe<DrawingSelectedSignal>(s =>
_analytics.LogEvent("drawing_selected", "template_id", s.TemplateId)));
// Onboarding / activation funnel
_subs.Add(_bus.Subscribe<IntroStartedSignal>(_ => _analytics.LogEvent(AnalyticsEvents.IntroStarted)));
_subs.Add(_bus.Subscribe<IntroCompletedSignal>(_ => _analytics.LogEvent(AnalyticsEvents.IntroCompleted)));
_subs.Add(_bus.Subscribe<PlayBtnClickedSignal>(_ => _analytics.LogEvent(AnalyticsEvents.PlayClicked)));
_subs.Add(_bus.Subscribe<ShapeBuilderStartedSignal>(s =>
_analytics.LogEvent("shape_builder_started", "template_id", s.TemplateId)));
// Navigation
_subs.Add(_bus.Subscribe<OpenColorBookSignal>(_ => _analytics.LogEvent(AnalyticsEvents.ColorbookOpened)));
_subs.Add(_bus.Subscribe<OpenArtBookSignal>(_ => _analytics.LogEvent(AnalyticsEvents.ArtbookOpened)));
_subs.Add(_bus.Subscribe<ReturnToMainMenuSignal>(_ => OnReturnToMainMenu()));
_subs.Add(_bus.Subscribe<ShapeAssembledSignal>(s =>
_analytics.LogEvent("shape_assembled", "template_id", s.TemplateId)));
// Core gameplay loop
_subs.Add(_bus.Subscribe<DrawingSelectedSignal>(s => OnDrawingSelected(s.TemplateId)));
_subs.Add(_bus.Subscribe<ColorAppliedSignal>(OnColorApplied));
_subs.Add(_bus.Subscribe<ShapeBuilderStartedSignal>(s => OnShapeBuilderStarted(s.TemplateId)));
_subs.Add(_bus.Subscribe<PieceSnappedSignal>(_ => _shapeAttempts++));
_subs.Add(_bus.Subscribe<PieceUnsnappedSignal>(_ => _shapeAttempts++));
_subs.Add(_bus.Subscribe<ShapeAssembledSignal>(s => OnShapeAssembled(s.TemplateId)));
_subs.Add(_bus.Subscribe<DrawingCompletedSignal>(OnDrawingCompleted));
_subs.Add(_bus.Subscribe<DrawingCompletedSignal>(s => _analytics.LogEvent("drawing_completed",
// Progression & content
_subs.Add(_bus.Subscribe<AllContentCompletedSignal>(OnAllContentCompleted));
// Capture / save
_subs.Add(_bus.Subscribe<GallerySaveStartedSignal>(_ => _analytics.LogEvent(AnalyticsEvents.GallerySaveStarted)));
_subs.Add(_bus.Subscribe<GallerySaveCompletedSignal>(OnGallerySaveCompleted));
// Tutorial
_subs.Add(_bus.Subscribe<TutorialStartedSignal>(_ => _analytics.LogEvent(AnalyticsEvents.TutorialStarted)));
_subs.Add(_bus.Subscribe<TutorialStepCompletedSignal>(s => _analytics.LogEvent(AnalyticsEvents.TutorialStepCompleted,
new Dictionary<string, object>
{
["template_id"] = s.TemplateId,
["completion_count"] = s.CompletionCount,
[AnalyticsParams.StepId] = s.StepId,
[AnalyticsParams.StepIndex] = s.StepIndex,
})));
_subs.Add(_bus.Subscribe<TutorialCompletedSignal>(_ => _analytics.LogEvent(AnalyticsEvents.TutorialCompleted)));
}
_subs.Add(_bus.Subscribe<GallerySaveStartedSignal>(_ => _analytics.LogEvent("gallery_save_started")));
_subs.Add(_bus.Subscribe<GallerySaveCompletedSignal>(s =>
_analytics.LogEvent("gallery_save_completed", "success", s.Success ? "true" : "false")));
private void OnDrawingSelected(string drawingId)
{
// A new selection while a drawing is still open (uncompleted) = the previous one was abandoned.
EndActiveDrawingIfAbandoned();
_subs.Add(_bus.Subscribe<TutorialStartedSignal>(_ => _analytics.LogEvent("tutorial_started")));
_subs.Add(_bus.Subscribe<TutorialStepCompletedSignal>(s => _analytics.LogEvent("tutorial_step_completed",
new Dictionary<string, object>
_activeDrawingId = drawingId;
_drawingStartTime = Time.realtimeSinceStartup;
_colors.Clear();
_colorApplyCount = 0;
_shapeAttempts = 0;
_analytics.LogEvent(AnalyticsEvents.DrawingSelected, AnalyticsParams.DrawingId, drawingId);
_analytics.LogEvent(AnalyticsEvents.DrawingStarted, AnalyticsParams.DrawingId, drawingId);
_analytics.LogEvent(AnalyticsEvents.LevelStart, AnalyticsParams.LevelName, drawingId);
if (!_firstDrawingTracked)
{
_firstDrawingTracked = true;
PlayerPrefs.SetInt(FirstDrawingKey, 1);
PlayerPrefs.Save();
_analytics.LogEvent(AnalyticsEvents.FirstDrawingStarted, AnalyticsParams.DrawingId, drawingId);
}
}
private void OnColorApplied(ColorAppliedSignal s)
{
_colorApplyCount++;
Color32 c = s.Color;
_colors.Add((c.r << 24) | (c.g << 16) | (c.b << 8) | c.a);
// High-frequency event: emit only the first apply and every Nth thereafter.
if (_colorApplyCount == 1 || _colorApplyCount % ColorApplySampleEvery == 0)
{
_analytics.LogEvent(AnalyticsEvents.ColorApplied, new Dictionary<string, object>
{
["step_id"] = s.StepId,
["step_index"] = s.StepIndex,
})));
_subs.Add(_bus.Subscribe<TutorialCompletedSignal>(_ => _analytics.LogEvent("tutorial_completed")));
[AnalyticsParams.DrawingId] = _activeDrawingId ?? string.Empty,
[AnalyticsParams.ApplyIndex] = _colorApplyCount,
});
}
}
private void OnShapeBuilderStarted(string drawingId)
{
_shapeAttempts = 0;
_analytics.LogEvent(AnalyticsEvents.ShapeBuilderStarted, AnalyticsParams.DrawingId, drawingId);
}
private void OnShapeAssembled(string drawingId)
{
_analytics.LogEvent(AnalyticsEvents.ShapeAssembled, new Dictionary<string, object>
{
[AnalyticsParams.DrawingId] = drawingId,
[AnalyticsParams.Attempts] = _shapeAttempts,
});
}
private void OnDrawingCompleted(DrawingCompletedSignal s)
{
var p = new Dictionary<string, object>
{
[AnalyticsParams.DrawingId] = s.TemplateId,
[AnalyticsParams.CompletionCount] = s.CompletionCount,
[AnalyticsParams.ColorsUsed] = _colors.Count,
};
float dur = ElapsedDrawingSeconds();
if (dur >= 0f) p[AnalyticsParams.DurationSeconds] = dur;
_analytics.LogEvent(AnalyticsEvents.DrawingCompleted, p);
// GA4 recommended games event (lights up built-in level funnels).
_analytics.LogEvent(AnalyticsEvents.LevelComplete, new Dictionary<string, object>
{
[AnalyticsParams.LevelName] = s.TemplateId,
[AnalyticsParams.Success] = 1,
});
ClearActiveDrawing();
}
private void OnAllContentCompleted(AllContentCompletedSignal s)
{
if (PlayerPrefs.GetInt(AllContentKey, 0) == 1) return; // once ever
PlayerPrefs.SetInt(AllContentKey, 1);
PlayerPrefs.Save();
_analytics.LogEvent(AnalyticsEvents.AllContentCompleted,
AnalyticsParams.CompletionCount, s.CompletedCount.ToString());
}
private void OnGallerySaveCompleted(GallerySaveCompletedSignal s)
{
_analytics.LogEvent(AnalyticsEvents.GallerySaveCompleted, AnalyticsParams.Success, s.Success ? "true" : "false");
_analytics.LogEvent(AnalyticsEvents.DrawingSaved, new Dictionary<string, object>
{
[AnalyticsParams.DrawingId] = _activeDrawingId ?? string.Empty,
[AnalyticsParams.Success] = s.Success ? 1 : 0,
});
}
private void OnReturnToMainMenu()
{
EndActiveDrawingIfAbandoned();
_analytics.LogEvent(AnalyticsEvents.MainMenuReturned);
}
// Emits drawing_abandoned for an open, uncompleted drawing. Completion clears _activeDrawingId, so
// reaching here with a non-null id means the drawing was left without finishing — the leak signal.
private void EndActiveDrawingIfAbandoned()
{
if (_activeDrawingId == null) return;
var p = new Dictionary<string, object>
{
[AnalyticsParams.DrawingId] = _activeDrawingId,
[AnalyticsParams.ColorsUsed] = _colors.Count,
};
float dur = ElapsedDrawingSeconds();
if (dur >= 0f) p[AnalyticsParams.DurationSeconds] = dur;
_analytics.LogEvent(AnalyticsEvents.DrawingAbandoned, p);
ClearActiveDrawing();
}
private float ElapsedDrawingSeconds()
{
if (_drawingStartTime < 0f) return -1f;
float d = Time.realtimeSinceStartup - _drawingStartTime;
return d >= 0f ? d : -1f; // guard against realtime baseline reset across an app relaunch
}
private void ClearActiveDrawing()
{
_activeDrawingId = null;
_drawingStartTime = -1f;
_colors.Clear();
_colorApplyCount = 0;
_shapeAttempts = 0;
}
public void Dispose()

View File

@@ -0,0 +1,66 @@
using System;
using System.Threading;
using Cysharp.Threading.Tasks;
using UnityEngine;
using VContainer.Unity;
#if UNITY_ANDROID
using AppsFlyerSDK;
using Firebase;
using Firebase.Messaging;
#endif
namespace Darkmatter.Services.Analytics
{
/// <summary>
/// Forwards the Firebase Cloud Messaging registration token to AppsFlyer so it can measure uninstalls.
/// Android-only: AppsFlyer's server-uninstall measurement keys off the FCM token. iOS uninstall uses the
/// APNs device token via a different AppsFlyer API and is intentionally not wired here, so FCM is not
/// initialised on iOS.
///
/// AppsFlyer itself is started in the Boot scene (AppsFlyerObjectScript); this only feeds it the token.
/// </summary>
public class AppsFlyerUninstallTracker : IAsyncStartable, IDisposable
{
public async UniTask StartAsync(CancellationToken cancellation = default)
{
#if UNITY_ANDROID
DependencyStatus status;
try
{
status = await FirebaseApp.CheckAndFixDependenciesAsync().AsUniTask();
}
catch (Exception e)
{
Debug.LogError($"[AppsFlyerUninstall] Firebase init failed: {e}");
return;
}
if (status != DependencyStatus.Available)
{
Debug.LogError($"[AppsFlyerUninstall] Firebase deps unavailable: {status}");
return;
}
// Subscribing triggers FCM registration; the current token is delivered here on every refresh.
FirebaseMessaging.TokenReceived += OnTokenReceived;
#else
await UniTask.CompletedTask;
#endif
}
#if UNITY_ANDROID
private static void OnTokenReceived(object sender, TokenReceivedEventArgs e)
{
try { AppsFlyer.updateServerUninstallToken(e.Token); }
catch (Exception ex) { Debug.LogError($"[AppsFlyerUninstall] updateServerUninstallToken failed: {ex}"); }
}
#endif
public void Dispose()
{
#if UNITY_ANDROID
FirebaseMessaging.TokenReceived -= OnTokenReceived;
#endif
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 7a08ecb787f14b11a4f8ce163f223a53
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,43 @@
using System;
using System.Collections.Generic;
using Darkmatter.Core.Contracts.Services.Analytics;
using UnityEngine;
namespace Darkmatter.Services.Analytics
{
/// <summary>
/// The single <see cref="IAnalyticsService"/> the app consumes. Fans every call out to all sinks
/// (Firebase + Facebook) so call sites log once and both backends receive it. A throwing sink is
/// isolated so it can't break the others. To add a sink: add a constructor arg and include it below.
/// </summary>
public sealed class CompositeAnalyticsService : IAnalyticsService
{
private readonly IAnalyticsService[] _sinks;
public CompositeAnalyticsService(FirebaseAnalyticsSystem firebase, FacebookAnalyticsSystem facebook)
{
_sinks = new IAnalyticsService[] { firebase, facebook };
}
public void LogEvent(string name) =>
ForEach(s => s.LogEvent(name));
public void LogEvent(string name, string paramName, string paramValue) =>
ForEach(s => s.LogEvent(name, paramName, paramValue));
public void LogEvent(string name, IReadOnlyDictionary<string, object> parameters) =>
ForEach(s => s.LogEvent(name, parameters));
public void SetUserProperty(string name, string value) =>
ForEach(s => s.SetUserProperty(name, value));
private void ForEach(Action<IAnalyticsService> action)
{
foreach (var sink in _sinks)
{
try { action(sink); }
catch (Exception e) { Debug.LogError($"[Analytics] Sink {sink.GetType().Name} failed: {e}"); }
}
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: fbc94bef99b574c83a85b6886ff7fd03

View File

@@ -0,0 +1,51 @@
using System;
using System.Collections.Generic;
using Darkmatter.Core.Contracts.Services.Analytics;
using UnityEngine;
using VContainer.Unity;
namespace Darkmatter.Services.Analytics
{
/// <summary>
/// Forwards Unity error/exception logs to analytics as a sampled <c>error_shown</c> event so
/// crash-adjacent friction surfaces in funnels. Capped per session and deduped by error_type to avoid
/// event floods / GA4 quota burn. No message text or PII is sent — only the leading token of the
/// condition (e.g. "NullReferenceException"). Distinct from Crashlytics (which logs full crashes).
/// </summary>
public sealed class ErrorAnalyticsTracker : IStartable, IDisposable
{
private const int MaxPerSession = 25;
private readonly IAnalyticsService _analytics;
private readonly HashSet<string> _seen = new();
private int _count;
public ErrorAnalyticsTracker(IAnalyticsService analytics) => _analytics = analytics;
public void Start() => Application.logMessageReceived += OnLog;
public void Dispose() => Application.logMessageReceived -= OnLog;
private void OnLog(string condition, string stackTrace, LogType type)
{
if (type != LogType.Error && type != LogType.Exception) return;
if (_count >= MaxPerSession) return;
string errorType = ExtractType(condition);
if (!_seen.Add(errorType)) return; // one per distinct type per session
_count++;
_analytics.LogEvent(AnalyticsEvents.ErrorShown, AnalyticsParams.ErrorType, errorType);
}
// Leading token only — keeps cardinality low and avoids leaking message contents / PII.
private static string ExtractType(string condition)
{
if (string.IsNullOrEmpty(condition)) return "unknown";
int colon = condition.IndexOf(':');
string head = (colon > 0 ? condition.Substring(0, colon) : condition).Trim();
int space = head.IndexOf(' ');
if (space > 0) head = head.Substring(0, space);
return head.Length > 40 ? head.Substring(0, 40) : head;
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 05e0b4bc8382a40c0a78b122ac37a9fa

View File

@@ -0,0 +1,108 @@
using System;
using System.Collections.Generic;
using Darkmatter.Core.Contracts.Services.Analytics;
using Facebook.Unity;
using UnityEngine;
using VContainer.Unity;
namespace Darkmatter.Services.Analytics
{
/// <summary>
/// Facebook App Events sink. Mirrors <see cref="FirebaseAnalyticsSystem"/>'s readiness queue: events
/// logged before FB.Init completes are buffered and flushed on init.
///
/// Compliance: child-directed app — advertiser-ID collection is OFF (FacebookSettings), so App Events
/// flow without IDFA. Do NOT enable advertiser tracking / ATT here. See memory child-directed-coppa.
/// </summary>
public class FacebookAnalyticsSystem : IAnalyticsService, IStartable
{
private bool _ready;
private readonly Queue<Action> _pending = new();
public void Start()
{
if (FB.IsInitialized) OnInitialized();
else FB.Init(OnInitialized);
}
private void OnInitialized()
{
FB.ActivateApp();
_ready = true;
while (_pending.Count > 0)
{
try { _pending.Dequeue().Invoke(); }
catch (Exception e) { Debug.LogError($"[FacebookAnalytics] Queued event failed: {e}"); }
}
}
public void LogEvent(string name) =>
Run(() => FB.LogAppEvent(MapName(name)));
public void LogEvent(string name, string paramName, string paramValue) =>
Run(() => FB.LogAppEvent(MapName(name), null,
new Dictionary<string, object> { [paramName] = paramValue ?? string.Empty }));
public void LogEvent(string name, IReadOnlyDictionary<string, object> parameters)
{
if (parameters == null || parameters.Count == 0)
{
LogEvent(name);
return;
}
var dict = new Dictionary<string, object>(parameters.Count);
float? valueToSum = null;
foreach (var kv in parameters)
{
// GA4 ad_impression carries revenue in "value"; surface it to FB as the summable value.
if (kv.Key == AnalyticsParams.Value && TryToFloat(kv.Value, out var v)) valueToSum = v;
dict[kv.Key] = ToFbValue(kv.Value);
}
Run(() => FB.LogAppEvent(MapName(name), valueToSum, dict));
}
// Facebook App Events has no user-property concept.
public void SetUserProperty(string name, string value) { }
private void Run(Action action)
{
if (_ready)
{
try { action(); }
catch (Exception e) { Debug.LogError($"[FacebookAnalytics] Event failed: {e}"); }
}
else _pending.Enqueue(action);
}
// Map our snake_case names to Facebook standard events where one exists; else log as custom.
// (This SDK's AppEventName has no Ad* constants, so ad_impression / ad_clicked stay custom.)
private static string MapName(string name) => name switch
{
AnalyticsEvents.TutorialCompleted => AppEventName.CompletedTutorial,
AnalyticsEvents.LevelComplete => AppEventName.AchievedLevel,
_ => name
};
private static object ToFbValue(object value) => value switch
{
null => string.Empty,
bool b => b ? 1 : 0,
string s => s,
int or long or float or double => value,
_ => value.ToString()
};
private static bool TryToFloat(object value, out float result)
{
switch (value)
{
case float f: result = f; return true;
case double d: result = (float)d; return true;
case int i: result = i; return true;
case long l: result = l; return true;
default: result = 0f; return false;
}
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 196c5d190005d45f885236d8765813d1

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

Binary file not shown.