Chương 5: Analytics (App.Analytics)

Hướng dẫn toàn diện về dịch vụ thu thập và phân tích dữ liệu người chơi qua App.Analytics (giao diện IAnalyticsService). Tìm hiểu mô hình Fan-out Hub chuyển phát sự kiện đa luồng, cơ chế tự động đo lường thời gian chơi, bảng ánh xạ chuẩn hoá sang Firebase Analytics, GameAnalytics, AppMetrica và SayKit.

1. Mục đích & Mô hình kiến trúc (Mental Model)

Trong phát triển game di động thương mại, việc chuyển đổi qua lại giữa các nhà phát hành (Publishers) hoặc thay đổi công cụ phân tích (Firebase, GameAnalytics, AppMetrica, SayKit) thường dẫn đến việc phải viết lại toàn bộ mã nguồn theo dõi dữ liệu. MGW SDK giải quyết triệt để vấn đề này thông qua kiến trúc Fan-out Hub và Ngôn ngữ sự kiện thống nhất:

  • Một điểm gọi duy nhất (Single Point of Tracking): Mã nguồn game chỉ tương tác với duy nhất Facade App.Analytics (trả về IAnalyticsService thuộc namespace TapBrain.Core.Services). Lập trình viên không bao giờ gọi trực tiếp SDK của bên thứ ba trong logic game.
  • Trung tâm điều phối Fan-out Hub: Bên dưới Facade, lớp nội bộ AnalyticsService giữ vai trò trung tâm phân phối (Hub). Khi một sự kiện được kích hoạt, Hub sẽ đóng gói thành cấu trúc TrackEvent và chuyển lần lượt (đồng bộ, trên luồng chính) tới các bộ thu nhận (Sinks) đã sẵn sàng; sink chưa sẵn sàng bị bỏ qua.
  • Cầu nối tương thích ngữ nghĩa (Tracking Bridge):
    • Provider SayKit: Lớp SayKitAnalyticsSink hiện thực giao diện ITrackingSink, tiếp nhận trực tiếp TrackEvent và chuyển thẳng tới các hàm gốc như SayKit.trackLevelStarted, SayKit.trackSoftIncome...
    • Provider MGW: Các Sink như FirebaseAnalyticsSink, GameAnalyticsSink, AppMetricaSink tiếp nhận tên sự kiện generic và từ điển tham số đã được chuẩn hoá bởi lớp TrackEventMapper.ToGeneric().
Tư duy thiết kế sự kiện

Hệ thống danh mục sự kiện của MGW SDK được chuẩn hoá theo quy ước số màn chơi bắt đầu từ 1 (dành cho người chơi nhìn thấy). Thứ tự khuyến nghị cho một lượt chơi: LevelStarted → (các StageStarted/StageCompleted nếu có) → LevelCompleted hoặc LevelFailed. SDK không ép thứ tự này: nếu thiếu LevelStarted, sự kiện kết thúc vẫn được gửi nhưng thiếu duration_sec, còn sự kiện stage mang level = 0. Hãy gọi LevelStarted cho mỗi lượt chơi, kể cả khi chơi lại (retry).

2. Quy trình tích hợp từng bước

Bước 1: Theo dõi vòng đời màn chơi (Levels & Stages)

Khi bắt đầu một màn chơi mới, gọi App.Analytics.LevelStarted(level, tag). Khi người chơi hoàn thành hoặc thất bại, gọi LevelCompleted hoặc LevelFailed. Trên các sink của Provider MGW, SDK tự tính thời lượng chơi (giây) từ mốc bắt đầu và đính kèm vào sự kiện kết thúc dưới dạng duration_sec.

using TapBrain.Core;
using UnityEngine;

namespace MyGame.Gameplay
{
    public class LevelCoordinator : MonoBehaviour
    {
        public void StartNewLevel(int levelNumber)
        {
            // Start the level (level numbers start at 1)
            App.Analytics.LevelStarted(levelNumber, tag: "campaign");
        }

        public void FinishLevel(int levelNumber, int earnedScore, bool isVictory)
        {
            if (isVictory)
            {
                // On MGW sinks duration_sec is measured from LevelStarted and attached automatically
                App.Analytics.LevelCompleted(levelNumber, score: earnedScore, tag: "campaign");
            }
            else
            {
                App.Analytics.LevelFailed(levelNumber, score: earnedScore, tag: "campaign");
            }
        }
    }
}

Bước 2: Theo dõi biến động kinh tế ảo (Virtual Currency)

Bất cứ khi nào số dư tiền tệ của người chơi thay đổi (nhận thưởng hoàn thành màn chơi, xem quảng cáo, mua lượt chơi hoặc nâng cấp trang bị), hãy ghi nhận bằng CurrencyIncome hoặc CurrencyOutcome. Phân loại tiền tệ sử dụng enum CurrencyType.Soft (tiền vàng/bạc kiếm được trong game) hoặc CurrencyType.Hard (kim cương/ngọc quý mua bằng tiền thật).

using TapBrain.Core;
using TapBrain.Core.Services;

namespace MyGame.Economy
{
    public class BankManager
    {
        private int _goldBalance = 500;

        public void AddReward(int amount, string placement)
        {
            _goldBalance += amount;
            App.Analytics.CurrencyIncome(CurrencyType.Soft, amount, _goldBalance, place: placement);
        }

        public bool SpendGold(int cost, string itemKey)
        {
            if (_goldBalance < cost) return false;
            _goldBalance -= cost;
            App.Analytics.CurrencyOutcome(CurrencyType.Soft, cost, _goldBalance, place: $"shop_{itemKey}");
            return true;
        }
    }
}

Bước 3: Gửi sự kiện tuỳ chỉnh (Custom Event)

Khi cần ghi nhận các hành vi đặc thù của game không nằm trong các hàm dựng sẵn, bạn có hai lựa chọn:

  • Khuyên dùng: App.Analytics.Event(name, param1, param2, extra1, extra2) — Thiết kế theo cấu trúc vị trí cố định (Fixed-slot architecture). Cả 4 slot được gửi đủ lên Firebase, AppMetrica và SayKit. GameAnalytics chỉ nhận param1 (dưới khoá value, khi khác 0); param2, extra1, extra2 bị bỏ trên GameAnalytics. Trên MGW, slot bằng 0 hoặc rỗng không được gửi.
  • Linh hoạt: App.Analytics.LogEvent(eventName, parameters) — Nhận từ điển tự do IReadOnlyDictionary<string, object>. Lưu ý: trên SayKit chỉ tham số số đầu tiên và chuỗi đầu tiên được giữ lại; trên GameAnalytics chỉ tham số có khoá "value" (kiểu số) được trích xuất. LogEvent không đi qua cầu nối TrackEvent và không kiểm tra tên sự kiện.

3. Bảng tra cứu toàn bộ API (App.Analytics)

Tất cả các phương thức dưới đây đều thuộc giao diện IAnalyticsService (namespace TapBrain.Core.Services), được truy xuất thông qua Facade tĩnh App.Analytics:

Chữ ký phương thức Tham số & Giá trị mặc định Mô tả chức năng & Quy tắc sử dụng
void LevelStarted(int level, string tag = null) level: Thứ tự màn (bắt đầu từ 1)
tag: Nhãn phân loại chế độ chơi (tuỳ chọn)
Ghi nhận bắt đầu màn chơi chính. Lưu lại mốc thời gian thực để đo thời lượng chơi khi kết thúc.
void LevelCompleted(int level, long score = 0, string tag = null) level: Số màn chơi
score: Điểm đạt được (mặc định 0)
tag: Nhãn phân loại
Ghi nhận người chơi vượt qua màn chơi. Trên sink MGW, tự đính kèm duration_sec (nếu đã gọi LevelStarted) và xoá mốc bắt đầu.
void LevelFailed(int level, long score = 0, string tag = null) level: Số màn chơi
score: Điểm số tại lúc thua
tag: Nhãn phân loại
Ghi nhận thất bại trong màn chơi. Trên sink MGW, tự đính kèm duration_sec (nếu đã gọi LevelStarted) và xoá mốc bắt đầu, nên chơi lại cần gọi LevelStarted lần nữa.
void StageStarted(int number, string tag = null) number: Thứ tự vòng/chặng trong level (1, 2, 3...)
tag: Nhãn phân loại
Bắt đầu một chặng con trong level hiện tại. Tự động lấy số level từ lần gọi LevelStarted gần nhất.
void StageCompleted(int number, long score = 0, string tag = null) number: Số chặng
score: Điểm chặng
tag: Nhãn phân loại
Hoàn thành chặng con. Trên sink MGW, duration_sec của chặng chỉ có khi đã gọi StageStarted; số level lấy từ lần LevelStarted gần nhất (0 nếu chưa có).
void StageFailed(int number, long score = 0, string tag = null) number: Số chặng
score: Điểm số
tag: Nhãn phân loại
Thất bại tại chặng con. Tự động ghi nhận thời lượng chặng và số level cha.
void ExtraLevelStarted(string tag = null) tag: Tên/nhãn màn đặc biệt (bonus, boss, minigame) Theo dõi màn chơi phụ hoặc màn thưởng ngoài luồng chính. Không làm tăng bộ đếm level người chơi.
void ExtraLevelCompleted(long score = 0, string tag = null) score: Điểm thưởng đạt được
tag: Nhãn màn đặc biệt
Ghi nhận hoàn thành màn chơi phụ/màn thưởng.
void ExtraLevelFailed(long score = 0, string tag = null) score: Điểm số khi thua
tag: Nhãn màn đặc biệt
Ghi nhận thất bại tại màn chơi phụ.
void TutorialStep(string tutorialName, string stepName) tutorialName: Tên chuỗi hướng dẫn
stepName: Bước cụ thể (ví dụ: "drag_card")
Theo dõi từng bước của luồng hướng dẫn tân thủ nhằm phân tích tỉ lệ rơi rụng (Drop-off rate).
void TutorialCompleted() Không có tham số Ghi nhận người chơi đã hoàn thành toàn bộ chuỗi hướng dẫn cơ bản ban đầu.
void ItemReceived(string item) item: Tên hoặc định danh vật phẩm nhận được Ghi nhận người chơi sở hữu vật phẩm, trang bị, thẻ bài hoặc skin mới.
void Screen(string screen) screen: Tên màn hình/giao diện mở ra Ghi nhận chuyển hướng màn hình (UI Screen View). Ánh xạ thành sự kiện chuẩn screen_view.
void Click(string screen, string element) screen: Màn hình hiện tại
element: Tên nút hoặc đối tượng được tương tác
Ghi nhận hành vi click chuột hoặc chạm nút UI cụ thể trên màn hình.
void CurrencyIncome(CurrencyType currency, int amount, int total, string place = null) currency: Loại tiền tệ (Soft / Hard)
amount: Lượng tiền nhận thêm (> 0)
total: Tổng số dư ví sau khi cộng
place: Vị trí/nguồn cấp (tuỳ chọn)
Ghi nhận nguồn thu tiền tệ ảo. Ánh xạ thành earn_virtual_currency với khoá virtual_currency_name.
void CurrencyOutcome(CurrencyType currency, int amount, int total, string place = null) currency: Loại tiền tệ (Soft / Hard)
amount: Lượng tiền tiêu trừ (> 0)
total: Tổng số dư ví sau khi trừ
place: Lý do/mục chi tiêu
Ghi nhận chi tiêu tiền tệ ảo. Ánh xạ thành spend_virtual_currency với đầy đủ số dư và nguồn tiêu.
void Event(string name, long param1 = 0, long param2 = 0, string extra1 = null, string extra2 = null) name: Tên sự kiện
param1: Giá trị số 1 (gán vào khoá value)
param2: Giá trị số 2
extra1, extra2: Tham số chuỗi bổ sung
Sự kiện tuỳ chỉnh theo 4 slot cố định của SayKit. Gửi đủ 4 slot lên Firebase, AppMetrica và SayKit; GameAnalytics chỉ nhận param1 (khoá value). Trên MGW, slot bằng 0 hoặc rỗng bị bỏ. Tên rỗng/null bị bỏ kèm cảnh báo.
void LogEvent(string eventName, IReadOnlyDictionary<string, object> parameters = null) eventName: Tên sự kiện
parameters: Từ điển tham số tự do (tuỳ chọn)
Gửi sự kiện với cấu trúc tham số tự do. Firebase nhận đủ khoá; AppMetrica nhận JSON tự dựng; GameAnalytics chỉ nhận khoá value kiểu số; SayKit chỉ giữ 1 số và 1 chuỗi đầu tiên. Không kiểm tra tên sự kiện (tên rỗng vẫn được chuyển tới sink).
void SetUserProperty(string name, string value) name: Tên thuộc tính
value: Giá trị thuộc tính người dùng
Gán thuộc tính người dùng (ví dụ: cấp VIP). Chỉ Firebase Analytics hỗ trợ; GameAnalytics, AppMetrica và SayKit bỏ qua. Lệnh gọi trước khi Firebase sẵn sàng bị mất (không được đệm): chỉ gọi sau khi FirebaseService.IsAvailable là true hoặc sau khi boot xong.

4. Bảng ánh xạ sự kiện chi tiết (TrackEventMapper & Generic Events)

Khi chạy trên Provider MGW, các lệnh gọi từ App.Analytics được lớp TrackEventMapper chuyển đổi sang định dạng sự kiện Generic (tương thích GA4 / Firebase) trước khi chuyển phát đến Firebase Analytics, GameAnalytics và AppMetrica. Lưu ý: bảng dưới là tập tham số mà Firebase và AppMetrica nhận; GameAnalytics chỉ nhận tên sự kiện kèm tham số số value (nếu có):

Lệnh gọi từ App.Analytics Tên sự kiện Generic Tham số đính kèm (chỉ tag/place bị bỏ khi rỗng; duration_sec bị bỏ khi thiếu mốc start; slot của Event bị bỏ khi 0/rỗng)
LevelStarted level_start level (int), tag (string)
LevelCompleted level_complete level (int), score (long), duration_sec (int, nếu có mốc start), tag (string)
LevelFailed level_fail level (int), score (long), duration_sec (int, nếu có mốc start), tag (string)
StageStarted stage_start level (int - tự điền từ level hiện tại), stage (int), tag (string)
StageCompleted stage_complete level (int), stage (int), score (long), duration_sec (int, nếu đã gọi StageStarted), tag (string)
StageFailed stage_fail level (int), stage (int), score (long), duration_sec (int, nếu đã gọi StageStarted), tag (string)
ExtraLevelStarted extra_level_start tag (string)
ExtraLevelCompleted extra_level_complete score (long), tag (string)
ExtraLevelFailed extra_level_fail score (long), tag (string)
TutorialStep tutorial_step tutorial (string), step (string)
TutorialCompleted tutorial_complete Không có tham số (tương thích GA4 chuẩn)
ItemReceived item_received item (string)
Screen screen_view screen_name (string)
Click ui_click screen (string), element (string)
CurrencyIncome earn_virtual_currency virtual_currency_name ("soft" hoặc "hard"), value (long), total (long), place (string)
CurrencyOutcome spend_virtual_currency virtual_currency_name ("soft" hoặc "hard"), value (long), total (long), place (string)
Event(name, ...) name value (long - từ param1 nếu ≠ 0), param2 (long nếu ≠ 0), extra1 (string, nếu không rỗng), extra2 (string, nếu không rỗng)
GameAnalytics chỉ nhận một giá trị số

GameAnalyticsSink gửi mọi sự kiện dưới dạng Design Event với duy nhất tham số số value (nếu có và là số). Vì vậy level_start, level_complete, stage_*, screen_view... đến GameAnalytics chỉ còn tên sự kiện; level, score, stage, tag, duration_sec không được gửi. Riêng earn_virtual_currency/spend_virtual_currency mang value = số lượng tiền (amount), và Event(...) mang value = param1.

Cơ chế tự động đo duration_sec và kế thừa level

Trong AnalyticsService, biến nội bộ _currentLevel ghi nhớ màn chơi đang hoạt động khi LevelStarted được gọi. Mốc thời gian thực _levelStartedAt = Time.realtimeSinceStartup cũng được kích hoạt.

  • Khi gọi StageStarted(number), SDK tự gán tham số level = _currentLevel mà không yêu cầu bạn phải truyền lại. Đồng thời kích hoạt bộ đếm thời gian riêng _stageStartedAt cho chặng.
  • Khi kết thúc màn chơi qua LevelCompleted hoặc LevelFailed, SDK tính toán:
    int seconds = Mathf.RoundToInt(Time.realtimeSinceStartup - _levelStartedAt);
    _levelStartedAt = -1f; // Reset after use
    Nếu chưa gọi LevelStarted (hoặc mốc đã bị xoá bởi lần kết thúc trước đó, ví dụ chơi lại mà không gọi LevelStarted), thời gian là -1 và tham số duration_sec bị lược bỏ hoàn toàn. Cơ chế tương tự áp dụng cho StageStarted → StageCompleted/StageFailed qua mốc riêng của chặng.
  • duration_sec và việc tự điền level cho sự kiện stage nằm trong bản ánh xạ generic nên chỉ tác động tới các sink MGW (Firebase, AppMetrica; GameAnalytics không nhận tham số này). Sink SayKit nhận lệnh gọi gốc (SayKit.trackLevelCompleted...) và không được SDK truyền duration_sec.

Sự khác biệt cốt lõi: Event vs LogEvent

Tiêu chí App.Analytics.Event(...) App.Analytics.LogEvent(...)
Cấu trúc tham số Cố định: 2 số nguyên (param1, param2) và 2 chuỗi (extra1, extra2). Tự do: Nhận từ điển IReadOnlyDictionary<string, object> tuỳ ý.
Hành vi trên SayKit Đủ 4 slot: Gọi SayKit.trackEvent với overload nhỏ nhất vẫn giữ mọi slot khác 0/rỗng (name, param1, param2, extra1, extra2). Bị cắt giảm: Duyệt từ điển theo thứ tự liệt kê, chỉ lấy giá trị số đầu tiên làm param1 (bool thành 0/1, float/double làm tròn thành số nguyên) và chuỗi đầu tiên làm extra1. param2, extra2 luôn trống; các tham số còn lại bị huỷ bỏ!
Hành vi trên GameAnalytics Chỉ param1 (khi khác 0) được ánh xạ vào khoá "value" để GA ghi nhận thành giá trị Design Event. param2, extra1, extra2 bị bỏ. Chỉ đọc duy nhất tham số mang khoá "value" nếu là kiểu số (float, double, int, long). Các tham số khác bị bỏ qua.
Khuyến nghị sử dụng Ưu tiên sử dụng cho sự kiện tuỳ biến thông thường: cùng một lệnh gọi chạy trên cả MGW và SayKit (riêng GameAnalytics chỉ nhận param1). Chỉ dùng khi làm việc thuần tuý với MGW và bắt buộc phải gửi từ điển tham số phức tạp lên Firebase Analytics.

Quy tắc kiểu dữ liệu tham số & Giới hạn nền tảng

  • Các kiểu dữ liệu được hỗ trợ: int, long, float, double, bool, string. Kiểu khác được chuyển thành chuỗi bằng ToString() trên Firebase và AppMetrica, và bị bỏ qua trên GameAnalytics và SayKit.
  • Chuyển đổi kiểu Boolean: FirebaseAnalyticsSink ép boolean thành 1L (true) hoặc 0L (false); null thành chuỗi rỗng.
  • AppMetrica dùng JSON tự dựng: AppMetricaSink ghép từ điển thành chuỗi JSON rồi gọi AppMetrica.ReportEvent(name, jsonString). Chỉ int, long, float, double được ghi dạng số; bool và mọi kiểu khác thành chuỗi (bool thành "True"/"False"). Chỉ ký tự " trong giá trị được escape: dấu \ và xuống dòng không được escape nên có thể tạo JSON không hợp lệ, hãy tránh các ký tự này trong giá trị chuỗi.
  • Sự kiện không có tên (Nameless Event): Chỉ Event(name, ...) bị kiểm tra: nếu name là chuỗi rỗng hoặc null, hệ thống bỏ qua sự kiện và ghi cảnh báo [Analytics] Custom event without a name ignored. LogEvent không kiểm tra tên: tên rỗng vẫn được chuyển tới các sink (Firebase sẽ ghi lỗi [Firebase] LogEvent ... failed), nên hãy tự đảm bảo tên không rỗng.
  • Quy tắc đặt tên và giới hạn của từng nền tảng (độ dài tên, số tham số tối đa của Firebase/GA...) do nhà cung cấp quy định và không được SDK kiểm tra: hãy tra tài liệu của nền tảng tương ứng.

5. Các sự kiện SDK tự động ghi nhận (Tuyệt đối không gửi trùng lặp)

Quy tắc cấm kỵ: Tuyệt đối không tự log doanh thu quảng cáo và IAP

Mã nguồn gameplay của bạn tuyệt đối không được gửi sự kiện doanh thu quảng cáo hoặc đơn hàng IAP thủ công. Toàn bộ các sự kiện này đã được MGW SDK theo dõi tự động từ tầng hạ tầng với độ chính xác tuyệt đối. Việc tự ý bắn thêm sự kiện sẽ khiến dữ liệu trên Dashboard bị nhân đôi (Double Counting), làm sai lệch hoàn toàn chỉ số LTV và ROAS của chiến dịch Marketing.

Hạng mục Đơn vị tự động ghi nhận Sự kiện được gửi lên các nền tảng phân tích
Doanh thu quảng cáo (Ad Revenue / ILRD) Tự động kích hoạt bởi MaxAdsService (Provider MGW) ngay khi có dữ liệu doanh thu của lượt hiển thị. Trên Provider SayKit, callback doanh thu của SayKit cũng gọi vào hub nhưng SayKitAnalyticsSink không chuyển tiếp đi đâu cả (SayKit tự ghi nhận quảng cáo của nó).
  • Firebase: Sự kiện chuẩn ad_impression đính kèm ad_platform="AppLovin", ad_source, ad_unit_name, ad_format, currency="USD", value=revenueUsd.
  • AppMetrica: Gửi đối tượng AdRevenue đầy đủ định dạng qua ReportAdRevenue.
  • GameAnalytics: Không nhận qua hub (LogAdRevenue của sink trống); thay vào đó sink đăng ký GameAnalyticsILRD.SubscribeMaxImpressions() khi kích hoạt (cần define gameanalytics_max_enabled theo ghi chú trong mã nguồn sink).
Giao dịch In-App Purchase Tự động kích hoạt bởi IapService một lần cho mỗi giao dịch mới, ngay sau khi biên lai được xác thực (trước khi trao thưởng; giao dịch được giao lại ở lần mở sau không báo lại). Trên Provider SayKit, sink không chuyển tiếp; giao dịch được báo qua SayKitPurchaseTracker (chỉ có khi define SAYKIT_PURCHASING được bật).
  • Firebase: Sự kiện chuẩn purchase đính kèm item_id, transaction_id, currency, value.
  • AppMetrica: Gửi đối tượng Revenue chính xác tới micro-unit.
  • GameAnalytics: Gửi sự kiện kinh doanh chuẩn NewBusinessEvent(currency, cents, "iap", productId, "store").

6. Cơ chế hoạt động Runtime & Hợp đồng vận hành

Trạng thái sẵn sàng của Sink & Cơ chế loại bỏ sự kiện sớm

Trong suốt chu kỳ khởi động của ứng dụng, các module phân tích cần thời gian để kết nối mạng và giải quyết phụ thuộc:

  • Firebase Analytics: Chỉ kích hoạt khi CoreConfig.enableFirebase = true. IsReady chỉ thành true sau khi CheckAndFixDependenciesAsync trả về Available và FirebaseApp.DefaultInstance tạo được. Sự kiện (và SetUserProperty) gọi trước thời điểm đó bị bỏ qua và mất hẳn: hub chỉ chuyển cho sink đã sẵn sàng, không đệm lại.
  • AppMetrica: Chỉ có khi project bật define TB_USE_APPMETRICA và cài package io.appmetrica.analytics, và CoreConfig.enableAppMetrica = true. Sink sẵn sàng khi appMetricaApiKey đã được điền (khác rỗng và khác REPLACE_ME); nếu không, hệ thống ghi cảnh báo và sink bị tắt trong phiên chạy đó.
  • GameAnalytics: Chỉ có khi bật define TB_USE_GA và cài package com.gameanalytics.sdk, và CoreConfig.enableGameAnalytics = true. Khi kích hoạt, sink tạo GameObject GameAnalytics nếu cảnh chưa có, rồi khởi tạo SDK. Khoá lấy từ cửa sổ cấu hình GameAnalytics, không phải CoreConfig.
  • SayKit: Lớp SayKitAnalyticsSink luôn trả về IsReady = true vì bản thân SDK của SayKit đã tích hợp sẵn cơ chế đệm sự kiện trong bộ nhớ (Memory Buffering) trước khi hoàn tất kết nối máy chủ.

So sánh tổng hợp giữa Provider MGW và SayKit

Đặc điểm hoạt động Provider MGW (Mặc định) Provider SayKit (SayGames)
Các Sink hoạt động Firebase Analytics, GameAnalytics (tuỳ chọn), AppMetrica (tuỳ chọn). Duy nhất SayKitAnalyticsSink kết nối tới hệ thống máy chủ phân tích của SayGames.
Cơ chế truyền sự kiện Ánh xạ qua TrackEventMapper.ToGeneric để phân rã thành tên sự kiện chuẩn và Dictionary. Truyền trực tiếp đối tượng cấu trúc TrackEvent qua giao diện ITrackingSink.
Hỗ trợ thuộc tính người dùng (User Properties) Chỉ Firebase hỗ trợ qua App.Analytics.SetUserProperty(name, value); GameAnalytics và AppMetrica bỏ qua. Không hỗ trợ (lệnh gọi bị bỏ qua an toàn, không gây lỗi).
Xử lý sự kiện tự do (LogEvent) Firebase nhận mọi khoá (bool thành 0/1); AppMetrica nhận JSON tự dựng (số giữ nguyên, kiểu khác thành chuỗi); GameAnalytics chỉ nhận khoá value kiểu số. Chỉ giữ lại số đầu tiên và chuỗi đầu tiên, bỏ qua toàn bộ phần còn lại.

7. Nhật ký Console chuẩn (Expected Log Lines)

Dưới đây là các dòng log xuất ra cửa sổ Unity Console qua lớp CoreLog với định dạng [Tag] Message:

Khi nào dòng Info xuất hiện

Các dòng mức Info chỉ được in khi CoreConfig.verboseLog = true hoặc khi chạy bản development (Debug.isDebugBuild). Cảnh báo (Warning) và lỗi (Error) luôn được in. Dòng [Analytics] <tên> chỉ cho biết hub đã nhận và ánh xạ sự kiện: nó chỉ in tên sự kiện (không in tham số) và được in trước khi chuyển cho sink, nên không chứng minh sink đã nhận (sink chưa sẵn sàng sẽ bỏ sự kiện).

Chuỗi Log xuất hiện trên Console Mức độ Ý nghĩa & Ngữ cảnh xuất hiện
[Analytics] level_start Log Info Hub đã nhận lệnh LevelStarted và ánh xạ thành sự kiện level_start (chỉ log tên).
[Analytics] level_complete Log Info Hub đã nhận lệnh LevelCompleted (tham số như duration_sec không xuất hiện trong log).
[Analytics] earn_virtual_currency Log Info Hub đã nhận lệnh CurrencyIncome (chỉ log tên sự kiện, không log số dư).
[Analytics] item_upgrade Log Info Hub đã nhận sự kiện tuỳ chỉnh tên item_upgrade (qua Event hoặc LogEvent).
[Analytics] Custom event without a name ignored Warning Event(name, ...) được gọi với tên rỗng hoặc null: sự kiện bị huỷ (không áp dụng cho LogEvent).
[Firebase] Ready (Analytics + Crashlytics) Log Info Module Firebase đã khởi tạo thành công, FirebaseAnalyticsSink bắt đầu nhận sự kiện.
[Firebase] LogEvent level_start failed: ... Error Firebase SDK ném ngoại lệ khi ghi sự kiện; thông điệp lỗi của Firebase đi kèm sau dấu hai chấm. Sự kiện đó không được gửi, các sink khác không bị ảnh hưởng.
[AppMetrica] API key not set in CoreConfig, AppMetrica disabled Warning AppMetrica (đã bật enableAppMetrica) bị vô hiệu hoá trong phiên chạy vì chưa cấu hình API Key trong CoreConfig.

8. So sánh Code Đúng vs Code Sai

Code Sai
// 1. WRONG: completing a level without calling LevelStarted first
void OnVictory()
{
    // Consequence: no duration_sec on the event (no start time was recorded)
    App.Analytics.LevelCompleted(5, score: 100);
}

// 2. WRONG: sending the IAP event manually
void OnPurchaseSuccess(string id)
{
    // Dangerous: double counts revenue on the dashboard.
    // IapService already reports the purchase to every sink.
    App.Analytics.Event("purchase", extra1: id);
}

// 3. WRONG: sending an event from Update on every frame
void Update()
{
    // Fires ~60 events per second: floods the sinks and buries real data in noise
    App.Analytics.Event("player_x", (long)transform.position.x);
}
Code Đúng
// 1. RIGHT: start the level before finishing it
void OnStartGame()
{
    App.Analytics.LevelStarted(5, tag: "normal");
}

void OnVictory()
{
    // MGW sinks receive duration_sec measured since LevelStarted
    App.Analytics.LevelCompleted(5, score: 100, tag: "normal");
}

// 2. RIGHT: only deliver the content, the SDK reports the purchase
void OnPurchaseSuccess(string id)
{
    // The SDK reports the purchase to the active sinks (Firebase, GameAnalytics, AppMetrica).
    // Game code only unlocks the feature and updates the UI.
    UnlockPremiumFeature(id);
}

// 3. RIGHT: track only meaningful, user-triggered actions
public void OnUpgradeButtonClicked()
{
    App.Analytics.Click("shop_screen", "btn_upgrade_sword");
}

9. Ví dụ thực hành mẫu (C# Realistic Examples)

Ví dụ 1: Quản lý trọn vẹn luồng màn chơi (LevelFlowManager)

Script mẫu minh hoạ cách tích hợp đầy đủ chu trình: Bắt đầu màn chơi, hoàn thành các chặng con (Stages) và kết thúc màn chơi có thưởng tiền tệ ảo:

using TapBrain.Core;
using TapBrain.Core.Services;
using UnityEngine;

namespace MyGame.Gameplay
{
    public class LevelFlowManager : MonoBehaviour
    {
        private int _currentLevel = 1;
        private int _currentStage = 1;
        private int _playerCoins = 250;

        public void StartCampaignLevel(int level)
        {
            _currentLevel = level;
            _currentStage = 1;

            // 1. Track the start of the main level
            App.Analytics.LevelStarted(_currentLevel, tag: "dungeon");

            // 2. Start the first stage (the SDK fills in the current level)
            App.Analytics.StageStarted(_currentStage, tag: "dungeon");
            Debug.Log($"[MyGame] Started Level {_currentLevel}, Stage {_currentStage}");
        }

        public void PassCurrentStage()
        {
            // Complete the stage (MGW sinks get its duration_sec)
            App.Analytics.StageCompleted(_currentStage, score: 50, tag: "dungeon");

            _currentStage++;
            // Start the next stage
            App.Analytics.StageStarted(_currentStage, tag: "dungeon");
        }

        public void CompleteLevel(bool isSuccess)
        {
            if (isSuccess)
            {
                // Complete the last stage
                App.Analytics.StageCompleted(_currentStage, score: 100, tag: "dungeon");

                // Complete the main level (MGW sinks get duration_sec since LevelStarted)
                App.Analytics.LevelCompleted(_currentLevel, score: 1000, tag: "dungeon");

                // Reward virtual currency on victory
                int rewardCoins = 100;
                _playerCoins += rewardCoins;
                App.Analytics.CurrencyIncome(CurrencyType.Soft, rewardCoins, _playerCoins, place: "level_victory");
            }
            else
            {
                // Fail the current stage, then the level
                App.Analytics.StageFailed(_currentStage, score: 10, tag: "dungeon");
                App.Analytics.LevelFailed(_currentLevel, score: 150, tag: "dungeon");
            }
        }
    }
}

Ví dụ 2: Mua và nâng cấp trang bị trong Cửa hàng (ShopItemController)

Script mẫu minh hoạ quy trình người chơi mở giao diện cửa hàng, tiêu tiền ảo để mua sắm vật phẩm và gửi sự kiện nâng cấp tuỳ biến:

using TapBrain.Core;
using TapBrain.Core.Services;
using UnityEngine;

namespace MyGame.Shop
{
    public class ShopItemController : MonoBehaviour
    {
        private int _softGems = 1200;

        public void OpenShopView()
        {
            // Track opening the shop screen
            App.Analytics.Screen("shop_weapon_view");
        }

        public void BuyItem(string itemId, int cost, int targetTier)
        {
            // Track the tap on the buy button
            App.Analytics.Click("shop_weapon_view", "buy_button");

            if (_softGems < cost)
            {
                Debug.LogWarning("[MyGame] Not enough gems to purchase!");
                return;
            }

            // 1. Spend the currency and track it
            _softGems -= cost;
            App.Analytics.CurrencyOutcome(CurrencyType.Soft, cost, _softGems, place: "shop_buy");

            // 2. Track the new item
            App.Analytics.ItemReceived(itemId);

            // 3. Custom event with the four fixed slots (GameAnalytics keeps only param1)
            // param1: cost, param2: upgrade tier, extra1: item id, extra2: purchase source
            App.Analytics.Event(
                name: "item_upgrade",
                param1: cost,
                param2: targetTier,
                extra1: itemId,
                extra2: "shop_dialog"
            );

            Debug.Log($"[MyGame] Successfully purchased {itemId} tier {targetTier}");
        }
    }
}

10. Các lỗi phổ biến & Cách khắc phục

  • Lỗi mất tham số duration_sec: Xảy ra khi gọi LevelCompleted hoặc LevelFailed nhưng trước đó bỏ quên LevelStarted, hoặc khi chơi lại mà không gọi lại LevelStarted (mốc bị xoá sau lần kết thúc đầu). Hãy gọi LevelStarted cho mỗi lượt chơi.
  • Dùng LogEvent với nhiều tham số trên Provider SayKit: SayKit chỉ ghi nhận 1 số và 1 chuỗi đầu tiên từ từ điển tham số. Giải pháp: dùng App.Analytics.Event(name, p1, p2, e1, e2) để cả 4 slot đến được SayKit (lưu ý GameAnalytics chỉ nhận p1).
  • Tự log sự kiện doanh thu quảng cáo hoặc IAP: Làm trùng dữ liệu doanh thu trên các nền tảng phân tích. Hãy để SDK (MaxAdsService, IapService) tự báo.
  • Gửi sự kiện quá thường xuyên: Tránh gọi Analytics trong Update(), FixedUpdate() hoặc mỗi bước di chuyển của nhân vật: mỗi lệnh gọi phát một sự kiện tới mọi sink đang sẵn sàng, làm nhiễu dữ liệu và tốn tài nguyên. Chỉ theo dõi các mốc có ý nghĩa phân tích. Giới hạn số sự kiện/tham số của từng nền tảng do nhà cung cấp quy định, SDK không kiểm tra.

11. Checklist kiểm tra nhanh

Mọi lời gọi LevelStarted đều truyền số màn người chơi nhìn thấy (≥ 1), không bắt đầu từ 0.
Kiểm tra toàn bộ mã nguồn game đảm bảo không gọi các sự kiện có tên như ad_impression, purchase hoặc ad_revenue.
Dùng cấu trúc 4 slot cố định để cùng một lệnh gọi chạy trên MGW và SayKit. Nhớ rằng GameAnalytics chỉ nhận param1; dữ liệu quan trọng nên nằm ở param1 hoặc ở Firebase/AppMetrica.