Skip to content

Commit 7842c09

Browse files
author
BoostOps
committed
v1.1.0: dedicated /v1/purchases endpoint with shared common envelope
- Purchases now ship to POST /v1/purchases via BoostOpsPurchaseClient with retry-until-acked persistence and server-side idempotency on (project_id, store, transaction_id). - BoostOpsCommonPayload module is the single source of truth for the envelope (schema, identifiers, routing flags, consent, context). Both /v1/events and /v1/purchases serialize through it so the two endpoints cannot drift in what they collect. - IAnalyticsProvider.TrackPurchase removed from the interface; BoostOpsAnalyticsContract.TrackPurchase calls Unity Analytics and Firebase directly for third-party mirroring. - Public BoostOpsSDK.TrackPurchase signatures unchanged; Unity IAP TrackPurchase(Product) overload now flows through the new pipeline. See CHANGELOG.md for full details. Made-with: Cursor
1 parent b155e34 commit 7842c09

11 files changed

Lines changed: 1397 additions & 452 deletions

Assets/BoostOps/CHANGELOG.md

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,35 @@
22

33
All notable changes to the BoostOps Unity SDK will be documented in this file.
44

5+
## [1.1.0] - 2026-04-28
6+
7+
### Changed (BREAKING wire change)
8+
9+
- **Purchases now use the dedicated `/v1/purchases` endpoint instead of the generic event log.** This matches the industry pattern (AppsFlyer Purchase Connector / `validateAndSendInAppPurchase`, Adjust `VerifyAndTrackPurchase`, Branch `logEvent(PURCHASE, SKPaymentTransaction)`) and gives revenue events:
10+
- Idempotency on `(project_id, store, transaction_id)` — replays and reinstalls collapse cleanly.
11+
- Synchronous bronze persistence on the server — the ack means "durable."
12+
- A typed wire shape with field-level validation (store enum, ISO 4217 currency, ≤32KB receipt, sandbox flag, sub/trial flags, `original_transaction_id`).
13+
- **`/v1/events` and `/v1/purchases` now share an identical common envelope.** Schema metadata, the four-tier identifier hierarchy (`boostops_id`, `install_id`, `custom_user_id`, `session_id`, plus `install_time_ms`), routing flags (`is_unity_editor`, `is_debug_build`, `is_testflight`, `is_emulator`), the `consent` block, and the device/platform `context` block are populated by a single builder and serialized by a single emitter, so the two endpoints can never drift in what they collect.
14+
- `BoostOps-SDK` no longer emits the `boostops_purchase` event on `/v1/events`. The events client will refuse to enqueue it and log an error pointing at the new path. Only `boostops_impression`, `boostops_click`, `boostops_open`, and `boostops_install_attribution_update` flow through `/v1/events`.
15+
- `IAnalyticsProvider.TrackPurchase` removed from the interface. `BoostOpsAnalyticsContract.TrackPurchase` calls Unity Analytics and Firebase Analytics directly for third-party mirroring; their concrete `TrackPurchase` methods remain in place. `BoostOpsAnalyticsProvider.TrackPurchase` is deleted (purchases no longer go through the BoostOps event provider).
16+
17+
### Added
18+
19+
- `BoostOps.Analytics.BoostOpsPurchaseClient` — dedicated singleton that ships purchases to `POST /v1/purchases`. Per-purchase JSON files under `persistentDataPath/BoostOps/purchases/` give us crash-safe, retry-until-acked durability. Exponential backoff with jitter, capped at 10 minutes. 4xx validation errors are non-recoverable and dropped; 5xx and network errors retry.
20+
- `BoostOps.BoostOpsPurchaseInfo` — typed input for the advanced `BoostOpsAnalyticsContract.TrackPurchase(BoostOpsPurchaseInfo)` overload. Use it when you need subscription/trial flags, original transaction IDs, sandbox overrides, or stable client event IDs across retries.
21+
- `BoostOps.Analytics.BoostOpsCommonPayload` + `BoostOpsCommonPayloadBuilder` + `BoostOpsCommonPayloadJson` — single source of truth for the shared envelope that both endpoints carry. The events serializer's `consent` and `context` rendering now delegates to this module so adding a new envelope field is a one-edit change instead of a two-pipeline change.
22+
- `BoostOps.Analytics.BoostOpsPurchaseRequest` — internal wire-shape mirror of the server's `PurchaseRequest`. Holds the shared `Common` envelope alongside the purchase-specific fields, so `bronze.raw_purchase.raw_payload` ends up structurally identical to `bronze.raw_event.raw_payload` (modulo the purchase-specific tail).
23+
24+
### Removed
25+
26+
- `BoostOpsEventBuilder.CreatePurchaseEvent` and the `EventBuilder.Purchase` factory — dead code now that purchases bypass the event log.
27+
- Purchase-specific install_id recovery branch in `BoostOpsAnalyticsClient.ValidateEventData` — the new client carries the identifier in its own request payload.
28+
29+
### Compatibility
30+
31+
- Public `BoostOpsSDK.TrackPurchase(...)` and `BoostOpsAnalyticsContract.TrackPurchase(...)` signatures are unchanged. Existing app code does not need to be updated.
32+
- The Unity IAP `TrackPurchase(Product)` overload continues to work and now delivers through the new pipeline.
33+
534
## [1.0.5] - 2026-04-21
635

736
- Fix UPM package name to match Unity Asset Store listing (`io.boostops.attribution-sdk`)

Assets/BoostOps/Scripts/Analytics/BoostOpsAnalyticsClient.cs

Lines changed: 19 additions & 155 deletions
Original file line numberDiff line numberDiff line change
@@ -933,45 +933,30 @@ private bool ValidateEventData(AnalyticsEventData eventData)
933933

934934
// Note: project_key is sent ONLY in HTTP header (BoostOps-Project-Key) for security, not in payload
935935

936-
var validEventTypes = new[] {
936+
// Purchase events are NOT accepted on the generic /v1/events path
937+
// as of SDK 1.1.0. Use BoostOpsAnalyticsContract.TrackPurchase, which
938+
// routes to the dedicated /v1/purchases endpoint via BoostOpsPurchaseClient.
939+
var validEventTypes = new[] {
937940
BoostOpsAnalyticsContract.EventNames.IMPRESSION,
938941
BoostOpsAnalyticsContract.EventNames.CLICK,
939942
BoostOpsAnalyticsContract.EventNames.APP_OPEN, // Includes installs (first_open=true)
940-
BoostOpsAnalyticsContract.EventNames.PURCHASE,
941943
BoostOpsAnalyticsContract.EventNames.INSTALL_ATTRIBUTION_UPDATE
942944
};
943945
if (Array.IndexOf(validEventTypes, eventData.event_type) == -1)
944946
{
945-
BoostOpsLogger.LogError("Analytics", $"Invalid event type: {eventData.event_type}");
946-
return false;
947-
}
948-
949-
// CRITICAL: Verify install_id for purchase events (essential for revenue attribution)
950-
// This is especially important on Android where timing issues can cause missing install_id
951-
if (eventData.event_type == BoostOpsAnalyticsContract.EventNames.PURCHASE)
952-
{
953-
if (string.IsNullOrEmpty(eventData.install_id))
947+
if (eventData.event_type == BoostOpsAnalyticsContract.EventNames.PURCHASE)
954948
{
955-
BoostOpsLogger.LogError("Analytics", "❌ CRITICAL: Purchase event is missing install_id! Attempting recovery...");
956-
// Attempt to recover by fetching install_id
957-
eventData.install_id = BoostOps.Analytics.BoostOpsIdentifierManager.GetInstallId();
958-
if (string.IsNullOrEmpty(eventData.install_id))
959-
{
960-
BoostOpsLogger.LogError("Analytics", "❌ FATAL: Could not recover install_id for purchase event! Event WILL BE BLOCKED.");
961-
// Block the event - sending purchase without install_id is worse than not sending
962-
return false;
963-
}
964-
else
965-
{
966-
BoostOpsLogger.LogInfo("Analytics", $"✅ Recovered install_id for purchase event: {eventData.install_id}");
967-
}
949+
BoostOpsLogger.LogError("Analytics",
950+
"❌ boostops_purchase is no longer accepted on /v1/events. " +
951+
"Use BoostOpsAnalyticsContract.TrackPurchase, which delivers via the dedicated /v1/purchases endpoint.");
968952
}
969953
else
970954
{
971-
BoostOpsLogger.LogDebug("Analytics", $"✅ Purchase event has install_id: {eventData.install_id}");
955+
BoostOpsLogger.LogError("Analytics", $"Invalid event type: {eventData.event_type}");
972956
}
957+
return false;
973958
}
974-
959+
975960
return true;
976961
}
977962

@@ -1095,144 +1080,23 @@ private string CreateCleanJsonString(AnalyticsEventData eventData)
10951080
}
10961081

10971082
/// <summary>
1098-
/// Create clean JSON for context data
1083+
/// Build the inner JSON of the <c>context</c> block. Delegates to the
1084+
/// shared serializer so the events endpoint and the dedicated
1085+
/// purchases endpoint emit identical context shapes.
10991086
/// </summary>
11001087
private string CreateCleanContextJson(EventContext context)
11011088
{
1102-
var contextParts = new List<string>();
1103-
1104-
if (!string.IsNullOrEmpty(context.source)) contextParts.Add($"\"source\":\"{context.source}\"");
1105-
if (!string.IsNullOrEmpty(context.platform)) contextParts.Add($"\"os\":\"{context.platform}\"");
1106-
if (!string.IsNullOrEmpty(context.os_version)) contextParts.Add($"\"os_version\":\"{context.os_version}\"");
1107-
if (!string.IsNullOrEmpty(context.app_version)) contextParts.Add($"\"app_version\":\"{context.app_version}\"");
1108-
if (!string.IsNullOrEmpty(context.app_identifier)) contextParts.Add($"\"app_identifier\":\"{context.app_identifier}\"");
1109-
if (!string.IsNullOrEmpty(context.sdk_version)) contextParts.Add($"\"sdk_version\":\"{context.sdk_version}\"");
1110-
if (!string.IsNullOrEmpty(context.store)) contextParts.Add($"\"store\":\"{context.store}\"");
1111-
if (!string.IsNullOrEmpty(context.store_id)) contextParts.Add($"\"store_id\":\"{context.store_id}\"");
1112-
if (!string.IsNullOrEmpty(context.device_model)) contextParts.Add($"\"device_model\":\"{context.device_model}\"");
1113-
if (!string.IsNullOrEmpty(context.device_brand)) contextParts.Add($"\"device_brand\":\"{context.device_brand}\"");
1114-
if (!string.IsNullOrEmpty(context.country)) contextParts.Add($"\"country\":\"{context.country}\"");
1115-
if (!string.IsNullOrEmpty(context.storefront_country)) contextParts.Add($"\"storefront_country\":\"{context.storefront_country}\"");
1116-
if (!string.IsNullOrEmpty(context.region)) contextParts.Add($"\"region\":\"{context.region}\"");
1117-
if (!string.IsNullOrEmpty(context.city)) contextParts.Add($"\"city\":\"{context.city}\"");
1118-
if (context.timezone_offset_minutes.HasValue) contextParts.Add($"\"timezone_offset_minutes\":{context.timezone_offset_minutes.Value}");
1119-
if (!string.IsNullOrEmpty(context.locale)) contextParts.Add($"\"locale\":\"{context.locale}\"");
1120-
if (!string.IsNullOrEmpty(context.language)) contextParts.Add($"\"language\":\"{context.language}\"");
1121-
if (!string.IsNullOrEmpty(context.carrier)) contextParts.Add($"\"carrier\":\"{context.carrier}\"");
1122-
if (!string.IsNullOrEmpty(context.connection_type)) contextParts.Add($"\"connection_type\":\"{context.connection_type}\"");
1123-
if (!string.IsNullOrEmpty(context.ip_address)) contextParts.Add($"\"ip_address\":\"{context.ip_address}\"");
1124-
// Note: timestamp is at top-level (milliseconds precision) - not duplicated in context
1125-
1126-
// Device identifiers (cross-app correlation)
1127-
// Note: install_id and custom_user_id moved to top-level (schema v6)
1128-
if (!string.IsNullOrEmpty(context.app_account_token)) contextParts.Add($"\"app_account_token\":\"{context.app_account_token}\"");
1129-
if (!string.IsNullOrEmpty(context.idfv)) contextParts.Add($"\"idfv\":\"{context.idfv}\"");
1130-
if (!string.IsNullOrEmpty(context.idfa)) contextParts.Add($"\"idfa\":\"{context.idfa}\"");
1131-
if (!string.IsNullOrEmpty(context.asid_sha256)) contextParts.Add($"\"asid_sha256\":\"{context.asid_sha256}\"");
1132-
if (!string.IsNullOrEmpty(context.gaid)) contextParts.Add($"\"gaid\":\"{context.gaid}\"");
1133-
if (!string.IsNullOrEmpty(context.firebase_app_id)) contextParts.Add($"\"firebase_app_id\":\"{context.firebase_app_id}\"");
1134-
if (!string.IsNullOrEmpty(context.windows_device_id)) contextParts.Add($"\"windows_device_id\":\"{context.windows_device_id}\"");
1135-
if (!string.IsNullOrEmpty(context.windows_machine_guid)) contextParts.Add($"\"windows_machine_guid\":\"{context.windows_machine_guid}\"");
1136-
if (!string.IsNullOrEmpty(context.msaid)) contextParts.Add($"\"msaid\":\"{context.msaid}\"");
1137-
1138-
// Environment detection
1139-
if (!string.IsNullOrEmpty(context.environment)) contextParts.Add($"\"environment\":\"{context.environment}\"");
1140-
if (!string.IsNullOrEmpty(context.installer_source)) contextParts.Add($"\"installer_source\":\"{context.installer_source}\"");
1141-
1142-
// Privacy consent data is now handled at the top-level event data
1143-
1144-
return string.Join(",", contextParts);
1089+
return BoostOpsCommonPayloadJson.BuildContextBody(context);
11451090
}
11461091

11471092
/// <summary>
1148-
/// Create clean JSON for consent data (privacy compliance)
1093+
/// Build the inner JSON of the <c>consent</c> block. Delegates to the
1094+
/// shared serializer so the events endpoint and the dedicated
1095+
/// purchases endpoint emit identical consent shapes.
11491096
/// </summary>
11501097
private string CreateCleanConsentJson(ConsentData consent)
11511098
{
1152-
var consentParts = new List<string>();
1153-
1154-
// Framework identification (backward compatible + enhanced)
1155-
if (!string.IsNullOrEmpty(consent.framework))
1156-
consentParts.Add($"\"framework\":\"{consent.framework}\"");
1157-
if (consent.gdpr_consent_required.HasValue)
1158-
consentParts.Add($"\"gdpr_required\":{consent.gdpr_consent_required.Value.ToString().ToLower()}");
1159-
if (consent.ccpa_consent_required.HasValue)
1160-
consentParts.Add($"\"ccpa_required\":{consent.ccpa_consent_required.Value.ToString().ToLower()}");
1161-
1162-
// Consent timestamps and metadata (enhanced fields)
1163-
if (consent.consent_timestamp.HasValue)
1164-
consentParts.Add($"\"timestamp\":{consent.consent_timestamp.Value}");
1165-
if (!string.IsNullOrEmpty(consent.consent_version))
1166-
consentParts.Add($"\"version\":\"{consent.consent_version}\"");
1167-
if (!string.IsNullOrEmpty(consent.consent_language))
1168-
consentParts.Add($"\"language\":\"{consent.consent_language}\"");
1169-
if (!string.IsNullOrEmpty(consent.consent_method))
1170-
consentParts.Add($"\"method\":\"{consent.consent_method}\"");
1171-
if (!string.IsNullOrEmpty(consent.consent_source))
1172-
consentParts.Add($"\"source\":\"{consent.consent_source}\"");
1173-
if (!string.IsNullOrEmpty(consent.legal_basis))
1174-
consentParts.Add($"\"legal_basis\":\"{consent.legal_basis}\"");
1175-
1176-
// Legacy TCF/consent string support
1177-
if (!string.IsNullOrEmpty(consent.consent_string))
1178-
consentParts.Add($"\"consent_string\":\"{consent.consent_string}\"");
1179-
1180-
// GDPR-specific consent (structured)
1181-
if (consent.gdpr != null)
1182-
{
1183-
var gdprParts = new List<string>();
1184-
if (consent.gdpr.applies.HasValue)
1185-
gdprParts.Add($"\"applies\":{consent.gdpr.applies.Value.ToString().ToLower()}");
1186-
if (consent.gdpr.consent_given.HasValue)
1187-
gdprParts.Add($"\"consent_given\":{consent.gdpr.consent_given.Value.ToString().ToLower()}");
1188-
if (consent.gdpr.analytics.HasValue)
1189-
gdprParts.Add($"\"analytics\":{consent.gdpr.analytics.Value.ToString().ToLower()}");
1190-
if (consent.gdpr.advertising.HasValue)
1191-
gdprParts.Add($"\"advertising\":{consent.gdpr.advertising.Value.ToString().ToLower()}");
1192-
if (consent.gdpr.measurement.HasValue)
1193-
gdprParts.Add($"\"measurement\":{consent.gdpr.measurement.Value.ToString().ToLower()}");
1194-
if (!string.IsNullOrEmpty(consent.gdpr.legal_basis))
1195-
gdprParts.Add($"\"legal_basis\":\"{consent.gdpr.legal_basis}\"");
1196-
1197-
if (gdprParts.Count > 0)
1198-
consentParts.Add($"\"gdpr\":{{{string.Join(",", gdprParts)}}}");
1199-
}
1200-
1201-
// ATT (iOS App Tracking Transparency)
1202-
if (consent.att != null)
1203-
{
1204-
var attParts = new List<string>();
1205-
if (!string.IsNullOrEmpty(consent.att.status))
1206-
attParts.Add($"\"status\":\"{consent.att.status}\"");
1207-
if (consent.att.authorized_time.HasValue)
1208-
attParts.Add($"\"authorized_time\":{consent.att.authorized_time.Value}");
1209-
if (consent.att.idfa_available.HasValue)
1210-
attParts.Add($"\"idfa_available\":{consent.att.idfa_available.Value.ToString().ToLower()}");
1211-
1212-
if (attParts.Count > 0)
1213-
consentParts.Add($"\"att\":{{{string.Join(",", attParts)}}}");
1214-
}
1215-
1216-
// Android privacy settings
1217-
if (consent.android != null)
1218-
{
1219-
var androidParts = new List<string>();
1220-
if (consent.android.advertising_id.HasValue)
1221-
androidParts.Add($"\"advertising_id\":{consent.android.advertising_id.Value.ToString().ToLower()}");
1222-
if (consent.android.limited_ad_tracking.HasValue)
1223-
androidParts.Add($"\"limited_ad_tracking\":{consent.android.limited_ad_tracking.Value.ToString().ToLower()}");
1224-
1225-
if (androidParts.Count > 0)
1226-
consentParts.Add($"\"android\":{{{string.Join(",", androidParts)}}}");
1227-
}
1228-
1229-
// Withdrawal tracking (enhanced fields)
1230-
if (consent.withdrawal_timestamp.HasValue)
1231-
consentParts.Add($"\"withdrawal_timestamp\":{consent.withdrawal_timestamp.Value}");
1232-
if (!string.IsNullOrEmpty(consent.withdrawal_method))
1233-
consentParts.Add($"\"withdrawal_method\":\"{consent.withdrawal_method}\"");
1234-
1235-
return string.Join(",", consentParts);
1099+
return BoostOpsCommonPayloadJson.BuildConsentBody(consent);
12361100
}
12371101

12381102
/// <summary>

0 commit comments

Comments
 (0)