Meta and appflyer deeper intregration
This commit is contained in:
@@ -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 <= 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";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 44ad2e947d64f40a38febad220fa92a1
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 3a7d4e7d6ad7e4ba39f557ddc970890c
|
||||
@@ -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();
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: ebc4c1a0dab8d4b4fb95428c30f57f35
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 3dc3c138deba5452ebb0d84ad3d4bf44
|
||||
@@ -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);
|
||||
|
||||
@@ -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))
|
||||
{
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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>();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 7a08ecb787f14b11a4f8ce163f223a53
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -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}"); }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: fbc94bef99b574c83a85b6886ff7fd03
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 05e0b4bc8382a40c0a78b122ac37a9fa
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 196c5d190005d45f885236d8765813d1
|
||||
Reference in New Issue
Block a user