From a235620fb1cc0e46bf5066fe6d3d0199a0c5a9b1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Turba=C5=84ski?= Date: Wed, 8 Jul 2026 13:58:22 +0200 Subject: [PATCH 01/18] feat: collect idfv/aaid/idfa and customer_user_id for identity linking --- examples/expo-bare/app.json | 15 ++- examples/expo-bare/package.json | 2 + examples/expo-bare/src/Screen.tsx | 26 +++- packages/react-native-detour/package.json | 7 ++ .../react-native-detour/src/DetourContext.tsx | 32 ++++- .../src/analytics/analytics.ts | 2 + .../src/analytics/api/events.ts | 12 ++ .../src/analytics/api/retention.ts | 12 ++ .../src/analytics/utils/devicePersistence.ts | 14 +-- .../src/analytics/utils/userIdentity.ts | 12 ++ .../src/links/api/getDeferredLink.ts | 13 +- .../src/links/hooks/useDetour.ts | 1 + .../src/links/types/index.ts | 9 ++ .../src/links/utils/deviceIdentifiers.ts | 111 ++++++++++++++++++ .../src/links/utils/fingerprint.ts | 42 ++++++- pnpm-lock.yaml | 55 +++++++++ 16 files changed, 343 insertions(+), 22 deletions(-) create mode 100644 packages/react-native-detour/src/analytics/utils/userIdentity.ts create mode 100644 packages/react-native-detour/src/links/utils/deviceIdentifiers.ts diff --git a/examples/expo-bare/app.json b/examples/expo-bare/app.json index 9149982..743934b 100644 --- a/examples/expo-bare/app.json +++ b/examples/expo-bare/app.json @@ -11,7 +11,12 @@ "bundleIdentifier": "detourreactnative.expobare", "supportsTablet": true, "icon": "./assets/detour-logo.png", - "associatedDomains": ["applinks:.godetour.link"] + "associatedDomains": [ + "applinks:.godetour.link" + ], + "infoPlist": { + "NSUserTrackingUsageDescription": "This identifier will be used to deliver personalized ads and measure their effectiveness." + } }, "android": { "package": "detourreactnative.expobare", @@ -31,7 +36,10 @@ "pathPrefix": "/" } ], - "category": ["BROWSABLE", "DEFAULT"] + "category": [ + "BROWSABLE", + "DEFAULT" + ] } ] }, @@ -47,7 +55,8 @@ "imageWidth": 120 } } - ] + ], + "expo-tracking-transparency" ], "experiments": { "reactCompiler": true diff --git a/examples/expo-bare/package.json b/examples/expo-bare/package.json index 084c3c5..cf19184 100644 --- a/examples/expo-bare/package.json +++ b/examples/expo-bare/package.json @@ -17,9 +17,11 @@ "expo-application": "*", "expo-clipboard": "*", "expo-constants": "~55.0.11", + "expo-crypto": "~14.1.5", "expo-dev-client": "~55.0.22", "expo-device": "~55.0.12", "expo-localization": "*", + "expo-tracking-transparency": "~5.2.4", "react": "19.2.0", "react-dom": "19.2.0", "react-native": "0.83.4", diff --git a/examples/expo-bare/src/Screen.tsx b/examples/expo-bare/src/Screen.tsx index db6dbba..67f2ddf 100644 --- a/examples/expo-bare/src/Screen.tsx +++ b/examples/expo-bare/src/Screen.tsx @@ -1,8 +1,12 @@ -import { Image, ScrollView, Text, View } from "react-native"; +import { Image, Pressable, ScrollView, Text, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; -import { useDetourContext } from "@swmansion/react-native-detour"; +import { + DetourAnalytics, + DetourEventNames, + useDetourContext, +} from "@swmansion/react-native-detour"; import { colors, styles } from "./styles"; @@ -76,6 +80,24 @@ export const Screen = () => { {link?.params && {JSON.stringify(link.params, null, 2)}} + + + + Test Actions + + Fire these on demand, then inspect the request body in the RN DevTools Network tab to + confirm idfv/aaid/idfa/install_id/customer_user_id are attached. + + + DetourAnalytics.setUserId("test-user-123")}> + Set test customer_user_id + + + DetourAnalytics.logEvent(DetourEventNames.Purchase, { test: true })} + > + Log test event (purchase) + diff --git a/packages/react-native-detour/package.json b/packages/react-native-detour/package.json index 71b346a..617a42b 100644 --- a/packages/react-native-detour/package.json +++ b/packages/react-native-detour/package.json @@ -76,8 +76,10 @@ "expo-application": "*", "expo-clipboard": "*", "expo-constants": "~55.0.11", + "expo-crypto": "^57.0.0", "expo-device": "~55.0.12", "expo-localization": "*", + "expo-tracking-transparency": "^57.0.0", "jest": "^29.7.0", "react": "19.2.0", "react-native": "0.83.4", @@ -90,8 +92,10 @@ "expo-application": ">=6.1.5", "expo-clipboard": ">=6.0.0", "expo-constants": ">=17.1.7", + "expo-crypto": ">=14.0.0", "expo-device": ">=7.0.0", "expo-localization": ">=15.0.0", + "expo-tracking-transparency": ">=5.0.0", "react": ">=18", "react-native": ">=0.72", "react-native-device-info": ">=10.0.0" @@ -103,6 +107,9 @@ "expo-device": { "optional": true }, + "expo-tracking-transparency": { + "optional": true + }, "react-native-device-info": { "optional": true } diff --git a/packages/react-native-detour/src/DetourContext.tsx b/packages/react-native-detour/src/DetourContext.tsx index 97fe5a3..84e7c1f 100644 --- a/packages/react-native-detour/src/DetourContext.tsx +++ b/packages/react-native-detour/src/DetourContext.tsx @@ -8,8 +8,13 @@ import { useAppOpenRetention } from "./analytics/hooks/useAppOpenRetention"; import type { DetourEvent, DetourEventNames } from "./analytics/types"; import { analyticsEmitter } from "./analytics/utils/analyticsEmitter"; import { prepareDeviceIdForApi } from "./analytics/utils/devicePersistence"; +import { getUserId } from "./analytics/utils/userIdentity"; import { useDetour } from "./links/hooks/useDetour"; import type { Config, DetourContextType } from "./links/types"; +import { + collectDeviceIdentitySignals, + requestTrackingPermission, +} from "./links/utils/deviceIdentifiers"; import { resolveStorage } from "./links/utils/storage"; type Props = PropsWithChildren & { config: Config }; @@ -32,10 +37,16 @@ const DetourProviderNative = ({ config, children }: Props) => { shouldUseClipboard = true, storage: userStorage, linkProcessingMode = "all", + shouldRequestTrackingPermission = false, } = config; const storage = resolveStorage(userStorage); + useEffect(() => { + if (!shouldRequestTrackingPermission) return; + requestTrackingPermission(); + }, [shouldRequestTrackingPermission]); + useEffect(() => { activeProviderCount++; @@ -52,10 +63,23 @@ const DetourProviderNative = ({ config, children }: Props) => { } try { - const deviceId = await prepareDeviceIdForApi(storage); + const [deviceId, { idfv, aaid, idfa }] = await Promise.all([ + prepareDeviceIdForApi(storage), + collectDeviceIdentitySignals(), + ]); + const customerUserId = getUserId(); if (isRetention) { - sendRetentionEvent({ apiKey, appID, eventName, deviceId }); + sendRetentionEvent({ + apiKey, + appID, + eventName, + deviceId, + idfv, + aaid, + idfa, + customerUserId, + }); } else { const event: DetourEvent = { eventName: eventName as DetourEventNames, @@ -66,6 +90,10 @@ const DetourProviderNative = ({ config, children }: Props) => { appID, event, deviceId, + idfv, + aaid, + idfa, + customerUserId, }); } } catch (error) { diff --git a/packages/react-native-detour/src/analytics/analytics.ts b/packages/react-native-detour/src/analytics/analytics.ts index 22104ff..15c767e 100644 --- a/packages/react-native-detour/src/analytics/analytics.ts +++ b/packages/react-native-detour/src/analytics/analytics.ts @@ -1,5 +1,6 @@ import { DetourEventNames } from "./types"; import { analyticsEmitter } from "./utils/analyticsEmitter"; +import { setUserId } from "./utils/userIdentity"; export const logEvent = (eventName: DetourEventNames | `${DetourEventNames}`, data?: any) => { analyticsEmitter.emit({ eventName, data }); @@ -12,4 +13,5 @@ export const logRetention = (retentionEventName: string) => { export const DetourAnalytics = { logEvent, logRetention, + setUserId, }; diff --git a/packages/react-native-detour/src/analytics/api/events.ts b/packages/react-native-detour/src/analytics/api/events.ts index 854df78..eef5254 100644 --- a/packages/react-native-detour/src/analytics/api/events.ts +++ b/packages/react-native-detour/src/analytics/api/events.ts @@ -10,11 +10,19 @@ export const sendEvent = async ({ appID, deviceId, event, + idfv, + aaid, + idfa, + customerUserId, }: { apiKey: string; appID: string; event: DetourEvent; deviceId: string; + idfv?: string; + aaid?: string; + idfa?: string; + customerUserId?: string; }) => { try { const response = await fetch(EVENT_API_URL, { @@ -31,6 +39,10 @@ export const sendEvent = async ({ timestamp: new Date().toISOString(), platform: Platform.OS, device_id: deviceId, + idfv, + aaid, + idfa, + customer_user_id: customerUserId, }), }); diff --git a/packages/react-native-detour/src/analytics/api/retention.ts b/packages/react-native-detour/src/analytics/api/retention.ts index 927b81c..90340df 100644 --- a/packages/react-native-detour/src/analytics/api/retention.ts +++ b/packages/react-native-detour/src/analytics/api/retention.ts @@ -9,11 +9,19 @@ export const sendRetentionEvent = async ({ appID, deviceId, eventName, + idfv, + aaid, + idfa, + customerUserId, }: { apiKey: string; appID: string; eventName: string; deviceId: string; + idfv?: string; + aaid?: string; + idfa?: string; + customerUserId?: string; }) => { try { const response = await fetch(RETENTION_API_URL, { @@ -29,6 +37,10 @@ export const sendRetentionEvent = async ({ timestamp: new Date().toISOString(), platform: Platform.OS, device_id: deviceId, + idfv, + aaid, + idfa, + customer_user_id: customerUserId, }), }); diff --git a/packages/react-native-detour/src/analytics/utils/devicePersistence.ts b/packages/react-native-detour/src/analytics/utils/devicePersistence.ts index a140781..92be51d 100644 --- a/packages/react-native-detour/src/analytics/utils/devicePersistence.ts +++ b/packages/react-native-detour/src/analytics/utils/devicePersistence.ts @@ -1,14 +1,12 @@ -/* eslint-disable no-bitwise */ +import * as Crypto from "expo-crypto"; + import type { DetourStorage } from "../../links/types"; import { StorageKeys } from "../../links/utils/storage"; -const generateUUID = () => { - return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, function (c) { - const r = (Math.random() * 16) | 0; - const v = c === "x" ? r : (r & 0x3) | 0x8; - return v.toString(16); - }); -}; +// Math.random() is not cryptographically secure and risks collisions across +// installs at scale — this is the install_id anchor that ties app_installs, +// events and clicks together, so it needs a real CSPRNG. +const generateUUID = () => Crypto.randomUUID(); const saveDeviceId = async (storage: DetourStorage, id: string) => { await storage.setItem(StorageKeys.DEVICE_ID_KEY, id); diff --git a/packages/react-native-detour/src/analytics/utils/userIdentity.ts b/packages/react-native-detour/src/analytics/utils/userIdentity.ts new file mode 100644 index 0000000..bbafff7 --- /dev/null +++ b/packages/react-native-detour/src/analytics/utils/userIdentity.ts @@ -0,0 +1,12 @@ +// In-memory only, by design: the host app is the source of truth for its own +// login state and is expected to call setUserId() again on every cold start +// once auth is known (same convention as AppsFlyer's setCustomerUserId / +// Branch's setIdentity). Persisting it ourselves risks a stale customer_user_id +// surviving a logout the host app forgot to clear. +let customerUserId: string | undefined; + +export const setUserId = (id: string | null): void => { + customerUserId = id ?? undefined; +}; + +export const getUserId = (): string | undefined => customerUserId; diff --git a/packages/react-native-detour/src/links/api/getDeferredLink.ts b/packages/react-native-detour/src/links/api/getDeferredLink.ts index 88a832f..401e69d 100644 --- a/packages/react-native-detour/src/links/api/getDeferredLink.ts +++ b/packages/react-native-detour/src/links/api/getDeferredLink.ts @@ -1,7 +1,7 @@ import * as Application from "expo-application"; import { SDK_HEADER_VALUE } from "../../version"; -import type { RequiredConfig } from "../types"; +import type { DetourStorage, RequiredConfig } from "../types"; import { type DeterministicFingerprint, type ProbabilisticFingerprint, @@ -38,7 +38,10 @@ export const getDeferredLink = async ({ apiKey: API_KEY, appID, shouldUseClipboard, -}: Pick) => { + storage, +}: Pick & { + storage: DetourStorage; +}) => { let referrer: string | null = null; try { referrer = await Application.getInstallReferrerAsync(); @@ -52,13 +55,15 @@ export const getDeferredLink = async ({ let response; if (referrerClickId?.length) { + const deterministicFingerprint = await getDeterministicFingerprint(referrerClickId, storage); + response = await sendFingerprint({ API_KEY, appID, - requestBody: getDeterministicFingerprint(referrerClickId), + requestBody: deterministicFingerprint, }); } else { - const probabilisticFingerprint = await getProbabilisticFingerprint(shouldUseClipboard); + const probabilisticFingerprint = await getProbabilisticFingerprint(shouldUseClipboard, storage); response = await sendFingerprint({ API_KEY, diff --git a/packages/react-native-detour/src/links/hooks/useDetour.ts b/packages/react-native-detour/src/links/hooks/useDetour.ts index 0c773bf..822519c 100644 --- a/packages/react-native-detour/src/links/hooks/useDetour.ts +++ b/packages/react-native-detour/src/links/hooks/useDetour.ts @@ -225,6 +225,7 @@ export const useDetour = ({ apiKey, appID, shouldUseClipboard, + storage, }); if (apiLink) { diff --git a/packages/react-native-detour/src/links/types/index.ts b/packages/react-native-detour/src/links/types/index.ts index f254ad0..450901b 100644 --- a/packages/react-native-detour/src/links/types/index.ts +++ b/packages/react-native-detour/src/links/types/index.ts @@ -11,6 +11,15 @@ export type Config = { * (recommended when Expo Router native-intent handler already resolves runtime/initial links) */ linkProcessingMode?: LinkProcessingMode; + /** + * If `true`, Detour triggers the native App Tracking Transparency prompt on + * iOS (via `expo-tracking-transparency`) shortly after the provider mounts, + * so it can read the IDFA. No-op on Android/web. Default: `false` — the end + * user's consent belongs to the host app, so this stays opt-in and the host + * app remains free to request permission itself at a better-timed moment + * (e.g. after an explanatory screen). + */ + shouldRequestTrackingPermission?: boolean; }; export type LinkProcessingMode = "all" | "web-only" | "deferred-only"; diff --git a/packages/react-native-detour/src/links/utils/deviceIdentifiers.ts b/packages/react-native-detour/src/links/utils/deviceIdentifiers.ts new file mode 100644 index 0000000..c3e5a08 --- /dev/null +++ b/packages/react-native-detour/src/links/utils/deviceIdentifiers.ts @@ -0,0 +1,111 @@ +import { Platform } from "react-native"; + +import * as Application from "expo-application"; + +// Android AAID / iOS IDFA opt-out sentinel — never send as-is, it collides +// every opted-out device into the same fake "identifier". +const AD_ID_OPT_OUT = "00000000-0000-0000-0000-000000000000"; + +type TrackingTransparencyModule = { + getAdvertisingId?: () => Promise; + requestTrackingPermissionsAsync?: () => Promise<{ granted: boolean }>; +}; + +// Metro needs string literal in require() during bundle time (same constraint as deviceInfo.ts) +const trackingTransparency = (() => { + try { + return require("expo-tracking-transparency") as TrackingTransparencyModule; + } catch { + return null; + } +})(); + +export const getIdfv = async (): Promise => { + if (Platform.OS !== "ios") return undefined; + try { + const idfv = await Application.getIosIdForVendorAsync(); + return idfv ?? undefined; + } catch { + return undefined; + } +}; + +const getRawAdvertisingId = async (): Promise => { + if (!trackingTransparency?.getAdvertisingId) return undefined; + try { + const id = await trackingTransparency.getAdvertisingId(); + if (!id || id === AD_ID_OPT_OUT) return undefined; + return id; + } catch { + return undefined; + } +}; + +// Android advertising ID — feeds the backend's deterministic device_uuid. +export const getAaid = async (): Promise => { + if (Platform.OS !== "android") return undefined; + return getRawAdvertisingId(); +}; + +// iOS advertising ID — ad-attribution signal only, does not feed device_uuid (IDFV does). +export const getIdfa = async (): Promise => { + if (Platform.OS !== "ios") return undefined; + return getRawAdvertisingId(); +}; + +export type DeviceIdentitySignals = { + idfv?: string; + aaid?: string; + idfa?: string; +}; + +let cachedSignals: DeviceIdentitySignals | null = null; +let pendingSignalsPromise: Promise | null = null; + +// These identifiers are static for the lifetime of the app session, so we +// resolve them once and reuse the result instead of re-hitting native APIs +// on every fingerprint/event send. +export const collectDeviceIdentitySignals = async (): Promise => { + if (cachedSignals) { + return cachedSignals; + } + + if (pendingSignalsPromise) { + return pendingSignalsPromise; + } + + pendingSignalsPromise = (async () => { + const [idfv, aaid, idfa] = await Promise.all([getIdfv(), getAaid(), getIdfa()]); + const signals = { idfv, aaid, idfa }; + cachedSignals = signals; + return signals; + })(); + + try { + return await pendingSignalsPromise; + } finally { + pendingSignalsPromise = null; + } +}; + +// The ATT dialog can resolve well after our first fingerprint/event read the +// (still-empty) advertising ID — drop the cache so the next read reflects the +// user's actual choice instead of being stuck with the pre-permission value. +const resetDeviceIdentitySignalsCache = (): void => { + cachedSignals = null; + pendingSignalsPromise = null; +}; + +// Host-app-opt-in helper: only called when `shouldRequestTrackingPermission` +// is set on the Detour config. On Android/web this is a no-op — the OS +// doesn't gate the advertising ID behind a runtime prompt there. +export const requestTrackingPermission = async (): Promise => { + if (Platform.OS !== "ios" || !trackingTransparency?.requestTrackingPermissionsAsync) return; + try { + await trackingTransparency.requestTrackingPermissionsAsync(); + } catch { + // Best-effort — a failed/denied request just leaves idfa undefined downstream. + } finally { + resetDeviceIdentitySignalsCache(); + } +}; diff --git a/packages/react-native-detour/src/links/utils/fingerprint.ts b/packages/react-native-detour/src/links/utils/fingerprint.ts index afa5e06..bcf1d5a 100644 --- a/packages/react-native-detour/src/links/utils/fingerprint.ts +++ b/packages/react-native-detour/src/links/utils/fingerprint.ts @@ -4,9 +4,24 @@ import * as Clipboard from "expo-clipboard"; import Constants from "expo-constants"; import * as Localization from "expo-localization"; +import { prepareDeviceIdForApi } from "../../analytics/utils/devicePersistence"; +import { getUserId } from "../../analytics/utils/userIdentity"; +import type { DetourStorage } from "../types"; +import { collectDeviceIdentitySignals } from "./deviceIdentifiers"; import { getDeviceInfo } from "./deviceInfo"; -export type ProbabilisticFingerprint = { +// Identity signals shared by both fingerprint variants — this is what lets +// match-link recognize a device via the same identity graph keys used by +// events, instead of only ever seeing a fresh "install". +export type DeviceIdentityFields = { + install_id: string; + idfv?: string; + aaid?: string; + idfa?: string; + customer_user_id?: string; +}; + +export type ProbabilisticFingerprint = DeviceIdentityFields & { platform: string; model: string; manufacturer: string; @@ -22,18 +37,38 @@ export type ProbabilisticFingerprint = { }; // used when install referrer on android is available -export type DeterministicFingerprint = { +export type DeterministicFingerprint = DeviceIdentityFields & { clickId: string; }; -export const getDeterministicFingerprint = (clickId: string): DeterministicFingerprint => { +const collectIdentityFields = async (storage: DetourStorage): Promise => { + const [installId, { idfv, aaid, idfa }] = await Promise.all([ + prepareDeviceIdForApi(storage), + collectDeviceIdentitySignals(), + ]); + + return { + install_id: installId, + idfv, + aaid, + idfa, + customer_user_id: getUserId(), + }; +}; + +export const getDeterministicFingerprint = async ( + clickId: string, + storage: DetourStorage, +): Promise => { return { clickId, + ...(await collectIdentityFields(storage)), }; }; export const getProbabilisticFingerprint = async ( shouldUseClipboard: boolean, + storage: DetourStorage, ): Promise => { const { width, height } = Dimensions.get("screen"); const locales = Localization.getLocales(); @@ -49,6 +84,7 @@ export const getProbabilisticFingerprint = async ( } return { + ...(await collectIdentityFields(storage)), platform: Platform.OS, model, manufacturer, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 62376e8..f5125a4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -94,6 +94,9 @@ importers: expo-constants: specifier: ~55.0.11 version: 55.0.11(expo@55.0.11)(react-native@0.83.4(@babel/core@7.29.0)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@react-native/metro-config@0.83.4(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(typescript@5.9.3) + expo-crypto: + specifier: ~14.1.5 + version: 14.1.5(expo@55.0.11) expo-dev-client: specifier: ~55.0.22 version: 55.0.22(expo@55.0.11)(typescript@5.9.3) @@ -103,6 +106,9 @@ importers: expo-localization: specifier: '*' version: 16.1.6(expo@55.0.11)(react@19.2.0) + expo-tracking-transparency: + specifier: ~5.2.4 + version: 5.2.4(expo@55.0.11)(react-native@0.83.4(@babel/core@7.29.0)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@react-native/metro-config@0.83.4(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0)) react: specifier: 19.2.0 version: 19.2.0 @@ -515,12 +521,18 @@ importers: expo-constants: specifier: ~55.0.11 version: 55.0.11(expo@55.0.11)(react-native@0.83.4(@babel/core@7.29.0)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@react-native/metro-config@0.83.4(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(typescript@5.9.3) + expo-crypto: + specifier: ^57.0.0 + version: 57.0.0(expo@55.0.11) expo-device: specifier: ~55.0.12 version: 55.0.12(expo@55.0.11) expo-localization: specifier: '*' version: 16.1.6(expo@55.0.11)(react@19.2.0) + expo-tracking-transparency: + specifier: ^57.0.0 + version: 57.0.0(expo@55.0.11)(react-native@0.83.4(@babel/core@7.29.0)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@react-native/metro-config@0.83.4(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0)) jest: specifier: ^29.7.0 version: 29.7.0(@types/node@25.5.1) @@ -3611,6 +3623,16 @@ packages: expo: '*' react-native: '*' + expo-crypto@14.1.5: + resolution: {integrity: sha512-ZXJoUMoUeiMNEoSD4itItFFz3cKrit6YJ/BR0hjuwNC+NczbV9rorvhvmeJmrU9O2cFQHhJQQR1fjQnt45Vu4Q==} + peerDependencies: + expo: '*' + + expo-crypto@57.0.0: + resolution: {integrity: sha512-vd0kdUO14h9CgPcgzcR8nmy/wgz3zSOhQmucnbDdyn/z9eAeR2IB5BKaDvPbg/lrIT+KweGAV5IlrK5PZFqUSQ==} + peerDependencies: + expo: '*' + expo-dev-client@55.0.22: resolution: {integrity: sha512-3AB4n28A07uWsUlu7mOxr76V/jjEpjc5TSlmR37u/qdaX4qwjTy+2GDZWCZumY8dSgy/hKgn1CThnKoVnIM5KA==} peerDependencies: @@ -3765,6 +3787,18 @@ packages: react-native-web: optional: true + expo-tracking-transparency@5.2.4: + resolution: {integrity: sha512-Evog1rmbSLrODuG8AukEoV0y1RzQiJelX95rFuXY3kxHZQbiBOQ7YzAOsuYACztDf4XDoSODStdHSgUkgKNxJA==} + peerDependencies: + expo: '*' + react-native: '*' + + expo-tracking-transparency@57.0.0: + resolution: {integrity: sha512-dJNtvoeLRLT20s6bBSjUfQqk+krSetUR6zVyY4g4IXsMj+6xXkGygCpF4NzbJKxJaK0vNeZji6Lw9YzN4/cfCg==} + peerDependencies: + expo: '*' + react-native: '*' + expo-updates-interface@55.1.5: resolution: {integrity: sha512-YOk9vhplWi0djoeqxMlEQgcDFeOGhnj4dWU0v1QvF5RqpqwLGdx780E0k3zL85xw6LXljVN78d6g8z51qIZu5g==} peerDependencies: @@ -9033,7 +9067,9 @@ snapshots: metro-runtime: 0.83.5 transitivePeerDependencies: - '@babel/core' + - bufferutil - supports-color + - utf-8-validate optional: true '@react-native/normalize-colors@0.74.89': {} @@ -10762,6 +10798,15 @@ snapshots: - supports-color - typescript + expo-crypto@14.1.5(expo@55.0.11): + dependencies: + base64-js: 1.5.1 + expo: 55.0.11(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.9)(expo-router@55.0.10)(react-dom@19.2.0(react@19.2.0))(react-native@0.83.4(@babel/core@7.29.0)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@react-native/metro-config@0.83.4(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3) + + expo-crypto@57.0.0(expo@55.0.11): + dependencies: + expo: 55.0.11(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.9)(expo-router@55.0.10)(react-dom@19.2.0(react@19.2.0))(react-native@0.83.4(@babel/core@7.29.0)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@react-native/metro-config@0.83.4(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3) + expo-dev-client@55.0.22(expo@55.0.11)(typescript@5.9.3): dependencies: expo: 55.0.11(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.9)(expo-router@55.0.10)(react-dom@19.2.0(react@19.2.0))(react-native@0.83.4(@babel/core@7.29.0)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@react-native/metro-config@0.83.4(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3) @@ -10952,6 +10997,16 @@ snapshots: transitivePeerDependencies: - supports-color + expo-tracking-transparency@5.2.4(expo@55.0.11)(react-native@0.83.4(@babel/core@7.29.0)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@react-native/metro-config@0.83.4(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0)): + dependencies: + expo: 55.0.11(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.9)(expo-router@55.0.10)(react-dom@19.2.0(react@19.2.0))(react-native@0.83.4(@babel/core@7.29.0)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@react-native/metro-config@0.83.4(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3) + react-native: 0.83.4(@babel/core@7.29.0)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@react-native/metro-config@0.83.4(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0) + + expo-tracking-transparency@57.0.0(expo@55.0.11)(react-native@0.83.4(@babel/core@7.29.0)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@react-native/metro-config@0.83.4(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0)): + dependencies: + expo: 55.0.11(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.9)(expo-router@55.0.10)(react-dom@19.2.0(react@19.2.0))(react-native@0.83.4(@babel/core@7.29.0)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@react-native/metro-config@0.83.4(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3) + react-native: 0.83.4(@babel/core@7.29.0)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@react-native/metro-config@0.83.4(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0) + expo-updates-interface@55.1.5(expo@55.0.11): dependencies: expo: 55.0.11(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.9)(expo-router@55.0.10)(react-dom@19.2.0(react@19.2.0))(react-native@0.83.4(@babel/core@7.29.0)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@react-native/metro-config@0.83.4(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3) From b5047334f6ee7d2b1f3e55d5a552bdf8cc0fb81d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Turba=C5=84ski?= Date: Wed, 8 Jul 2026 14:52:52 +0200 Subject: [PATCH 02/18] fix: resolve expo-bare Android build failures from stale/missing deps --- examples/expo-bare/package.json | 1 + package.json | 1 + pnpm-lock.yaml | 6 ++++-- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/examples/expo-bare/package.json b/examples/expo-bare/package.json index cf19184..e8e5f34 100644 --- a/examples/expo-bare/package.json +++ b/examples/expo-bare/package.json @@ -21,6 +21,7 @@ "expo-dev-client": "~55.0.22", "expo-device": "~55.0.12", "expo-localization": "*", + "expo-splash-screen": "~55.0.15", "expo-tracking-transparency": "~5.2.4", "react": "19.2.0", "react-dom": "19.2.0", diff --git a/package.json b/package.json index 088c4a6..8ecf904 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,7 @@ }, "pnpm": { "overrides": { + "@react-native/babel-preset": "0.83.4", "metro": "0.83.5", "metro-babel-transformer": "0.83.5", "metro-cache": "0.83.5", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f5125a4..5e9832a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5,6 +5,7 @@ settings: excludeLinksFromLockfile: false overrides: + '@react-native/babel-preset': 0.83.4 metro: 0.83.5 metro-babel-transformer: 0.83.5 metro-cache: 0.83.5 @@ -106,6 +107,9 @@ importers: expo-localization: specifier: '*' version: 16.1.6(expo@55.0.11)(react@19.2.0) + expo-splash-screen: + specifier: ~55.0.15 + version: 55.0.15(expo@55.0.11)(typescript@5.9.3) expo-tracking-transparency: specifier: ~5.2.4 version: 5.2.4(expo@55.0.11)(react-native@0.83.4(@babel/core@7.29.0)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@react-native/metro-config@0.83.4(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0)) @@ -9067,9 +9071,7 @@ snapshots: metro-runtime: 0.83.5 transitivePeerDependencies: - '@babel/core' - - bufferutil - supports-color - - utf-8-validate optional: true '@react-native/normalize-colors@0.74.89': {} From 36100dd018061d46466249f945300788e853fe21 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Turba=C5=84ski?= Date: Mon, 13 Jul 2026 11:07:21 +0200 Subject: [PATCH 03/18] fix: updating dependency required --- examples/expo-bare/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/expo-bare/package.json b/examples/expo-bare/package.json index e8e5f34..8815d0b 100644 --- a/examples/expo-bare/package.json +++ b/examples/expo-bare/package.json @@ -22,7 +22,7 @@ "expo-device": "~55.0.12", "expo-localization": "*", "expo-splash-screen": "~55.0.15", - "expo-tracking-transparency": "~5.2.4", + "expo-tracking-transparency": "~55.0.11", "react": "19.2.0", "react-dom": "19.2.0", "react-native": "0.83.4", From cfdd3a66e561c819a54c6e2c4fa53a9857e247ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Turba=C5=84ski?= Date: Mon, 13 Jul 2026 11:17:32 +0200 Subject: [PATCH 04/18] feat: collecting app-version and build-number --- packages/react-native-detour/src/DetourContext.tsx | 7 +++++++ packages/react-native-detour/src/analytics/api/events.ts | 6 ++++++ .../react-native-detour/src/analytics/api/retention.ts | 6 ++++++ .../react-native-detour/src/analytics/utils/appInfo.ts | 8 ++++++++ 4 files changed, 27 insertions(+) create mode 100644 packages/react-native-detour/src/analytics/utils/appInfo.ts diff --git a/packages/react-native-detour/src/DetourContext.tsx b/packages/react-native-detour/src/DetourContext.tsx index 84e7c1f..5e448da 100644 --- a/packages/react-native-detour/src/DetourContext.tsx +++ b/packages/react-native-detour/src/DetourContext.tsx @@ -7,6 +7,7 @@ import { sendRetentionEvent } from "./analytics/api/retention"; import { useAppOpenRetention } from "./analytics/hooks/useAppOpenRetention"; import type { DetourEvent, DetourEventNames } from "./analytics/types"; import { analyticsEmitter } from "./analytics/utils/analyticsEmitter"; +import { getAppVersion, getBuildNumber } from "./analytics/utils/appInfo"; import { prepareDeviceIdForApi } from "./analytics/utils/devicePersistence"; import { getUserId } from "./analytics/utils/userIdentity"; import { useDetour } from "./links/hooks/useDetour"; @@ -68,6 +69,8 @@ const DetourProviderNative = ({ config, children }: Props) => { collectDeviceIdentitySignals(), ]); const customerUserId = getUserId(); + const appVersion = getAppVersion(); + const buildNumber = getBuildNumber(); if (isRetention) { sendRetentionEvent({ @@ -79,6 +82,8 @@ const DetourProviderNative = ({ config, children }: Props) => { aaid, idfa, customerUserId, + appVersion, + buildNumber, }); } else { const event: DetourEvent = { @@ -94,6 +99,8 @@ const DetourProviderNative = ({ config, children }: Props) => { aaid, idfa, customerUserId, + appVersion, + buildNumber, }); } } catch (error) { diff --git a/packages/react-native-detour/src/analytics/api/events.ts b/packages/react-native-detour/src/analytics/api/events.ts index eef5254..e041756 100644 --- a/packages/react-native-detour/src/analytics/api/events.ts +++ b/packages/react-native-detour/src/analytics/api/events.ts @@ -14,6 +14,8 @@ export const sendEvent = async ({ aaid, idfa, customerUserId, + appVersion, + buildNumber, }: { apiKey: string; appID: string; @@ -23,6 +25,8 @@ export const sendEvent = async ({ aaid?: string; idfa?: string; customerUserId?: string; + appVersion?: string; + buildNumber?: string; }) => { try { const response = await fetch(EVENT_API_URL, { @@ -43,6 +47,8 @@ export const sendEvent = async ({ aaid, idfa, customer_user_id: customerUserId, + app_version: appVersion, + build_number: buildNumber, }), }); diff --git a/packages/react-native-detour/src/analytics/api/retention.ts b/packages/react-native-detour/src/analytics/api/retention.ts index 90340df..e6a6ce7 100644 --- a/packages/react-native-detour/src/analytics/api/retention.ts +++ b/packages/react-native-detour/src/analytics/api/retention.ts @@ -13,6 +13,8 @@ export const sendRetentionEvent = async ({ aaid, idfa, customerUserId, + appVersion, + buildNumber, }: { apiKey: string; appID: string; @@ -22,6 +24,8 @@ export const sendRetentionEvent = async ({ aaid?: string; idfa?: string; customerUserId?: string; + appVersion?: string; + buildNumber?: string; }) => { try { const response = await fetch(RETENTION_API_URL, { @@ -41,6 +45,8 @@ export const sendRetentionEvent = async ({ aaid, idfa, customer_user_id: customerUserId, + app_version: appVersion, + build_number: buildNumber, }), }); diff --git a/packages/react-native-detour/src/analytics/utils/appInfo.ts b/packages/react-native-detour/src/analytics/utils/appInfo.ts new file mode 100644 index 0000000..aa21932 --- /dev/null +++ b/packages/react-native-detour/src/analytics/utils/appInfo.ts @@ -0,0 +1,8 @@ +import * as Application from "expo-application"; + +// Marketing version + build number of the host app — analytics-only context +// Both are synchronous and static for the app's lifetime, no caching needed. +export const getAppVersion = (): string | undefined => + Application.nativeApplicationVersion ?? undefined; + +export const getBuildNumber = (): string | undefined => Application.nativeBuildVersion ?? undefined; From 571cbc270e2bbd15e4fd4be1955e069c3aad70ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Turba=C5=84ski?= Date: Mon, 13 Jul 2026 11:36:12 +0200 Subject: [PATCH 05/18] feat: collect utm info --- .../src/links/api/getDeferredLink.ts | 26 +++++++++++++++++-- .../src/links/utils/fingerprint.ts | 6 +++++ 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/packages/react-native-detour/src/links/api/getDeferredLink.ts b/packages/react-native-detour/src/links/api/getDeferredLink.ts index 401e69d..572e62f 100644 --- a/packages/react-native-detour/src/links/api/getDeferredLink.ts +++ b/packages/react-native-detour/src/links/api/getDeferredLink.ts @@ -11,6 +11,19 @@ import { const API_URL = "https://godetour.dev/api/link/match-link"; +const parseUtmParams = (decodedReferrer: string): Record | undefined => { + const params = new URLSearchParams(decodedReferrer); + const utm: Record = {}; + + for (const [key, value] of params) { + if (key.startsWith("utm_") && value) { + utm[key] = value; + } + } + + return Object.keys(utm).length > 0 ? utm : undefined; +}; + const sendFingerprint = async ({ API_KEY, appID, @@ -52,10 +65,15 @@ export const getDeferredLink = async ({ const decodedReferrer = decodeURIComponent(referrer ?? ""); const matchClickId = decodedReferrer.match(/(?:^|&)click_id=([^&]+)/); const referrerClickId = matchClickId ? matchClickId[1] : null; + const utm = parseUtmParams(decodedReferrer); let response; if (referrerClickId?.length) { - const deterministicFingerprint = await getDeterministicFingerprint(referrerClickId, storage); + const deterministicFingerprint = await getDeterministicFingerprint( + referrerClickId, + storage, + utm, + ); response = await sendFingerprint({ API_KEY, @@ -63,7 +81,11 @@ export const getDeferredLink = async ({ requestBody: deterministicFingerprint, }); } else { - const probabilisticFingerprint = await getProbabilisticFingerprint(shouldUseClipboard, storage); + const probabilisticFingerprint = await getProbabilisticFingerprint( + shouldUseClipboard, + storage, + utm, + ); response = await sendFingerprint({ API_KEY, diff --git a/packages/react-native-detour/src/links/utils/fingerprint.ts b/packages/react-native-detour/src/links/utils/fingerprint.ts index bcf1d5a..a9b2a87 100644 --- a/packages/react-native-detour/src/links/utils/fingerprint.ts +++ b/packages/react-native-detour/src/links/utils/fingerprint.ts @@ -34,11 +34,13 @@ export type ProbabilisticFingerprint = DeviceIdentityFields & { userAgent: string; timestamp: number; pastedLink?: string; + utm?: Record; }; // used when install referrer on android is available export type DeterministicFingerprint = DeviceIdentityFields & { clickId: string; + utm?: Record; }; const collectIdentityFields = async (storage: DetourStorage): Promise => { @@ -59,9 +61,11 @@ const collectIdentityFields = async (storage: DetourStorage): Promise, ): Promise => { return { clickId, + utm, ...(await collectIdentityFields(storage)), }; }; @@ -69,6 +73,7 @@ export const getDeterministicFingerprint = async ( export const getProbabilisticFingerprint = async ( shouldUseClipboard: boolean, storage: DetourStorage, + utm?: Record, ): Promise => { const { width, height } = Dimensions.get("screen"); const locales = Localization.getLocales(); @@ -98,5 +103,6 @@ export const getProbabilisticFingerprint = async ( timestamp: Date.now(), pastedLink: shouldUseClipboard && Platform.OS === "ios" ? await Clipboard.getStringAsync() : undefined, + utm, }; }; From 3853764f1c0b53fcee3a2a48b34df0089f294f03 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Turba=C5=84ski?= Date: Tue, 14 Jul 2026 13:34:25 +0200 Subject: [PATCH 06/18] feat: set and collect consent --- .../react-native-detour/src/DetourContext.tsx | 4 ++++ .../src/analytics/analytics.ts | 2 ++ .../src/analytics/api/events.ts | 4 ++++ .../src/analytics/api/retention.ts | 4 ++++ .../src/analytics/utils/consent.ts | 18 ++++++++++++++++++ 5 files changed, 32 insertions(+) create mode 100644 packages/react-native-detour/src/analytics/utils/consent.ts diff --git a/packages/react-native-detour/src/DetourContext.tsx b/packages/react-native-detour/src/DetourContext.tsx index 5e448da..7bfe66e 100644 --- a/packages/react-native-detour/src/DetourContext.tsx +++ b/packages/react-native-detour/src/DetourContext.tsx @@ -8,6 +8,7 @@ import { useAppOpenRetention } from "./analytics/hooks/useAppOpenRetention"; import type { DetourEvent, DetourEventNames } from "./analytics/types"; import { analyticsEmitter } from "./analytics/utils/analyticsEmitter"; import { getAppVersion, getBuildNumber } from "./analytics/utils/appInfo"; +import { getConsent } from "./analytics/utils/consent"; import { prepareDeviceIdForApi } from "./analytics/utils/devicePersistence"; import { getUserId } from "./analytics/utils/userIdentity"; import { useDetour } from "./links/hooks/useDetour"; @@ -71,6 +72,7 @@ const DetourProviderNative = ({ config, children }: Props) => { const customerUserId = getUserId(); const appVersion = getAppVersion(); const buildNumber = getBuildNumber(); + const consent = getConsent(); if (isRetention) { sendRetentionEvent({ @@ -84,6 +86,7 @@ const DetourProviderNative = ({ config, children }: Props) => { customerUserId, appVersion, buildNumber, + consent, }); } else { const event: DetourEvent = { @@ -101,6 +104,7 @@ const DetourProviderNative = ({ config, children }: Props) => { customerUserId, appVersion, buildNumber, + consent, }); } } catch (error) { diff --git a/packages/react-native-detour/src/analytics/analytics.ts b/packages/react-native-detour/src/analytics/analytics.ts index 15c767e..b95039a 100644 --- a/packages/react-native-detour/src/analytics/analytics.ts +++ b/packages/react-native-detour/src/analytics/analytics.ts @@ -1,5 +1,6 @@ import { DetourEventNames } from "./types"; import { analyticsEmitter } from "./utils/analyticsEmitter"; +import { setConsent } from "./utils/consent"; import { setUserId } from "./utils/userIdentity"; export const logEvent = (eventName: DetourEventNames | `${DetourEventNames}`, data?: any) => { @@ -14,4 +15,5 @@ export const DetourAnalytics = { logEvent, logRetention, setUserId, + setConsent, }; diff --git a/packages/react-native-detour/src/analytics/api/events.ts b/packages/react-native-detour/src/analytics/api/events.ts index e041756..0299913 100644 --- a/packages/react-native-detour/src/analytics/api/events.ts +++ b/packages/react-native-detour/src/analytics/api/events.ts @@ -2,6 +2,7 @@ import { Platform } from "react-native"; import { SDK_HEADER_VALUE } from "../../version"; import type { DetourEvent } from "../types"; +import type { Consent } from "../utils/consent"; const EVENT_API_URL = "https://godetour.dev/api/analytics/event"; @@ -16,6 +17,7 @@ export const sendEvent = async ({ customerUserId, appVersion, buildNumber, + consent, }: { apiKey: string; appID: string; @@ -27,6 +29,7 @@ export const sendEvent = async ({ customerUserId?: string; appVersion?: string; buildNumber?: string; + consent?: Consent; }) => { try { const response = await fetch(EVENT_API_URL, { @@ -49,6 +52,7 @@ export const sendEvent = async ({ customer_user_id: customerUserId, app_version: appVersion, build_number: buildNumber, + consent, }), }); diff --git a/packages/react-native-detour/src/analytics/api/retention.ts b/packages/react-native-detour/src/analytics/api/retention.ts index e6a6ce7..3bcb00a 100644 --- a/packages/react-native-detour/src/analytics/api/retention.ts +++ b/packages/react-native-detour/src/analytics/api/retention.ts @@ -1,6 +1,7 @@ import { Platform } from "react-native"; import { SDK_HEADER_VALUE } from "../../version"; +import type { Consent } from "../utils/consent"; const RETENTION_API_URL = "https://godetour.dev/api/analytics/retention"; @@ -15,6 +16,7 @@ export const sendRetentionEvent = async ({ customerUserId, appVersion, buildNumber, + consent, }: { apiKey: string; appID: string; @@ -26,6 +28,7 @@ export const sendRetentionEvent = async ({ customerUserId?: string; appVersion?: string; buildNumber?: string; + consent?: Consent; }) => { try { const response = await fetch(RETENTION_API_URL, { @@ -47,6 +50,7 @@ export const sendRetentionEvent = async ({ customer_user_id: customerUserId, app_version: appVersion, build_number: buildNumber, + consent, }), }); diff --git a/packages/react-native-detour/src/analytics/utils/consent.ts b/packages/react-native-detour/src/analytics/utils/consent.ts new file mode 100644 index 0000000..242367d --- /dev/null +++ b/packages/react-native-detour/src/analytics/utils/consent.ts @@ -0,0 +1,18 @@ +// In-memory only, by design: same convention as userIdentity.ts — the host +// app owns its own consent UI/state and is expected to call setConsent() +// again on every cold start once the user's choice is known. +export type Consent = { + ad?: boolean; + analytics?: boolean; + tracking?: boolean; +}; + +let consent: Consent | undefined; + +// Merges into the existing state so the host can update one flag (e.g. +// tracking, right after an ATT prompt resolves) without re-supplying the rest. +export const setConsent = (update: Consent): void => { + consent = { ...consent, ...update }; +}; + +export const getConsent = (): Consent | undefined => consent; From f5df99d83ede85a46701a80bafb64821a36ea00c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Turba=C5=84ski?= Date: Tue, 14 Jul 2026 15:11:48 +0200 Subject: [PATCH 07/18] feat: set advertising id by hand --- .../src/analytics/analytics.ts | 2 ++ .../src/links/utils/deviceIdentifiers.ts | 16 ++++++++++++++++ 2 files changed, 18 insertions(+) diff --git a/packages/react-native-detour/src/analytics/analytics.ts b/packages/react-native-detour/src/analytics/analytics.ts index b95039a..a448ddd 100644 --- a/packages/react-native-detour/src/analytics/analytics.ts +++ b/packages/react-native-detour/src/analytics/analytics.ts @@ -1,3 +1,4 @@ +import { setAdvertisingId } from "../links/utils/deviceIdentifiers"; import { DetourEventNames } from "./types"; import { analyticsEmitter } from "./utils/analyticsEmitter"; import { setConsent } from "./utils/consent"; @@ -16,4 +17,5 @@ export const DetourAnalytics = { logRetention, setUserId, setConsent, + setAdvertisingId, }; diff --git a/packages/react-native-detour/src/links/utils/deviceIdentifiers.ts b/packages/react-native-detour/src/links/utils/deviceIdentifiers.ts index c3e5a08..2dff778 100644 --- a/packages/react-native-detour/src/links/utils/deviceIdentifiers.ts +++ b/packages/react-native-detour/src/links/utils/deviceIdentifiers.ts @@ -6,6 +6,11 @@ import * as Application from "expo-application"; // every opted-out device into the same fake "identifier". const AD_ID_OPT_OUT = "00000000-0000-0000-0000-000000000000"; +// Host-supplied override — set via setAdvertisingId() when the host already +// collected the ID through another native module/SDK, so we skip our own +// native call entirely (see getAaid/getIdfa precedence below). +let manualAdvertisingId: string | undefined; + type TrackingTransparencyModule = { getAdvertisingId?: () => Promise; requestTrackingPermissionsAsync?: () => Promise<{ granted: boolean }>; @@ -44,12 +49,14 @@ const getRawAdvertisingId = async (): Promise => { // Android advertising ID — feeds the backend's deterministic device_uuid. export const getAaid = async (): Promise => { if (Platform.OS !== "android") return undefined; + if (manualAdvertisingId) return manualAdvertisingId; return getRawAdvertisingId(); }; // iOS advertising ID — ad-attribution signal only, does not feed device_uuid (IDFV does). export const getIdfa = async (): Promise => { if (Platform.OS !== "ios") return undefined; + if (manualAdvertisingId) return manualAdvertisingId; return getRawAdvertisingId(); }; @@ -96,6 +103,15 @@ const resetDeviceIdentitySignalsCache = (): void => { pendingSignalsPromise = null; }; +// Lets the host inject an IDFA/AAID it already collected another way (its own +// native bridge, another attribution SDK) — skips our native call for the +// rest of the session. Resets the cache so a value collected before this call +// (or the pre-override native fallback) doesn't linger. +export const setAdvertisingId = (id: string): void => { + manualAdvertisingId = id; + resetDeviceIdentitySignalsCache(); +}; + // Host-app-opt-in helper: only called when `shouldRequestTrackingPermission` // is set on the Detour config. On Android/web this is a no-op — the OS // doesn't gate the advertising ID behind a runtime prompt there. From 0f6ca95b02425679a1ccd0908bc0af9b04c40551 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Turba=C5=84ski?= Date: Thu, 16 Jul 2026 12:14:43 +0200 Subject: [PATCH 08/18] feat: collect localization and os version --- packages/react-native-detour/src/DetourContext.tsx | 9 +++++++++ .../react-native-detour/src/analytics/api/events.ts | 6 ++++++ .../src/analytics/api/retention.ts | 6 ++++++ .../src/links/utils/deviceInfo.ts | 12 ++++++++++++ 4 files changed, 33 insertions(+) diff --git a/packages/react-native-detour/src/DetourContext.tsx b/packages/react-native-detour/src/DetourContext.tsx index 7bfe66e..17fc9cf 100644 --- a/packages/react-native-detour/src/DetourContext.tsx +++ b/packages/react-native-detour/src/DetourContext.tsx @@ -2,6 +2,8 @@ import { type PropsWithChildren, createContext, useContext, useEffect } from "re import { Platform } from "react-native"; +import * as Localization from "expo-localization"; + import { sendEvent } from "./analytics/api/events"; import { sendRetentionEvent } from "./analytics/api/retention"; import { useAppOpenRetention } from "./analytics/hooks/useAppOpenRetention"; @@ -17,6 +19,7 @@ import { collectDeviceIdentitySignals, requestTrackingPermission, } from "./links/utils/deviceIdentifiers"; +import { getSafeOsVersion } from "./links/utils/deviceInfo"; import { resolveStorage } from "./links/utils/storage"; type Props = PropsWithChildren & { config: Config }; @@ -73,6 +76,8 @@ const DetourProviderNative = ({ config, children }: Props) => { const appVersion = getAppVersion(); const buildNumber = getBuildNumber(); const consent = getConsent(); + const osVersion = getSafeOsVersion(); + const locale = Localization.getLocales().map((l) => l.languageTag); if (isRetention) { sendRetentionEvent({ @@ -87,6 +92,8 @@ const DetourProviderNative = ({ config, children }: Props) => { appVersion, buildNumber, consent, + osVersion, + locale, }); } else { const event: DetourEvent = { @@ -105,6 +112,8 @@ const DetourProviderNative = ({ config, children }: Props) => { appVersion, buildNumber, consent, + osVersion, + locale, }); } } catch (error) { diff --git a/packages/react-native-detour/src/analytics/api/events.ts b/packages/react-native-detour/src/analytics/api/events.ts index 0299913..c81c5b9 100644 --- a/packages/react-native-detour/src/analytics/api/events.ts +++ b/packages/react-native-detour/src/analytics/api/events.ts @@ -18,6 +18,8 @@ export const sendEvent = async ({ appVersion, buildNumber, consent, + osVersion, + locale, }: { apiKey: string; appID: string; @@ -30,6 +32,8 @@ export const sendEvent = async ({ appVersion?: string; buildNumber?: string; consent?: Consent; + osVersion?: string; + locale?: string[]; }) => { try { const response = await fetch(EVENT_API_URL, { @@ -53,6 +57,8 @@ export const sendEvent = async ({ app_version: appVersion, build_number: buildNumber, consent, + os_version: osVersion, + locale, }), }); diff --git a/packages/react-native-detour/src/analytics/api/retention.ts b/packages/react-native-detour/src/analytics/api/retention.ts index 3bcb00a..53aede5 100644 --- a/packages/react-native-detour/src/analytics/api/retention.ts +++ b/packages/react-native-detour/src/analytics/api/retention.ts @@ -17,6 +17,8 @@ export const sendRetentionEvent = async ({ appVersion, buildNumber, consent, + osVersion, + locale, }: { apiKey: string; appID: string; @@ -29,6 +31,8 @@ export const sendRetentionEvent = async ({ appVersion?: string; buildNumber?: string; consent?: Consent; + osVersion?: string; + locale?: string[]; }) => { try { const response = await fetch(RETENTION_API_URL, { @@ -51,6 +55,8 @@ export const sendRetentionEvent = async ({ app_version: appVersion, build_number: buildNumber, consent, + os_version: osVersion, + locale, }), }); diff --git a/packages/react-native-detour/src/links/utils/deviceInfo.ts b/packages/react-native-detour/src/links/utils/deviceInfo.ts index c6021ed..8615398 100644 --- a/packages/react-native-detour/src/links/utils/deviceInfo.ts +++ b/packages/react-native-detour/src/links/utils/deviceInfo.ts @@ -117,6 +117,18 @@ export const getSyncDeviceInfo = (): SyncDeviceInfo => { }; }; +// Optional analytics context field — unlike getSyncDeviceInfo/getDeviceInfo, +// never throws when no device info library is installed (falls back to undefined +// instead of forcing every event to be dropped for a non-essential field). +export const getSafeOsVersion = (): string | undefined => { + try { + const { osVersion } = getSyncDeviceInfo(); + return osVersion === UNKNOWN ? undefined : osVersion; + } catch { + return undefined; + } +}; + export const getDeviceInfo = async (): Promise => { assertDeviceInfoLibraryAvailable(); From 8f07d354af9302bf5a3722fd0465ffb293454f36 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Turba=C5=84ski?= Date: Thu, 16 Jul 2026 13:18:31 +0200 Subject: [PATCH 09/18] feat: collect attStatus and add autoconset for ios --- .../react-native-detour/src/DetourContext.tsx | 4 +- .../src/analytics/analytics.ts | 3 +- .../src/analytics/api/events.ts | 4 ++ .../src/analytics/api/retention.ts | 4 ++ .../src/links/utils/deviceIdentifiers.ts | 63 +++++++++++++++++-- 5 files changed, 72 insertions(+), 6 deletions(-) diff --git a/packages/react-native-detour/src/DetourContext.tsx b/packages/react-native-detour/src/DetourContext.tsx index 17fc9cf..13dbc15 100644 --- a/packages/react-native-detour/src/DetourContext.tsx +++ b/packages/react-native-detour/src/DetourContext.tsx @@ -68,7 +68,7 @@ const DetourProviderNative = ({ config, children }: Props) => { } try { - const [deviceId, { idfv, aaid, idfa }] = await Promise.all([ + const [deviceId, { idfv, aaid, idfa, attStatus }] = await Promise.all([ prepareDeviceIdForApi(storage), collectDeviceIdentitySignals(), ]); @@ -94,6 +94,7 @@ const DetourProviderNative = ({ config, children }: Props) => { consent, osVersion, locale, + attStatus, }); } else { const event: DetourEvent = { @@ -114,6 +115,7 @@ const DetourProviderNative = ({ config, children }: Props) => { consent, osVersion, locale, + attStatus, }); } } catch (error) { diff --git a/packages/react-native-detour/src/analytics/analytics.ts b/packages/react-native-detour/src/analytics/analytics.ts index a448ddd..1259e25 100644 --- a/packages/react-native-detour/src/analytics/analytics.ts +++ b/packages/react-native-detour/src/analytics/analytics.ts @@ -1,4 +1,4 @@ -import { setAdvertisingId } from "../links/utils/deviceIdentifiers"; +import { setAdvertisingId, setTrackingAuthorizationStatus } from "../links/utils/deviceIdentifiers"; import { DetourEventNames } from "./types"; import { analyticsEmitter } from "./utils/analyticsEmitter"; import { setConsent } from "./utils/consent"; @@ -18,4 +18,5 @@ export const DetourAnalytics = { setUserId, setConsent, setAdvertisingId, + setTrackingAuthorizationStatus, }; diff --git a/packages/react-native-detour/src/analytics/api/events.ts b/packages/react-native-detour/src/analytics/api/events.ts index c81c5b9..8500041 100644 --- a/packages/react-native-detour/src/analytics/api/events.ts +++ b/packages/react-native-detour/src/analytics/api/events.ts @@ -1,5 +1,6 @@ import { Platform } from "react-native"; +import type { AttStatus } from "../../links/utils/deviceIdentifiers"; import { SDK_HEADER_VALUE } from "../../version"; import type { DetourEvent } from "../types"; import type { Consent } from "../utils/consent"; @@ -20,6 +21,7 @@ export const sendEvent = async ({ consent, osVersion, locale, + attStatus, }: { apiKey: string; appID: string; @@ -34,6 +36,7 @@ export const sendEvent = async ({ consent?: Consent; osVersion?: string; locale?: string[]; + attStatus?: AttStatus; }) => { try { const response = await fetch(EVENT_API_URL, { @@ -59,6 +62,7 @@ export const sendEvent = async ({ consent, os_version: osVersion, locale, + att_status: attStatus, }), }); diff --git a/packages/react-native-detour/src/analytics/api/retention.ts b/packages/react-native-detour/src/analytics/api/retention.ts index 53aede5..53be0ce 100644 --- a/packages/react-native-detour/src/analytics/api/retention.ts +++ b/packages/react-native-detour/src/analytics/api/retention.ts @@ -1,5 +1,6 @@ import { Platform } from "react-native"; +import type { AttStatus } from "../../links/utils/deviceIdentifiers"; import { SDK_HEADER_VALUE } from "../../version"; import type { Consent } from "../utils/consent"; @@ -19,6 +20,7 @@ export const sendRetentionEvent = async ({ consent, osVersion, locale, + attStatus, }: { apiKey: string; appID: string; @@ -33,6 +35,7 @@ export const sendRetentionEvent = async ({ consent?: Consent; osVersion?: string; locale?: string[]; + attStatus?: AttStatus; }) => { try { const response = await fetch(RETENTION_API_URL, { @@ -57,6 +60,7 @@ export const sendRetentionEvent = async ({ consent, os_version: osVersion, locale, + att_status: attStatus, }), }); diff --git a/packages/react-native-detour/src/links/utils/deviceIdentifiers.ts b/packages/react-native-detour/src/links/utils/deviceIdentifiers.ts index 2dff778..c81838f 100644 --- a/packages/react-native-detour/src/links/utils/deviceIdentifiers.ts +++ b/packages/react-native-detour/src/links/utils/deviceIdentifiers.ts @@ -2,6 +2,8 @@ import { Platform } from "react-native"; import * as Application from "expo-application"; +import { setConsent } from "../../analytics/utils/consent"; + // Android AAID / iOS IDFA opt-out sentinel — never send as-is, it collides // every opted-out device into the same fake "identifier". const AD_ID_OPT_OUT = "00000000-0000-0000-0000-000000000000"; @@ -11,9 +13,16 @@ const AD_ID_OPT_OUT = "00000000-0000-0000-0000-000000000000"; // native call entirely (see getAaid/getIdfa precedence below). let manualAdvertisingId: string | undefined; +export type AttStatus = "granted" | "denied" | "undetermined" | "unavailable"; + +// Host-supplied override — set via setTrackingAuthorizationStatus() when the +// host's native ATT module isn't expo-tracking-transparency. +let manualAttStatus: AttStatus | undefined; + type TrackingTransparencyModule = { getAdvertisingId?: () => Promise; - requestTrackingPermissionsAsync?: () => Promise<{ granted: boolean }>; + requestTrackingPermissionsAsync?: () => Promise<{ status: string }>; + getTrackingPermissionsAsync?: () => Promise<{ status: string }>; }; // Metro needs string literal in require() during bundle time (same constraint as deviceInfo.ts) @@ -60,10 +69,42 @@ export const getIdfa = async (): Promise => { return getRawAdvertisingId(); }; +// ATT is one gate for both tracking and ad personalization on iOS (Apple ties +// them together) — so once we know the status, we know both consent flags +// without asking the host. Only fires for a determined status; "undetermined" +// means we don't know yet, so we don't want to assert a false negative. +const applyAttAutoConsent = (status: AttStatus): void => { + if (status !== "granted" && status !== "denied") return; + setConsent({ tracking: status === "granted", ad: status === "granted" }); +}; + +const getRawAttStatus = async (): Promise => { + if (Platform.OS !== "ios" || !trackingTransparency?.getTrackingPermissionsAsync) { + return "unavailable"; + } + try { + const { status } = await trackingTransparency.getTrackingPermissionsAsync(); + return status as AttStatus; + } catch { + return "unavailable"; + } +}; + +// Reads the current ATT permission state without ever showing the system +// prompt (unlike requestTrackingPermission) — so att_status is known even +// when shouldRequestTrackingPermission is off (host prompts some other way). +export const getAttStatus = async (): Promise => { + if (manualAttStatus) return manualAttStatus; + const status = await getRawAttStatus(); + applyAttAutoConsent(status); + return status; +}; + export type DeviceIdentitySignals = { idfv?: string; aaid?: string; idfa?: string; + attStatus: AttStatus; }; let cachedSignals: DeviceIdentitySignals | null = null; @@ -82,8 +123,13 @@ export const collectDeviceIdentitySignals = async (): Promise { - const [idfv, aaid, idfa] = await Promise.all([getIdfv(), getAaid(), getIdfa()]); - const signals = { idfv, aaid, idfa }; + const [idfv, aaid, idfa, attStatus] = await Promise.all([ + getIdfv(), + getAaid(), + getIdfa(), + getAttStatus(), + ]); + const signals = { idfv, aaid, idfa, attStatus }; cachedSignals = signals; return signals; })(); @@ -118,10 +164,19 @@ export const setAdvertisingId = (id: string): void => { export const requestTrackingPermission = async (): Promise => { if (Platform.OS !== "ios" || !trackingTransparency?.requestTrackingPermissionsAsync) return; try { - await trackingTransparency.requestTrackingPermissionsAsync(); + const { status } = await trackingTransparency.requestTrackingPermissionsAsync(); + applyAttAutoConsent(status as AttStatus); } catch { // Best-effort — a failed/denied request just leaves idfa undefined downstream. } finally { resetDeviceIdentitySignalsCache(); } }; + +// Override for hosts whose native ATT module isn't expo-tracking-transparency +// (e.g. a custom native bridge) — same precedence pattern as setAdvertisingId. +export const setTrackingAuthorizationStatus = (status: AttStatus): void => { + manualAttStatus = status; + applyAttAutoConsent(status); + resetDeviceIdentitySignalsCache(); +}; From eed2545e0d78030bedaf9401303106f09121b564 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Turba=C5=84ski?= Date: Thu, 16 Jul 2026 15:54:25 +0200 Subject: [PATCH 10/18] feat: auto collecting android aaid --- .../src/analytics/utils/consent.ts | 7 +++- .../src/links/utils/deviceIdentifiers.ts | 32 ++++++++++++++----- 2 files changed, 30 insertions(+), 9 deletions(-) diff --git a/packages/react-native-detour/src/analytics/utils/consent.ts b/packages/react-native-detour/src/analytics/utils/consent.ts index 242367d..3f4354d 100644 --- a/packages/react-native-detour/src/analytics/utils/consent.ts +++ b/packages/react-native-detour/src/analytics/utils/consent.ts @@ -5,14 +5,19 @@ export type Consent = { ad?: boolean; analytics?: boolean; tracking?: boolean; + source?: "att" | "aaid-optout" | "manual"; + updatedAt?: number; }; let consent: Consent | undefined; // Merges into the existing state so the host can update one flag (e.g. // tracking, right after an ATT prompt resolves) without re-supplying the rest. +// `source` defaults to "manual" when omitted — internal auto-derivation +// (ATT/AAID) always passes its own explicit source, so an omitted source +// means this call came directly from the host. export const setConsent = (update: Consent): void => { - consent = { ...consent, ...update }; + consent = { ...consent, ...update, source: update.source ?? "manual", updatedAt: Date.now() }; }; export const getConsent = (): Consent | undefined => consent; diff --git a/packages/react-native-detour/src/links/utils/deviceIdentifiers.ts b/packages/react-native-detour/src/links/utils/deviceIdentifiers.ts index c81838f..85ae4fb 100644 --- a/packages/react-native-detour/src/links/utils/deviceIdentifiers.ts +++ b/packages/react-native-detour/src/links/utils/deviceIdentifiers.ts @@ -44,22 +44,38 @@ export const getIdfv = async (): Promise => { } }; -const getRawAdvertisingId = async (): Promise => { - if (!trackingTransparency?.getAdvertisingId) return undefined; +const fetchRawAdvertisingId = async (): Promise => { + if (!trackingTransparency?.getAdvertisingId) return null; try { - const id = await trackingTransparency.getAdvertisingId(); - if (!id || id === AD_ID_OPT_OUT) return undefined; - return id; + return await trackingTransparency.getAdvertisingId(); } catch { - return undefined; + return null; } }; +const getRawAdvertisingId = async (): Promise => { + const id = await fetchRawAdvertisingId(); + if (!id || id === AD_ID_OPT_OUT) return undefined; + return id; +}; + +// Android's equivalent of ATT: Google Play Services returns the opt-out +// sentinel when the user enabled "Opt out of Ads Personalization" in device +// settings. `null` means the fetch failed/unavailable, not an +// opt-out. +const applyAaidAutoConsent = (rawId: string | null): void => { + if (!rawId) return; + setConsent({ ad: rawId !== AD_ID_OPT_OUT, source: "aaid-optout" }); +}; + // Android advertising ID — feeds the backend's deterministic device_uuid. export const getAaid = async (): Promise => { if (Platform.OS !== "android") return undefined; if (manualAdvertisingId) return manualAdvertisingId; - return getRawAdvertisingId(); + const rawId = await fetchRawAdvertisingId(); + applyAaidAutoConsent(rawId); + if (!rawId || rawId === AD_ID_OPT_OUT) return undefined; + return rawId; }; // iOS advertising ID — ad-attribution signal only, does not feed device_uuid (IDFV does). @@ -75,7 +91,7 @@ export const getIdfa = async (): Promise => { // means we don't know yet, so we don't want to assert a false negative. const applyAttAutoConsent = (status: AttStatus): void => { if (status !== "granted" && status !== "denied") return; - setConsent({ tracking: status === "granted", ad: status === "granted" }); + setConsent({ tracking: status === "granted", ad: status === "granted", source: "att" }); }; const getRawAttStatus = async (): Promise => { From edb2d37b4259f36001adb80ed70716c1e6ca799c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Turba=C5=84ski?= Date: Thu, 16 Jul 2026 16:07:21 +0200 Subject: [PATCH 11/18] feat: add conversion logger --- .../react-native-detour/src/DetourContext.tsx | 137 +++++++++--------- .../src/analytics/analytics.ts | 17 +++ .../src/analytics/api/events.ts | 9 +- .../src/analytics/types/index.ts | 11 ++ .../src/analytics/utils/analyticsEmitter.ts | 8 +- 5 files changed, 112 insertions(+), 70 deletions(-) diff --git a/packages/react-native-detour/src/DetourContext.tsx b/packages/react-native-detour/src/DetourContext.tsx index 13dbc15..c326b33 100644 --- a/packages/react-native-detour/src/DetourContext.tsx +++ b/packages/react-native-detour/src/DetourContext.tsx @@ -55,76 +55,79 @@ const DetourProviderNative = ({ config, children }: Props) => { useEffect(() => { activeProviderCount++; - const unsubscribe = analyticsEmitter.subscribe(async ({ eventName, data, isRetention }) => { - if (activeProviderCount > 1) { - if (__DEV__) { + const unsubscribe = analyticsEmitter.subscribe( + async ({ eventName, data, isRetention, conversion }) => { + if (activeProviderCount > 1) { + if (__DEV__) { + console.error( + `🔗[Detour:ANALYTICS_ERROR] Event "${eventName}" dropped. ` + + `Multiple DetourProviders (${activeProviderCount}) detected. ` + + "Analytics logging is disabled until only one provider remains.", + ); + } + return; + } + + try { + const [deviceId, { idfv, aaid, idfa, attStatus }] = await Promise.all([ + prepareDeviceIdForApi(storage), + collectDeviceIdentitySignals(), + ]); + const customerUserId = getUserId(); + const appVersion = getAppVersion(); + const buildNumber = getBuildNumber(); + const consent = getConsent(); + const osVersion = getSafeOsVersion(); + const locale = Localization.getLocales().map((l) => l.languageTag); + + if (isRetention) { + sendRetentionEvent({ + apiKey, + appID, + eventName, + deviceId, + idfv, + aaid, + idfa, + customerUserId, + appVersion, + buildNumber, + consent, + osVersion, + locale, + attStatus, + }); + } else { + const event: DetourEvent = { + eventName: eventName as DetourEventNames, + data, + }; + sendEvent({ + apiKey, + appID, + event, + deviceId, + idfv, + aaid, + idfa, + customerUserId, + appVersion, + buildNumber, + consent, + osVersion, + locale, + attStatus, + conversion, + }); + } + } catch (error) { console.error( - `🔗[Detour:ANALYTICS_ERROR] Event "${eventName}" dropped. ` + - `Multiple DetourProviders (${activeProviderCount}) detected. ` + - "Analytics logging is disabled until only one provider remains.", + "[Detour:ANALYTICS_ERROR] Analytics disabled due to storage/runtime failure:", + error, ); } - return; - } - - try { - const [deviceId, { idfv, aaid, idfa, attStatus }] = await Promise.all([ - prepareDeviceIdForApi(storage), - collectDeviceIdentitySignals(), - ]); - const customerUserId = getUserId(); - const appVersion = getAppVersion(); - const buildNumber = getBuildNumber(); - const consent = getConsent(); - const osVersion = getSafeOsVersion(); - const locale = Localization.getLocales().map((l) => l.languageTag); - - if (isRetention) { - sendRetentionEvent({ - apiKey, - appID, - eventName, - deviceId, - idfv, - aaid, - idfa, - customerUserId, - appVersion, - buildNumber, - consent, - osVersion, - locale, - attStatus, - }); - } else { - const event: DetourEvent = { - eventName: eventName as DetourEventNames, - data, - }; - sendEvent({ - apiKey, - appID, - event, - deviceId, - idfv, - aaid, - idfa, - customerUserId, - appVersion, - buildNumber, - consent, - osVersion, - locale, - attStatus, - }); - } - } catch (error) { - console.error( - "[Detour:ANALYTICS_ERROR] Analytics disabled due to storage/runtime failure:", - error, - ); - } - }); + }, + ); return () => { activeProviderCount--; diff --git a/packages/react-native-detour/src/analytics/analytics.ts b/packages/react-native-detour/src/analytics/analytics.ts index 1259e25..040ca0b 100644 --- a/packages/react-native-detour/src/analytics/analytics.ts +++ b/packages/react-native-detour/src/analytics/analytics.ts @@ -1,5 +1,6 @@ import { setAdvertisingId, setTrackingAuthorizationStatus } from "../links/utils/deviceIdentifiers"; import { DetourEventNames } from "./types"; +import type { Conversion } from "./types"; import { analyticsEmitter } from "./utils/analyticsEmitter"; import { setConsent } from "./utils/consent"; import { setUserId } from "./utils/userIdentity"; @@ -12,9 +13,25 @@ export const logRetention = (retentionEventName: string) => { analyticsEmitter.emit({ eventName: retentionEventName, isRetention: true }); }; +export type ConversionParams = Conversion & { + eventName?: DetourEventNames | `${DetourEventNames}`; +}; + +// Revenue reporting inherently needs the host to call in — the SDK has no +// signal for transaction amount. Defaults to Purchase since that's the +// overwhelming majority case; still rides the existing event endpoint (see +// events.ts), just with revenue/currency guaranteed as top-level fields. +export const logConversion = ({ + eventName = DetourEventNames.Purchase, + ...conversion +}: ConversionParams) => { + analyticsEmitter.emit({ eventName, conversion }); +}; + export const DetourAnalytics = { logEvent, logRetention, + logConversion, setUserId, setConsent, setAdvertisingId, diff --git a/packages/react-native-detour/src/analytics/api/events.ts b/packages/react-native-detour/src/analytics/api/events.ts index 8500041..bc3ef6a 100644 --- a/packages/react-native-detour/src/analytics/api/events.ts +++ b/packages/react-native-detour/src/analytics/api/events.ts @@ -2,7 +2,7 @@ import { Platform } from "react-native"; import type { AttStatus } from "../../links/utils/deviceIdentifiers"; import { SDK_HEADER_VALUE } from "../../version"; -import type { DetourEvent } from "../types"; +import type { Conversion, DetourEvent } from "../types"; import type { Consent } from "../utils/consent"; const EVENT_API_URL = "https://godetour.dev/api/analytics/event"; @@ -22,6 +22,7 @@ export const sendEvent = async ({ osVersion, locale, attStatus, + conversion, }: { apiKey: string; appID: string; @@ -37,6 +38,7 @@ export const sendEvent = async ({ osVersion?: string; locale?: string[]; attStatus?: AttStatus; + conversion?: Conversion; }) => { try { const response = await fetch(EVENT_API_URL, { @@ -63,6 +65,11 @@ export const sendEvent = async ({ os_version: osVersion, locale, att_status: attStatus, + revenue: conversion?.revenue, + currency: conversion?.currency, + product_id: conversion?.productId, + quantity: conversion?.quantity, + transaction_id: conversion?.transactionId, }), }); diff --git a/packages/react-native-detour/src/analytics/types/index.ts b/packages/react-native-detour/src/analytics/types/index.ts index 4eb9295..cc2a2b7 100644 --- a/packages/react-native-detour/src/analytics/types/index.ts +++ b/packages/react-native-detour/src/analytics/types/index.ts @@ -25,3 +25,14 @@ export type DetourEvent = { eventName: DetourEventNames; data?: any; }; + +// Revenue always as first-class, typed fields (not buried in `data`) — every +// host reports it the same way, so the backend can aggregate ROAS across +// apps without per-host parsing conventions. +export type Conversion = { + revenue: number; + currency: string; + productId?: string; + quantity?: number; + transactionId?: string; +}; diff --git a/packages/react-native-detour/src/analytics/utils/analyticsEmitter.ts b/packages/react-native-detour/src/analytics/utils/analyticsEmitter.ts index 1a4bb0e..3f2035e 100644 --- a/packages/react-native-detour/src/analytics/utils/analyticsEmitter.ts +++ b/packages/react-native-detour/src/analytics/utils/analyticsEmitter.ts @@ -1,13 +1,15 @@ -import type { DetourEventNames } from "../types"; +import type { Conversion, DetourEventNames } from "../types"; type AnalyticsListener = ({ eventName, data, isRetention, + conversion, }: { eventName: string | DetourEventNames; data?: any; isRetention?: boolean; + conversion?: Conversion; }) => void; let listeners: AnalyticsListener[] = []; @@ -24,10 +26,12 @@ export const analyticsEmitter = { eventName, data, isRetention, + conversion, }: { eventName: string | DetourEventNames; data?: any; isRetention?: boolean; + conversion?: Conversion; }) => { if (listeners.length === 0) { console.warn( @@ -35,6 +39,6 @@ export const analyticsEmitter = { ); return; } - listeners.forEach((listener) => listener({ eventName, data, isRetention })); + listeners.forEach((listener) => listener({ eventName, data, isRetention, conversion })); }, }; From ed492c72b63fb5dbb00fb886b84cff45e03f4464 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Turba=C5=84ski?= Date: Fri, 17 Jul 2026 09:33:15 +0200 Subject: [PATCH 12/18] feat: session tracking --- .../react-native-detour/src/DetourContext.tsx | 5 +++ .../src/analytics/api/events.ts | 3 ++ .../src/analytics/api/retention.ts | 3 ++ .../analytics/hooks/useSessionTracking.tsx | 41 +++++++++++++++++++ 4 files changed, 52 insertions(+) create mode 100644 packages/react-native-detour/src/analytics/hooks/useSessionTracking.tsx diff --git a/packages/react-native-detour/src/DetourContext.tsx b/packages/react-native-detour/src/DetourContext.tsx index c326b33..ceefb97 100644 --- a/packages/react-native-detour/src/DetourContext.tsx +++ b/packages/react-native-detour/src/DetourContext.tsx @@ -7,6 +7,7 @@ import * as Localization from "expo-localization"; import { sendEvent } from "./analytics/api/events"; import { sendRetentionEvent } from "./analytics/api/retention"; import { useAppOpenRetention } from "./analytics/hooks/useAppOpenRetention"; +import { getSessionId, useSessionTracking } from "./analytics/hooks/useSessionTracking"; import type { DetourEvent, DetourEventNames } from "./analytics/types"; import { analyticsEmitter } from "./analytics/utils/analyticsEmitter"; import { getAppVersion, getBuildNumber } from "./analytics/utils/appInfo"; @@ -79,6 +80,7 @@ const DetourProviderNative = ({ config, children }: Props) => { const consent = getConsent(); const osVersion = getSafeOsVersion(); const locale = Localization.getLocales().map((l) => l.languageTag); + const sessionId = getSessionId(); if (isRetention) { sendRetentionEvent({ @@ -96,6 +98,7 @@ const DetourProviderNative = ({ config, children }: Props) => { osVersion, locale, attStatus, + sessionId, }); } else { const event: DetourEvent = { @@ -117,6 +120,7 @@ const DetourProviderNative = ({ config, children }: Props) => { osVersion, locale, attStatus, + sessionId, conversion, }); } @@ -143,6 +147,7 @@ const DetourProviderNative = ({ config, children }: Props) => { linkProcessingMode, }); useAppOpenRetention(); + useSessionTracking(); return {children}; }; diff --git a/packages/react-native-detour/src/analytics/api/events.ts b/packages/react-native-detour/src/analytics/api/events.ts index bc3ef6a..3100367 100644 --- a/packages/react-native-detour/src/analytics/api/events.ts +++ b/packages/react-native-detour/src/analytics/api/events.ts @@ -22,6 +22,7 @@ export const sendEvent = async ({ osVersion, locale, attStatus, + sessionId, conversion, }: { apiKey: string; @@ -38,6 +39,7 @@ export const sendEvent = async ({ osVersion?: string; locale?: string[]; attStatus?: AttStatus; + sessionId?: string; conversion?: Conversion; }) => { try { @@ -65,6 +67,7 @@ export const sendEvent = async ({ os_version: osVersion, locale, att_status: attStatus, + session_id: sessionId, revenue: conversion?.revenue, currency: conversion?.currency, product_id: conversion?.productId, diff --git a/packages/react-native-detour/src/analytics/api/retention.ts b/packages/react-native-detour/src/analytics/api/retention.ts index 53be0ce..4f536a1 100644 --- a/packages/react-native-detour/src/analytics/api/retention.ts +++ b/packages/react-native-detour/src/analytics/api/retention.ts @@ -21,6 +21,7 @@ export const sendRetentionEvent = async ({ osVersion, locale, attStatus, + sessionId, }: { apiKey: string; appID: string; @@ -36,6 +37,7 @@ export const sendRetentionEvent = async ({ osVersion?: string; locale?: string[]; attStatus?: AttStatus; + sessionId?: string; }) => { try { const response = await fetch(RETENTION_API_URL, { @@ -61,6 +63,7 @@ export const sendRetentionEvent = async ({ os_version: osVersion, locale, att_status: attStatus, + session_id: sessionId, }), }); diff --git a/packages/react-native-detour/src/analytics/hooks/useSessionTracking.tsx b/packages/react-native-detour/src/analytics/hooks/useSessionTracking.tsx new file mode 100644 index 0000000..d08b6bd --- /dev/null +++ b/packages/react-native-detour/src/analytics/hooks/useSessionTracking.tsx @@ -0,0 +1,41 @@ +import { useEffect } from "react"; + +import { AppState, type AppStateStatus } from "react-native"; + +import * as Crypto from "expo-crypto"; + +// A session groups a contiguous stretch of activity — the basis for funnels, +// time-in-session and LTV. In-memory only (same convention as userIdentity.ts): +// a cold start is inherently a new session, so there's nothing to persist. +let currentSessionId: string = Crypto.randomUUID(); +let backgroundedAt: number | null = null; + +// 30 min of background before a re-open counts as a fresh session — the +// industry-standard timeout (Google Analytics, Adjust). Shorter trips back to +// the OS (Control Center, a notification, an ATT prompt) keep the same session. +const SESSION_TIMEOUT_MS = 30 * 60 * 1000; + +const handleAppStateChange = (nextState: AppStateStatus) => { + if (nextState === "background" || nextState === "inactive") { + backgroundedAt = Date.now(); + return; + } + + if (nextState === "active" && backgroundedAt !== null) { + if (Date.now() - backgroundedAt > SESSION_TIMEOUT_MS) { + currentSessionId = Crypto.randomUUID(); + } + backgroundedAt = null; + } +}; + +export const useSessionTracking = () => { + useEffect(() => { + const subscription = AppState.addEventListener("change", handleAppStateChange); + return () => { + subscription.remove(); + }; + }, []); +}; + +export const getSessionId = (): string => currentSessionId; From b7e4db07235c3cb606ba2c4ca47f37fb5ad4d9d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Turba=C5=84ski?= Date: Fri, 17 Jul 2026 11:17:09 +0200 Subject: [PATCH 13/18] fix: remove crypto --- packages/react-native-detour/package.json | 2 -- .../analytics/hooks/useSessionTracking.tsx | 6 ++--- .../src/analytics/utils/devicePersistence.ts | 8 +------ .../src/analytics/utils/uuid.ts | 11 ++++++++++ pnpm-lock.yaml | 22 +++++-------------- 5 files changed, 20 insertions(+), 29 deletions(-) create mode 100644 packages/react-native-detour/src/analytics/utils/uuid.ts diff --git a/packages/react-native-detour/package.json b/packages/react-native-detour/package.json index 617a42b..f9af06d 100644 --- a/packages/react-native-detour/package.json +++ b/packages/react-native-detour/package.json @@ -76,7 +76,6 @@ "expo-application": "*", "expo-clipboard": "*", "expo-constants": "~55.0.11", - "expo-crypto": "^57.0.0", "expo-device": "~55.0.12", "expo-localization": "*", "expo-tracking-transparency": "^57.0.0", @@ -92,7 +91,6 @@ "expo-application": ">=6.1.5", "expo-clipboard": ">=6.0.0", "expo-constants": ">=17.1.7", - "expo-crypto": ">=14.0.0", "expo-device": ">=7.0.0", "expo-localization": ">=15.0.0", "expo-tracking-transparency": ">=5.0.0", diff --git a/packages/react-native-detour/src/analytics/hooks/useSessionTracking.tsx b/packages/react-native-detour/src/analytics/hooks/useSessionTracking.tsx index d08b6bd..f99843f 100644 --- a/packages/react-native-detour/src/analytics/hooks/useSessionTracking.tsx +++ b/packages/react-native-detour/src/analytics/hooks/useSessionTracking.tsx @@ -2,12 +2,12 @@ import { useEffect } from "react"; import { AppState, type AppStateStatus } from "react-native"; -import * as Crypto from "expo-crypto"; +import { generateUUID } from "../utils/uuid"; // A session groups a contiguous stretch of activity — the basis for funnels, // time-in-session and LTV. In-memory only (same convention as userIdentity.ts): // a cold start is inherently a new session, so there's nothing to persist. -let currentSessionId: string = Crypto.randomUUID(); +let currentSessionId: string = generateUUID(); let backgroundedAt: number | null = null; // 30 min of background before a re-open counts as a fresh session — the @@ -23,7 +23,7 @@ const handleAppStateChange = (nextState: AppStateStatus) => { if (nextState === "active" && backgroundedAt !== null) { if (Date.now() - backgroundedAt > SESSION_TIMEOUT_MS) { - currentSessionId = Crypto.randomUUID(); + currentSessionId = generateUUID(); } backgroundedAt = null; } diff --git a/packages/react-native-detour/src/analytics/utils/devicePersistence.ts b/packages/react-native-detour/src/analytics/utils/devicePersistence.ts index 92be51d..d879606 100644 --- a/packages/react-native-detour/src/analytics/utils/devicePersistence.ts +++ b/packages/react-native-detour/src/analytics/utils/devicePersistence.ts @@ -1,12 +1,6 @@ -import * as Crypto from "expo-crypto"; - import type { DetourStorage } from "../../links/types"; import { StorageKeys } from "../../links/utils/storage"; - -// Math.random() is not cryptographically secure and risks collisions across -// installs at scale — this is the install_id anchor that ties app_installs, -// events and clicks together, so it needs a real CSPRNG. -const generateUUID = () => Crypto.randomUUID(); +import { generateUUID } from "./uuid"; const saveDeviceId = async (storage: DetourStorage, id: string) => { await storage.setItem(StorageKeys.DEVICE_ID_KEY, id); diff --git a/packages/react-native-detour/src/analytics/utils/uuid.ts b/packages/react-native-detour/src/analytics/utils/uuid.ts new file mode 100644 index 0000000..26d7606 --- /dev/null +++ b/packages/react-native-detour/src/analytics/utils/uuid.ts @@ -0,0 +1,11 @@ +// Math.random()-based RFC4122 v4 UUID. Sufficient +// for both consumers: device_id is compared across a huge population, but even +// non-cryptographic 122 bits of randomness makes a collision practically +// impossible; session_id never leaves a single device, so collision risk is a +// non-issue there. +export const generateUUID = (): string => + "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (char) => { + const random = Math.floor(Math.random() * 16); + if (char === "x") return random.toString(16); + return (8 + (random % 4)).toString(16); + }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5e9832a..0e285f8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -111,8 +111,8 @@ importers: specifier: ~55.0.15 version: 55.0.15(expo@55.0.11)(typescript@5.9.3) expo-tracking-transparency: - specifier: ~5.2.4 - version: 5.2.4(expo@55.0.11)(react-native@0.83.4(@babel/core@7.29.0)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@react-native/metro-config@0.83.4(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0)) + specifier: ~55.0.11 + version: 55.0.16(expo@55.0.11)(react-native@0.83.4(@babel/core@7.29.0)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@react-native/metro-config@0.83.4(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0)) react: specifier: 19.2.0 version: 19.2.0 @@ -525,9 +525,6 @@ importers: expo-constants: specifier: ~55.0.11 version: 55.0.11(expo@55.0.11)(react-native@0.83.4(@babel/core@7.29.0)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@react-native/metro-config@0.83.4(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(typescript@5.9.3) - expo-crypto: - specifier: ^57.0.0 - version: 57.0.0(expo@55.0.11) expo-device: specifier: ~55.0.12 version: 55.0.12(expo@55.0.11) @@ -3632,11 +3629,6 @@ packages: peerDependencies: expo: '*' - expo-crypto@57.0.0: - resolution: {integrity: sha512-vd0kdUO14h9CgPcgzcR8nmy/wgz3zSOhQmucnbDdyn/z9eAeR2IB5BKaDvPbg/lrIT+KweGAV5IlrK5PZFqUSQ==} - peerDependencies: - expo: '*' - expo-dev-client@55.0.22: resolution: {integrity: sha512-3AB4n28A07uWsUlu7mOxr76V/jjEpjc5TSlmR37u/qdaX4qwjTy+2GDZWCZumY8dSgy/hKgn1CThnKoVnIM5KA==} peerDependencies: @@ -3791,8 +3783,8 @@ packages: react-native-web: optional: true - expo-tracking-transparency@5.2.4: - resolution: {integrity: sha512-Evog1rmbSLrODuG8AukEoV0y1RzQiJelX95rFuXY3kxHZQbiBOQ7YzAOsuYACztDf4XDoSODStdHSgUkgKNxJA==} + expo-tracking-transparency@55.0.16: + resolution: {integrity: sha512-yCNl9GZXMdp7Ayqh4dX8UlnDDTDny4xmVKtzZJEJeqtyYzQlJfWbzqHDj+gL/VXEfB5DkHmBBuNuJGFck0cfBw==} peerDependencies: expo: '*' react-native: '*' @@ -10805,10 +10797,6 @@ snapshots: base64-js: 1.5.1 expo: 55.0.11(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.9)(expo-router@55.0.10)(react-dom@19.2.0(react@19.2.0))(react-native@0.83.4(@babel/core@7.29.0)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@react-native/metro-config@0.83.4(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3) - expo-crypto@57.0.0(expo@55.0.11): - dependencies: - expo: 55.0.11(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.9)(expo-router@55.0.10)(react-dom@19.2.0(react@19.2.0))(react-native@0.83.4(@babel/core@7.29.0)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@react-native/metro-config@0.83.4(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3) - expo-dev-client@55.0.22(expo@55.0.11)(typescript@5.9.3): dependencies: expo: 55.0.11(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.9)(expo-router@55.0.10)(react-dom@19.2.0(react@19.2.0))(react-native@0.83.4(@babel/core@7.29.0)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@react-native/metro-config@0.83.4(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3) @@ -10999,7 +10987,7 @@ snapshots: transitivePeerDependencies: - supports-color - expo-tracking-transparency@5.2.4(expo@55.0.11)(react-native@0.83.4(@babel/core@7.29.0)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@react-native/metro-config@0.83.4(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0)): + expo-tracking-transparency@55.0.16(expo@55.0.11)(react-native@0.83.4(@babel/core@7.29.0)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@react-native/metro-config@0.83.4(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0)): dependencies: expo: 55.0.11(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.9)(expo-router@55.0.10)(react-dom@19.2.0(react@19.2.0))(react-native@0.83.4(@babel/core@7.29.0)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@react-native/metro-config@0.83.4(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3) react-native: 0.83.4(@babel/core@7.29.0)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@react-native/metro-config@0.83.4(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0) From 36bae96e395535d82872c3b1ca5a7610d53aea09 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Turba=C5=84ski?= Date: Fri, 17 Jul 2026 12:09:50 +0200 Subject: [PATCH 14/18] fix: removing expo-tracking-transparency --- packages/react-native-detour/package.json | 5 ----- pnpm-lock.yaml | 14 -------------- 2 files changed, 19 deletions(-) diff --git a/packages/react-native-detour/package.json b/packages/react-native-detour/package.json index f9af06d..71b346a 100644 --- a/packages/react-native-detour/package.json +++ b/packages/react-native-detour/package.json @@ -78,7 +78,6 @@ "expo-constants": "~55.0.11", "expo-device": "~55.0.12", "expo-localization": "*", - "expo-tracking-transparency": "^57.0.0", "jest": "^29.7.0", "react": "19.2.0", "react-native": "0.83.4", @@ -93,7 +92,6 @@ "expo-constants": ">=17.1.7", "expo-device": ">=7.0.0", "expo-localization": ">=15.0.0", - "expo-tracking-transparency": ">=5.0.0", "react": ">=18", "react-native": ">=0.72", "react-native-device-info": ">=10.0.0" @@ -105,9 +103,6 @@ "expo-device": { "optional": true }, - "expo-tracking-transparency": { - "optional": true - }, "react-native-device-info": { "optional": true } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0e285f8..9ce0dcb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -531,9 +531,6 @@ importers: expo-localization: specifier: '*' version: 16.1.6(expo@55.0.11)(react@19.2.0) - expo-tracking-transparency: - specifier: ^57.0.0 - version: 57.0.0(expo@55.0.11)(react-native@0.83.4(@babel/core@7.29.0)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@react-native/metro-config@0.83.4(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0)) jest: specifier: ^29.7.0 version: 29.7.0(@types/node@25.5.1) @@ -3789,12 +3786,6 @@ packages: expo: '*' react-native: '*' - expo-tracking-transparency@57.0.0: - resolution: {integrity: sha512-dJNtvoeLRLT20s6bBSjUfQqk+krSetUR6zVyY4g4IXsMj+6xXkGygCpF4NzbJKxJaK0vNeZji6Lw9YzN4/cfCg==} - peerDependencies: - expo: '*' - react-native: '*' - expo-updates-interface@55.1.5: resolution: {integrity: sha512-YOk9vhplWi0djoeqxMlEQgcDFeOGhnj4dWU0v1QvF5RqpqwLGdx780E0k3zL85xw6LXljVN78d6g8z51qIZu5g==} peerDependencies: @@ -10992,11 +10983,6 @@ snapshots: expo: 55.0.11(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.9)(expo-router@55.0.10)(react-dom@19.2.0(react@19.2.0))(react-native@0.83.4(@babel/core@7.29.0)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@react-native/metro-config@0.83.4(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3) react-native: 0.83.4(@babel/core@7.29.0)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@react-native/metro-config@0.83.4(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0) - expo-tracking-transparency@57.0.0(expo@55.0.11)(react-native@0.83.4(@babel/core@7.29.0)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@react-native/metro-config@0.83.4(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0)): - dependencies: - expo: 55.0.11(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.9)(expo-router@55.0.10)(react-dom@19.2.0(react@19.2.0))(react-native@0.83.4(@babel/core@7.29.0)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@react-native/metro-config@0.83.4(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3) - react-native: 0.83.4(@babel/core@7.29.0)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@react-native/metro-config@0.83.4(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0) - expo-updates-interface@55.1.5(expo@55.0.11): dependencies: expo: 55.0.11(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.9)(expo-router@55.0.10)(react-dom@19.2.0(react@19.2.0))(react-native@0.83.4(@babel/core@7.29.0)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@react-native/metro-config@0.83.4(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3) From 5aaf3e38b0ef1a36ce2c2b6d991082b3e01eef63 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Turba=C5=84ski?= Date: Fri, 17 Jul 2026 12:35:21 +0200 Subject: [PATCH 15/18] feat: update testing app --- examples/expo-bare/src/Screen.tsx | 47 ++++++++++++++++++++++++++++++- 1 file changed, 46 insertions(+), 1 deletion(-) diff --git a/examples/expo-bare/src/Screen.tsx b/examples/expo-bare/src/Screen.tsx index 67f2ddf..cb95289 100644 --- a/examples/expo-bare/src/Screen.tsx +++ b/examples/expo-bare/src/Screen.tsx @@ -86,7 +86,8 @@ export const Screen = () => { Test Actions Fire these on demand, then inspect the request body in the RN DevTools Network tab to - confirm idfv/aaid/idfa/install_id/customer_user_id are attached. + confirm idfv/aaid/idfa/install_id/customer_user_id/att_status/consent/session_id/ + app_version/build_number/os_version/locale/revenue fields are attached. DetourAnalytics.setUserId("test-user-123")}> @@ -98,6 +99,50 @@ export const Screen = () => { > Log test event (purchase) + + + DetourAnalytics.logConversion({ + revenue: 9.99, + currency: "USD", + productId: "test_sku_1", + quantity: 1, + transactionId: "test-txn-001", + }) + } + > + Log test conversion (revenue) + + + + DetourAnalytics.setConsent({ ad: true, analytics: true, tracking: true }) + } + > + Set consent: all granted + + + + DetourAnalytics.setConsent({ ad: false, analytics: false, tracking: false }) + } + > + Set consent: all denied + + + DetourAnalytics.setAdvertisingId("11111111-2222-3333-4444-555555555555")} + > + Override advertising id (manual) + + + DetourAnalytics.setTrackingAuthorizationStatus("granted")}> + Override ATT status: granted + + + DetourAnalytics.setTrackingAuthorizationStatus("denied")}> + Override ATT status: denied + From 43aaf5856e546ed1731b0b3c3b942046290c0c75 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Turba=C5=84ski?= Date: Thu, 23 Jul 2026 17:20:42 +0200 Subject: [PATCH 16/18] fix: refactor after review --- examples/expo-bare/package.json | 1 - examples/expo-bare/src/Screen.tsx | 47 +------- package.json | 1 - packages/react-native-detour/FIELDS.md | 82 ++++++++++++++ .../react-native-detour/src/DetourContext.tsx | 104 +++--------------- .../src/analytics/analytics.ts | 12 +- .../src/analytics/api/events.ts | 51 +-------- .../src/analytics/api/retention.ts | 49 +-------- .../analytics/hooks/useSessionTracking.tsx | 11 +- .../src/analytics/types/index.ts | 21 +++- .../src/analytics/utils/analyticsEmitter.ts | 25 +---- .../analytics/utils/buildAnalyticsContext.ts | 65 +++++++++++ .../src/analytics/utils/consent.ts | 23 ---- .../analytics/utils/dispatchAnalyticsEvent.ts | 34 ++++++ .../src/analytics/utils/userIdentity.ts | 12 -- .../src/analytics/utils/uuid.ts | 11 -- .../src/links/api/getDeferredLink.ts | 32 ++---- .../src/links/api/sendUniversalLinkClick.ts | 7 +- .../src/links/types/index.ts | 8 +- .../src/links/utils/appEntrance.ts | 2 +- .../src/links/utils/fingerprint.ts | 32 ++---- .../src/links/utils/urlHelpers.ts | 13 +++ .../{analytics/utils => shared}/appInfo.ts | 2 - .../react-native-detour/src/shared/consent.ts | 23 ++++ .../utils => shared}/deviceIdentifiers.ts | 77 ++++++------- .../src/{links/utils => shared}/deviceInfo.ts | 3 - .../utils => shared}/devicePersistence.ts | 4 +- .../src/{links/utils => shared}/storage.ts | 6 +- .../src/shared/userIdentity.ts | 7 ++ .../react-native-detour/src/shared/uuid.ts | 6 + pnpm-lock.yaml | 14 --- 31 files changed, 363 insertions(+), 422 deletions(-) create mode 100644 packages/react-native-detour/FIELDS.md create mode 100644 packages/react-native-detour/src/analytics/utils/buildAnalyticsContext.ts delete mode 100644 packages/react-native-detour/src/analytics/utils/consent.ts create mode 100644 packages/react-native-detour/src/analytics/utils/dispatchAnalyticsEvent.ts delete mode 100644 packages/react-native-detour/src/analytics/utils/userIdentity.ts delete mode 100644 packages/react-native-detour/src/analytics/utils/uuid.ts rename packages/react-native-detour/src/{analytics/utils => shared}/appInfo.ts (62%) create mode 100644 packages/react-native-detour/src/shared/consent.ts rename packages/react-native-detour/src/{links/utils => shared}/deviceIdentifiers.ts (61%) rename packages/react-native-detour/src/{links/utils => shared}/deviceInfo.ts (92%) rename packages/react-native-detour/src/{analytics/utils => shared}/devicePersistence.ts (90%) rename packages/react-native-detour/src/{links/utils => shared}/storage.ts (81%) create mode 100644 packages/react-native-detour/src/shared/userIdentity.ts create mode 100644 packages/react-native-detour/src/shared/uuid.ts diff --git a/examples/expo-bare/package.json b/examples/expo-bare/package.json index 8815d0b..1ccce5a 100644 --- a/examples/expo-bare/package.json +++ b/examples/expo-bare/package.json @@ -17,7 +17,6 @@ "expo-application": "*", "expo-clipboard": "*", "expo-constants": "~55.0.11", - "expo-crypto": "~14.1.5", "expo-dev-client": "~55.0.22", "expo-device": "~55.0.12", "expo-localization": "*", diff --git a/examples/expo-bare/src/Screen.tsx b/examples/expo-bare/src/Screen.tsx index cb95289..67f2ddf 100644 --- a/examples/expo-bare/src/Screen.tsx +++ b/examples/expo-bare/src/Screen.tsx @@ -86,8 +86,7 @@ export const Screen = () => { Test Actions Fire these on demand, then inspect the request body in the RN DevTools Network tab to - confirm idfv/aaid/idfa/install_id/customer_user_id/att_status/consent/session_id/ - app_version/build_number/os_version/locale/revenue fields are attached. + confirm idfv/aaid/idfa/install_id/customer_user_id are attached. DetourAnalytics.setUserId("test-user-123")}> @@ -99,50 +98,6 @@ export const Screen = () => { > Log test event (purchase) - - - DetourAnalytics.logConversion({ - revenue: 9.99, - currency: "USD", - productId: "test_sku_1", - quantity: 1, - transactionId: "test-txn-001", - }) - } - > - Log test conversion (revenue) - - - - DetourAnalytics.setConsent({ ad: true, analytics: true, tracking: true }) - } - > - Set consent: all granted - - - - DetourAnalytics.setConsent({ ad: false, analytics: false, tracking: false }) - } - > - Set consent: all denied - - - DetourAnalytics.setAdvertisingId("11111111-2222-3333-4444-555555555555")} - > - Override advertising id (manual) - - - DetourAnalytics.setTrackingAuthorizationStatus("granted")}> - Override ATT status: granted - - - DetourAnalytics.setTrackingAuthorizationStatus("denied")}> - Override ATT status: denied - diff --git a/package.json b/package.json index 8ecf904..088c4a6 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,6 @@ }, "pnpm": { "overrides": { - "@react-native/babel-preset": "0.83.4", "metro": "0.83.5", "metro-babel-transformer": "0.83.5", "metro-cache": "0.83.5", diff --git a/packages/react-native-detour/FIELDS.md b/packages/react-native-detour/FIELDS.md new file mode 100644 index 0000000..0c79e78 --- /dev/null +++ b/packages/react-native-detour/FIELDS.md @@ -0,0 +1,82 @@ +# Collected fields + +This document lists every field the SDK collects and sends, why it exists, and how the pieces fit +together. It's meant as a reference for anyone reviewing or extending the SDK's data collection — +not user-facing documentation (see the main [README](./README.md) for that). + +## The process, in short + +The SDK talks to three backend endpoints, each covering a different moment in the app's lifecycle: + +1. **`/api/link/match-link`** — called once per install, from `getDeferredLink.ts`. Tries to match + the current device to a click that happened before install (deferred deep linking). Sends a + `DeterministicFingerprint` (exact click ID from the Android install referrer) or, if that isn't + available, a `ProbabilisticFingerprint` (device/locale/timezone/clipboard signals used for a + best-effort match). +2. **`/api/link/universal-link-click`** — called from `sendUniversalLinkClick.ts` whenever the app + is opened via a Universal/App Link at runtime (not a deferred install). +3. **`/api/analytics/event`** and **`/api/analytics/retention`** — called from `events.ts` / + `retention.ts` whenever the host calls `DetourAnalytics.logEvent` / `logRetention` / + `logConversion`, or when the SDK's own `useAppOpenRetention`/`useSessionTracking` hooks fire. + +All three share the same **identity signals** (`device_id`/`install_id`, `idfv`, `aaid`, `idfa`, +`customer_user_id`) — that's what lets the backend stitch together "the click that led to this +install" and "the events this install later produced" into one continuous record, instead of +seeing them as unrelated facts. See `shared/` for where each signal is collected, and +`analytics/utils/buildAnalyticsContext.ts` / `links/utils/fingerprint.ts#collectIdentityFields` for +where they get assembled into a request. + +## Identity fields (shared across all three endpoints) + +| Field | Source | Why | +|---|---|---| +| `device_id` (events/retention) / `install_id` (fingerprint) | `shared/devicePersistence.ts` — random UUID, generated once, persisted to storage | Anchors every event from one installation together before (or absent) a logged-in user. Survives app restarts, not reinstalls. | +| `idfv` | `shared/deviceIdentifiers.ts` (`Application.getIosIdForVendorAsync`) | iOS per-vendor ID. Available without ATT consent — the most reliable iOS identity signal pre-login. | +| `aaid` | `shared/deviceIdentifiers.ts` (`expo-tracking-transparency`'s `getAdvertisingId`, Android) | Android advertising ID. Feeds the backend's deterministic `device_uuid` for cross-MMP migration matching. | +| `idfa` | Same native call, iOS | Ad-attribution signal only — doesn't feed `device_uuid` (IDFV does). Empty unless ATT is granted. | +| `customer_user_id` | `shared/userIdentity.ts`, set via `DetourAnalytics.setUserId()` | The one identifier that survives reinstalls and is shared across a user's devices — the root key for merging anonymous and identified activity. | + +## Event / retention payload (`analytics/types/index.ts#AnalyticsContext`) + +**Wire shape:** `event_name`, `data` (events only), `timestamp`, `platform`, `device_id` stay +top-level — that's exactly what the backend's current `/api/analytics/event` and +`/api/analytics/retention` endpoints already read and store today. Everything below is new and not +yet persisted server-side (see "Not yet stored server-side" below) — it's grouped under one +`metadata` object (`buildAnalyticsContext.ts#toMetadataFields`/`toConversionFields`) instead of +more top-level keys, so the existing endpoint stays backward-compatible (it already ignores unknown +body fields) and adding backend support later means parsing one object, not hunting down +individually-added fields. + +| Field (inside `metadata`) | Source | Why | +|---|---|---| +| `idfv` / `aaid` / `idfa` / `customer_user_id` | See identity fields above | Same identity signals as the fingerprint payload. | +| `app_version` / `build_number` | `shared/appInfo.ts` (`expo-application`) | Groups events by release; `build_number` distinguishes builds within the same marketing version. | +| `os_version` | `shared/deviceInfo.ts` | Basic diagnostic/segmentation context. | +| `locale` | `expo-localization`, collected in `buildAnalyticsContext.ts` | Segmentation without relying on IP-based guesses. | +| `att_status` | `shared/deviceIdentifiers.ts` | Explicit ATT state (`granted`/`denied`/`undetermined`/`unavailable`) — otherwise indistinguishable from "not asked yet" if inferred only from a missing `idfa`. | +| `consent` | `shared/consent.ts` (`{ ad, analytics, tracking, source, updatedAt }`) | Audit trail of what the user had consented to when the event was logged. Auto-derived from ATT (iOS) / AAID opt-out (Android) unless the host calls `setConsent()` directly, which always wins (see `applyAutoConsent`'s manual-source guard). | +| `session_id` | `analytics/hooks/useSessionTracking.tsx` | Groups a contiguous stretch of activity (funnels, time-in-session). Rotates after 30 min in background. | +| `revenue` / `currency` / `product_id` / `quantity` / `transaction_id` | Host-supplied via `DetourAnalytics.logConversion()` (events only) | Revenue as first-class fields (not buried in `data`) so the backend can aggregate ROAS without per-host parsing conventions. | + +## Fingerprint payload (`links/utils/fingerprint.ts`) + +Deterministic (Android install referrer present): identity fields + `clickId` + `utm`. + +Probabilistic (fallback): identity fields + `platform`, `model`, `manufacturer`, `systemVersion`, +`screenWidth`/`screenHeight`/`scale`, `locale`, `timezone`, `userAgent`, `timestamp`, `pastedLink` +(iOS clipboard, only when `shouldUseClipboard` is on), `utm`. + +`utm` comes from parsing the Android install referrer (`links/utils/urlHelpers.ts#parseUtmParams`) +— campaign attribution for installs that arrive with UTM-tagged links. + +## Universal Link click payload (`links/api/sendUniversalLinkClick.ts`) + +`url`, `timestamp`, `platform`, `params` (query params extracted from the clicked URL), and +`metadata: { os_version, app_version, device_model }` — enough context to rate-limit and debug +click volume without duplicating the full identity/analytics payload for a runtime link open. + +## What's collected but never used to gate anything + +Nothing gates on `consent` today — it's recorded as a field on every request, not used to skip +collection. See the open discussion in the PR about whether `ad`/`tracking: false` should stop +AAID/IDFA collection specifically (deferred, pending team discussion). diff --git a/packages/react-native-detour/src/DetourContext.tsx b/packages/react-native-detour/src/DetourContext.tsx index ceefb97..e91897b 100644 --- a/packages/react-native-detour/src/DetourContext.tsx +++ b/packages/react-native-detour/src/DetourContext.tsx @@ -2,26 +2,14 @@ import { type PropsWithChildren, createContext, useContext, useEffect } from "re import { Platform } from "react-native"; -import * as Localization from "expo-localization"; - -import { sendEvent } from "./analytics/api/events"; -import { sendRetentionEvent } from "./analytics/api/retention"; import { useAppOpenRetention } from "./analytics/hooks/useAppOpenRetention"; -import { getSessionId, useSessionTracking } from "./analytics/hooks/useSessionTracking"; -import type { DetourEvent, DetourEventNames } from "./analytics/types"; +import { useSessionTracking } from "./analytics/hooks/useSessionTracking"; import { analyticsEmitter } from "./analytics/utils/analyticsEmitter"; -import { getAppVersion, getBuildNumber } from "./analytics/utils/appInfo"; -import { getConsent } from "./analytics/utils/consent"; -import { prepareDeviceIdForApi } from "./analytics/utils/devicePersistence"; -import { getUserId } from "./analytics/utils/userIdentity"; +import { dispatchAnalyticsEvent } from "./analytics/utils/dispatchAnalyticsEvent"; import { useDetour } from "./links/hooks/useDetour"; import type { Config, DetourContextType } from "./links/types"; -import { - collectDeviceIdentitySignals, - requestTrackingPermission, -} from "./links/utils/deviceIdentifiers"; -import { getSafeOsVersion } from "./links/utils/deviceInfo"; -import { resolveStorage } from "./links/utils/storage"; +import { requestTrackingPermission } from "./shared/deviceIdentifiers"; +import { resolveStorage } from "./shared/storage"; type Props = PropsWithChildren & { config: Config }; @@ -56,82 +44,20 @@ const DetourProviderNative = ({ config, children }: Props) => { useEffect(() => { activeProviderCount++; - const unsubscribe = analyticsEmitter.subscribe( - async ({ eventName, data, isRetention, conversion }) => { - if (activeProviderCount > 1) { - if (__DEV__) { - console.error( - `🔗[Detour:ANALYTICS_ERROR] Event "${eventName}" dropped. ` + - `Multiple DetourProviders (${activeProviderCount}) detected. ` + - "Analytics logging is disabled until only one provider remains.", - ); - } - return; - } - - try { - const [deviceId, { idfv, aaid, idfa, attStatus }] = await Promise.all([ - prepareDeviceIdForApi(storage), - collectDeviceIdentitySignals(), - ]); - const customerUserId = getUserId(); - const appVersion = getAppVersion(); - const buildNumber = getBuildNumber(); - const consent = getConsent(); - const osVersion = getSafeOsVersion(); - const locale = Localization.getLocales().map((l) => l.languageTag); - const sessionId = getSessionId(); - - if (isRetention) { - sendRetentionEvent({ - apiKey, - appID, - eventName, - deviceId, - idfv, - aaid, - idfa, - customerUserId, - appVersion, - buildNumber, - consent, - osVersion, - locale, - attStatus, - sessionId, - }); - } else { - const event: DetourEvent = { - eventName: eventName as DetourEventNames, - data, - }; - sendEvent({ - apiKey, - appID, - event, - deviceId, - idfv, - aaid, - idfa, - customerUserId, - appVersion, - buildNumber, - consent, - osVersion, - locale, - attStatus, - sessionId, - conversion, - }); - } - } catch (error) { + const unsubscribe = analyticsEmitter.subscribe((payload) => { + if (activeProviderCount > 1) { + if (__DEV__) { console.error( - "[Detour:ANALYTICS_ERROR] Analytics disabled due to storage/runtime failure:", - error, + `🔗[Detour:ANALYTICS_ERROR] Event "${payload.eventName}" dropped. ` + + `Multiple DetourProviders (${activeProviderCount}) detected. ` + + "Analytics logging is disabled until only one provider remains.", ); } - }, - ); + return; + } + + dispatchAnalyticsEvent(payload, { apiKey, appID, storage }); + }); return () => { activeProviderCount--; diff --git a/packages/react-native-detour/src/analytics/analytics.ts b/packages/react-native-detour/src/analytics/analytics.ts index 040ca0b..82540a8 100644 --- a/packages/react-native-detour/src/analytics/analytics.ts +++ b/packages/react-native-detour/src/analytics/analytics.ts @@ -1,9 +1,9 @@ -import { setAdvertisingId, setTrackingAuthorizationStatus } from "../links/utils/deviceIdentifiers"; +import { setConsent } from "../shared/consent"; +import { setAdvertisingId, setTrackingAuthorizationStatus } from "../shared/deviceIdentifiers"; +import { setUserId } from "../shared/userIdentity"; import { DetourEventNames } from "./types"; import type { Conversion } from "./types"; import { analyticsEmitter } from "./utils/analyticsEmitter"; -import { setConsent } from "./utils/consent"; -import { setUserId } from "./utils/userIdentity"; export const logEvent = (eventName: DetourEventNames | `${DetourEventNames}`, data?: any) => { analyticsEmitter.emit({ eventName, data }); @@ -17,10 +17,8 @@ export type ConversionParams = Conversion & { eventName?: DetourEventNames | `${DetourEventNames}`; }; -// Revenue reporting inherently needs the host to call in — the SDK has no -// signal for transaction amount. Defaults to Purchase since that's the -// overwhelming majority case; still rides the existing event endpoint (see -// events.ts), just with revenue/currency guaranteed as top-level fields. +// The SDK has no signal for transaction amount, so revenue reporting always +// needs an explicit host call. Rides the existing event endpoint (events.ts). export const logConversion = ({ eventName = DetourEventNames.Purchase, ...conversion diff --git a/packages/react-native-detour/src/analytics/api/events.ts b/packages/react-native-detour/src/analytics/api/events.ts index 3100367..34a7fbd 100644 --- a/packages/react-native-detour/src/analytics/api/events.ts +++ b/packages/react-native-detour/src/analytics/api/events.ts @@ -1,47 +1,23 @@ import { Platform } from "react-native"; -import type { AttStatus } from "../../links/utils/deviceIdentifiers"; import { SDK_HEADER_VALUE } from "../../version"; -import type { Conversion, DetourEvent } from "../types"; -import type { Consent } from "../utils/consent"; +import type { AnalyticsContext, Conversion, DetourEvent } from "../types"; +import { toConversionFields, toMetadataFields } from "../utils/buildAnalyticsContext"; const EVENT_API_URL = "https://godetour.dev/api/analytics/event"; export const sendEvent = async ({ apiKey, appID, - deviceId, event, - idfv, - aaid, - idfa, - customerUserId, - appVersion, - buildNumber, - consent, - osVersion, - locale, - attStatus, - sessionId, conversion, + ...ctx }: { apiKey: string; appID: string; event: DetourEvent; - deviceId: string; - idfv?: string; - aaid?: string; - idfa?: string; - customerUserId?: string; - appVersion?: string; - buildNumber?: string; - consent?: Consent; - osVersion?: string; - locale?: string[]; - attStatus?: AttStatus; - sessionId?: string; conversion?: Conversion; -}) => { +} & AnalyticsContext) => { try { const response = await fetch(EVENT_API_URL, { method: "POST", @@ -56,23 +32,8 @@ export const sendEvent = async ({ data: event.data, timestamp: new Date().toISOString(), platform: Platform.OS, - device_id: deviceId, - idfv, - aaid, - idfa, - customer_user_id: customerUserId, - app_version: appVersion, - build_number: buildNumber, - consent, - os_version: osVersion, - locale, - att_status: attStatus, - session_id: sessionId, - revenue: conversion?.revenue, - currency: conversion?.currency, - product_id: conversion?.productId, - quantity: conversion?.quantity, - transaction_id: conversion?.transactionId, + device_id: ctx.deviceId, + metadata: { ...toMetadataFields(ctx), ...toConversionFields(conversion) }, }), }); diff --git a/packages/react-native-detour/src/analytics/api/retention.ts b/packages/react-native-detour/src/analytics/api/retention.ts index 4f536a1..d3a3781 100644 --- a/packages/react-native-detour/src/analytics/api/retention.ts +++ b/packages/react-native-detour/src/analytics/api/retention.ts @@ -1,44 +1,17 @@ import { Platform } from "react-native"; -import type { AttStatus } from "../../links/utils/deviceIdentifiers"; import { SDK_HEADER_VALUE } from "../../version"; -import type { Consent } from "../utils/consent"; +import type { AnalyticsContext } from "../types"; +import { toMetadataFields } from "../utils/buildAnalyticsContext"; const RETENTION_API_URL = "https://godetour.dev/api/analytics/retention"; export const sendRetentionEvent = async ({ apiKey, appID, - deviceId, eventName, - idfv, - aaid, - idfa, - customerUserId, - appVersion, - buildNumber, - consent, - osVersion, - locale, - attStatus, - sessionId, -}: { - apiKey: string; - appID: string; - eventName: string; - deviceId: string; - idfv?: string; - aaid?: string; - idfa?: string; - customerUserId?: string; - appVersion?: string; - buildNumber?: string; - consent?: Consent; - osVersion?: string; - locale?: string[]; - attStatus?: AttStatus; - sessionId?: string; -}) => { + ...ctx +}: { apiKey: string; appID: string; eventName: string } & AnalyticsContext) => { try { const response = await fetch(RETENTION_API_URL, { method: "POST", @@ -52,18 +25,8 @@ export const sendRetentionEvent = async ({ event_name: eventName, timestamp: new Date().toISOString(), platform: Platform.OS, - device_id: deviceId, - idfv, - aaid, - idfa, - customer_user_id: customerUserId, - app_version: appVersion, - build_number: buildNumber, - consent, - os_version: osVersion, - locale, - att_status: attStatus, - session_id: sessionId, + device_id: ctx.deviceId, + metadata: toMetadataFields(ctx), }), }); diff --git a/packages/react-native-detour/src/analytics/hooks/useSessionTracking.tsx b/packages/react-native-detour/src/analytics/hooks/useSessionTracking.tsx index f99843f..73a0331 100644 --- a/packages/react-native-detour/src/analytics/hooks/useSessionTracking.tsx +++ b/packages/react-native-detour/src/analytics/hooks/useSessionTracking.tsx @@ -2,17 +2,14 @@ import { useEffect } from "react"; import { AppState, type AppStateStatus } from "react-native"; -import { generateUUID } from "../utils/uuid"; +import { generateUUID } from "../../shared/uuid"; -// A session groups a contiguous stretch of activity — the basis for funnels, -// time-in-session and LTV. In-memory only (same convention as userIdentity.ts): -// a cold start is inherently a new session, so there's nothing to persist. +// In-memory only — a cold start is inherently a new session. let currentSessionId: string = generateUUID(); let backgroundedAt: number | null = null; -// 30 min of background before a re-open counts as a fresh session — the -// industry-standard timeout (Google Analytics, Adjust). Shorter trips back to -// the OS (Control Center, a notification, an ATT prompt) keep the same session. +// 30 min background before a re-open counts as a fresh session (same +// threshold as Google Analytics/Adjust). const SESSION_TIMEOUT_MS = 30 * 60 * 1000; const handleAppStateChange = (nextState: AppStateStatus) => { diff --git a/packages/react-native-detour/src/analytics/types/index.ts b/packages/react-native-detour/src/analytics/types/index.ts index cc2a2b7..89c869f 100644 --- a/packages/react-native-detour/src/analytics/types/index.ts +++ b/packages/react-native-detour/src/analytics/types/index.ts @@ -1,3 +1,6 @@ +import type { Consent } from "../../shared/consent"; +import type { AttStatus } from "../../shared/deviceIdentifiers"; + export enum DetourEventNames { // general Login = "login", @@ -26,9 +29,6 @@ export type DetourEvent = { data?: any; }; -// Revenue always as first-class, typed fields (not buried in `data`) — every -// host reports it the same way, so the backend can aggregate ROAS across -// apps without per-host parsing conventions. export type Conversion = { revenue: number; currency: string; @@ -36,3 +36,18 @@ export type Conversion = { quantity?: number; transactionId?: string; }; + +export type AnalyticsContext = { + deviceId: string; + idfv?: string; + aaid?: string; + idfa?: string; + customerUserId?: string; + appVersion?: string; + buildNumber?: string; + consent?: Consent; + osVersion?: string; + locale?: { languageTag: string }[]; + attStatus?: AttStatus; + sessionId?: string; +}; diff --git a/packages/react-native-detour/src/analytics/utils/analyticsEmitter.ts b/packages/react-native-detour/src/analytics/utils/analyticsEmitter.ts index 3f2035e..dad88ca 100644 --- a/packages/react-native-detour/src/analytics/utils/analyticsEmitter.ts +++ b/packages/react-native-detour/src/analytics/utils/analyticsEmitter.ts @@ -1,16 +1,13 @@ import type { Conversion, DetourEventNames } from "../types"; -type AnalyticsListener = ({ - eventName, - data, - isRetention, - conversion, -}: { +export type AnalyticsEmitterPayload = { eventName: string | DetourEventNames; data?: any; isRetention?: boolean; conversion?: Conversion; -}) => void; +}; + +type AnalyticsListener = (payload: AnalyticsEmitterPayload) => void; let listeners: AnalyticsListener[] = []; @@ -22,23 +19,13 @@ export const analyticsEmitter = { }; }, - emit: ({ - eventName, - data, - isRetention, - conversion, - }: { - eventName: string | DetourEventNames; - data?: any; - isRetention?: boolean; - conversion?: Conversion; - }) => { + emit: (payload: AnalyticsEmitterPayload) => { if (listeners.length === 0) { console.warn( "🔗[Detour:ANALYTICS_WARNING] DetourAnalytics method called but DetourProvider is not mounted. Event dropped.", ); return; } - listeners.forEach((listener) => listener({ eventName, data, isRetention, conversion })); + listeners.forEach((listener) => listener(payload)); }, }; diff --git a/packages/react-native-detour/src/analytics/utils/buildAnalyticsContext.ts b/packages/react-native-detour/src/analytics/utils/buildAnalyticsContext.ts new file mode 100644 index 0000000..a141b30 --- /dev/null +++ b/packages/react-native-detour/src/analytics/utils/buildAnalyticsContext.ts @@ -0,0 +1,65 @@ +import * as Localization from "expo-localization"; + +import { getAppVersion, getBuildNumber } from "../../shared/appInfo"; +import { getConsent } from "../../shared/consent"; +import { collectDeviceIdentitySignals } from "../../shared/deviceIdentifiers"; +import { getSafeOsVersion } from "../../shared/deviceInfo"; +import { prepareDeviceIdForApi } from "../../shared/devicePersistence"; +import type { DetourStorage } from "../../shared/storage"; +import { getUserId } from "../../shared/userIdentity"; +import { getSessionId } from "../hooks/useSessionTracking"; +import type { AnalyticsContext, Conversion } from "../types"; + +export async function buildAnalyticsContext(storage: DetourStorage): Promise { + const [deviceId, { idfv, aaid, idfa, attStatus }] = await Promise.all([ + prepareDeviceIdForApi(storage), + collectDeviceIdentitySignals(), + ]); + + const customerUserId = getUserId(); + const appVersion = getAppVersion(); + const buildNumber = getBuildNumber(); + const consent = getConsent(); + const osVersion = getSafeOsVersion(); + const locale = Localization.getLocales().map((l) => ({ languageTag: l.languageTag })); + const sessionId = getSessionId(); + + return { + deviceId, + idfv, + aaid, + idfa, + customerUserId, + appVersion, + buildNumber, + consent, + osVersion, + locale, + attStatus, + sessionId, + }; +} + +export default buildAnalyticsContext; + +export const toConversionFields = (conversion?: Conversion) => ({ + revenue: conversion?.revenue, + currency: conversion?.currency, + product_id: conversion?.productId, + quantity: conversion?.quantity, + transaction_id: conversion?.transactionId, +}); + +export const toMetadataFields = (ctx: AnalyticsContext) => ({ + idfv: ctx.idfv, + aaid: ctx.aaid, + idfa: ctx.idfa, + customer_user_id: ctx.customerUserId, + app_version: ctx.appVersion, + build_number: ctx.buildNumber, + consent: ctx.consent, + os_version: ctx.osVersion, + locale: ctx.locale, + att_status: ctx.attStatus, + session_id: ctx.sessionId, +}); diff --git a/packages/react-native-detour/src/analytics/utils/consent.ts b/packages/react-native-detour/src/analytics/utils/consent.ts deleted file mode 100644 index 3f4354d..0000000 --- a/packages/react-native-detour/src/analytics/utils/consent.ts +++ /dev/null @@ -1,23 +0,0 @@ -// In-memory only, by design: same convention as userIdentity.ts — the host -// app owns its own consent UI/state and is expected to call setConsent() -// again on every cold start once the user's choice is known. -export type Consent = { - ad?: boolean; - analytics?: boolean; - tracking?: boolean; - source?: "att" | "aaid-optout" | "manual"; - updatedAt?: number; -}; - -let consent: Consent | undefined; - -// Merges into the existing state so the host can update one flag (e.g. -// tracking, right after an ATT prompt resolves) without re-supplying the rest. -// `source` defaults to "manual" when omitted — internal auto-derivation -// (ATT/AAID) always passes its own explicit source, so an omitted source -// means this call came directly from the host. -export const setConsent = (update: Consent): void => { - consent = { ...consent, ...update, source: update.source ?? "manual", updatedAt: Date.now() }; -}; - -export const getConsent = (): Consent | undefined => consent; diff --git a/packages/react-native-detour/src/analytics/utils/dispatchAnalyticsEvent.ts b/packages/react-native-detour/src/analytics/utils/dispatchAnalyticsEvent.ts new file mode 100644 index 0000000..dd2379c --- /dev/null +++ b/packages/react-native-detour/src/analytics/utils/dispatchAnalyticsEvent.ts @@ -0,0 +1,34 @@ +import type { DetourStorage } from "../../shared/storage"; +import { sendEvent } from "../api/events"; +import { sendRetentionEvent } from "../api/retention"; +import type { DetourEvent, DetourEventNames } from "../types"; +import type { AnalyticsEmitterPayload } from "./analyticsEmitter"; +import buildAnalyticsContext from "./buildAnalyticsContext"; + +export type DispatchAnalyticsEventConfig = { + apiKey: string; + appID: string; + storage: DetourStorage; +}; + +export const dispatchAnalyticsEvent = async ( + { eventName, data, isRetention, conversion }: AnalyticsEmitterPayload, + { apiKey, appID, storage }: DispatchAnalyticsEventConfig, +): Promise => { + try { + const analyticsContext = await buildAnalyticsContext(storage); + + if (isRetention) { + await sendRetentionEvent({ apiKey, appID, eventName, ...analyticsContext }); + return; + } + + const event: DetourEvent = { eventName: eventName as DetourEventNames, data }; + await sendEvent({ apiKey, appID, event, ...analyticsContext, conversion }); + } catch (error) { + console.error( + "[Detour:ANALYTICS_ERROR] Analytics disabled due to storage/runtime failure:", + error, + ); + } +}; diff --git a/packages/react-native-detour/src/analytics/utils/userIdentity.ts b/packages/react-native-detour/src/analytics/utils/userIdentity.ts deleted file mode 100644 index bbafff7..0000000 --- a/packages/react-native-detour/src/analytics/utils/userIdentity.ts +++ /dev/null @@ -1,12 +0,0 @@ -// In-memory only, by design: the host app is the source of truth for its own -// login state and is expected to call setUserId() again on every cold start -// once auth is known (same convention as AppsFlyer's setCustomerUserId / -// Branch's setIdentity). Persisting it ourselves risks a stale customer_user_id -// surviving a logout the host app forgot to clear. -let customerUserId: string | undefined; - -export const setUserId = (id: string | null): void => { - customerUserId = id ?? undefined; -}; - -export const getUserId = (): string | undefined => customerUserId; diff --git a/packages/react-native-detour/src/analytics/utils/uuid.ts b/packages/react-native-detour/src/analytics/utils/uuid.ts deleted file mode 100644 index 26d7606..0000000 --- a/packages/react-native-detour/src/analytics/utils/uuid.ts +++ /dev/null @@ -1,11 +0,0 @@ -// Math.random()-based RFC4122 v4 UUID. Sufficient -// for both consumers: device_id is compared across a huge population, but even -// non-cryptographic 122 bits of randomness makes a collision practically -// impossible; session_id never leaves a single device, so collision risk is a -// non-issue there. -export const generateUUID = (): string => - "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (char) => { - const random = Math.floor(Math.random() * 16); - if (char === "x") return random.toString(16); - return (8 + (random % 4)).toString(16); - }); diff --git a/packages/react-native-detour/src/links/api/getDeferredLink.ts b/packages/react-native-detour/src/links/api/getDeferredLink.ts index 572e62f..34a890b 100644 --- a/packages/react-native-detour/src/links/api/getDeferredLink.ts +++ b/packages/react-native-detour/src/links/api/getDeferredLink.ts @@ -5,25 +5,14 @@ import type { DetourStorage, RequiredConfig } from "../types"; import { type DeterministicFingerprint, type ProbabilisticFingerprint, + collectIdentityFields, getDeterministicFingerprint, getProbabilisticFingerprint, } from "../utils/fingerprint"; +import { parseUtmParams } from "../utils/urlHelpers"; const API_URL = "https://godetour.dev/api/link/match-link"; -const parseUtmParams = (decodedReferrer: string): Record | undefined => { - const params = new URLSearchParams(decodedReferrer); - const utm: Record = {}; - - for (const [key, value] of params) { - if (key.startsWith("utm_") && value) { - utm[key] = value; - } - } - - return Object.keys(utm).length > 0 ? utm : undefined; -}; - const sendFingerprint = async ({ API_KEY, appID, @@ -66,14 +55,15 @@ export const getDeferredLink = async ({ const matchClickId = decodedReferrer.match(/(?:^|&)click_id=([^&]+)/); const referrerClickId = matchClickId ? matchClickId[1] : null; const utm = parseUtmParams(decodedReferrer); + const identityFields = await collectIdentityFields(storage); let response; if (referrerClickId?.length) { - const deterministicFingerprint = await getDeterministicFingerprint( - referrerClickId, - storage, + const deterministicFingerprint: DeterministicFingerprint = { + ...identityFields, + ...getDeterministicFingerprint(referrerClickId), utm, - ); + }; response = await sendFingerprint({ API_KEY, @@ -81,11 +71,11 @@ export const getDeferredLink = async ({ requestBody: deterministicFingerprint, }); } else { - const probabilisticFingerprint = await getProbabilisticFingerprint( - shouldUseClipboard, - storage, + const probabilisticFingerprint: ProbabilisticFingerprint = { + ...identityFields, + ...(await getProbabilisticFingerprint(shouldUseClipboard)), utm, - ); + }; response = await sendFingerprint({ API_KEY, diff --git a/packages/react-native-detour/src/links/api/sendUniversalLinkClick.ts b/packages/react-native-detour/src/links/api/sendUniversalLinkClick.ts index a4a227a..be6b936 100644 --- a/packages/react-native-detour/src/links/api/sendUniversalLinkClick.ts +++ b/packages/react-native-detour/src/links/api/sendUniversalLinkClick.ts @@ -1,10 +1,9 @@ import { Platform } from "react-native"; -import Constants from "expo-constants"; - +import { getAppVersion } from "../../shared/appInfo"; +import { getSyncDeviceInfo } from "../../shared/deviceInfo"; import { SDK_HEADER_VALUE } from "../../version"; import type { RequiredConfig } from "../types"; -import { getSyncDeviceInfo } from "../utils/deviceInfo"; const API_URL = "https://godetour.dev/api/link/universal-link-click"; @@ -51,7 +50,7 @@ const buildMetadata = (): Record => { const raw: Record = { os_version: osVersion, - app_version: Constants.nativeAppVersion, + app_version: getAppVersion(), device_model: model, }; diff --git a/packages/react-native-detour/src/links/types/index.ts b/packages/react-native-detour/src/links/types/index.ts index 450901b..fb54979 100644 --- a/packages/react-native-detour/src/links/types/index.ts +++ b/packages/react-native-detour/src/links/types/index.ts @@ -1,3 +1,5 @@ +import type { DetourStorage } from "../../shared/storage"; + export type Config = { appID: string; apiKey: string; @@ -49,11 +51,7 @@ export type DetourContextType = { clearLink: () => void; }; -export interface DetourStorage { - getItem(key: string): Promise | string | null; - setItem(key: string, value: string): Promise | void; - removeItem?(key: string): Promise | void; -} +export type { DetourStorage }; export type DetourUrlEvent = { url: string; diff --git a/packages/react-native-detour/src/links/utils/appEntrance.ts b/packages/react-native-detour/src/links/utils/appEntrance.ts index 8b5c7ac..fa80a61 100644 --- a/packages/react-native-detour/src/links/utils/appEntrance.ts +++ b/packages/react-native-detour/src/links/utils/appEntrance.ts @@ -1,5 +1,5 @@ +import { StorageKeys } from "../../shared/storage"; import type { DetourStorage } from "../types"; -import { StorageKeys } from "./storage"; export const markFirstEntrance = async (storage: DetourStorage) => { await storage.setItem(StorageKeys.FIRST_ENTRANCE_FLAG_KEY, "true"); diff --git a/packages/react-native-detour/src/links/utils/fingerprint.ts b/packages/react-native-detour/src/links/utils/fingerprint.ts index a9b2a87..3e4234a 100644 --- a/packages/react-native-detour/src/links/utils/fingerprint.ts +++ b/packages/react-native-detour/src/links/utils/fingerprint.ts @@ -4,11 +4,11 @@ import * as Clipboard from "expo-clipboard"; import Constants from "expo-constants"; import * as Localization from "expo-localization"; -import { prepareDeviceIdForApi } from "../../analytics/utils/devicePersistence"; -import { getUserId } from "../../analytics/utils/userIdentity"; +import { collectDeviceIdentitySignals } from "../../shared/deviceIdentifiers"; +import { getDeviceInfo } from "../../shared/deviceInfo"; +import { prepareDeviceIdForApi } from "../../shared/devicePersistence"; +import { getUserId } from "../../shared/userIdentity"; import type { DetourStorage } from "../types"; -import { collectDeviceIdentitySignals } from "./deviceIdentifiers"; -import { getDeviceInfo } from "./deviceInfo"; // Identity signals shared by both fingerprint variants — this is what lets // match-link recognize a device via the same identity graph keys used by @@ -43,7 +43,9 @@ export type DeterministicFingerprint = DeviceIdentityFields & { utm?: Record; }; -const collectIdentityFields = async (storage: DetourStorage): Promise => { +export const collectIdentityFields = async ( + storage: DetourStorage, +): Promise => { const [installId, { idfv, aaid, idfa }] = await Promise.all([ prepareDeviceIdForApi(storage), collectDeviceIdentitySignals(), @@ -58,23 +60,13 @@ const collectIdentityFields = async (storage: DetourStorage): Promise, -): Promise => { - return { - clickId, - utm, - ...(await collectIdentityFields(storage)), - }; -}; +export const getDeterministicFingerprint = (clickId: string): { clickId: string } => ({ + clickId, +}); export const getProbabilisticFingerprint = async ( shouldUseClipboard: boolean, - storage: DetourStorage, - utm?: Record, -): Promise => { +): Promise> => { const { width, height } = Dimensions.get("screen"); const locales = Localization.getLocales(); const localeLanguageTags = locales.map((locale) => ({ @@ -89,7 +81,6 @@ export const getProbabilisticFingerprint = async ( } return { - ...(await collectIdentityFields(storage)), platform: Platform.OS, model, manufacturer, @@ -103,6 +94,5 @@ export const getProbabilisticFingerprint = async ( timestamp: Date.now(), pastedLink: shouldUseClipboard && Platform.OS === "ios" ? await Clipboard.getStringAsync() : undefined, - utm, }; }; diff --git a/packages/react-native-detour/src/links/utils/urlHelpers.ts b/packages/react-native-detour/src/links/utils/urlHelpers.ts index d8293ce..de01a99 100644 --- a/packages/react-native-detour/src/links/utils/urlHelpers.ts +++ b/packages/react-native-detour/src/links/utils/urlHelpers.ts @@ -35,3 +35,16 @@ export function getRouteFromDeepLink(urlObj: URL): string { const route = urlObj.host + urlObj.pathname + (urlObj.search ?? ""); return route.startsWith("/") ? route : `/${route}`; } + +export function parseUtmParams(decodedReferrer: string): Record | undefined { + const params = new URLSearchParams(decodedReferrer); + const utm: Record = {}; + + for (const [key, value] of params) { + if (key.startsWith("utm_") && value) { + utm[key] = value; + } + } + + return Object.keys(utm).length > 0 ? utm : undefined; +} diff --git a/packages/react-native-detour/src/analytics/utils/appInfo.ts b/packages/react-native-detour/src/shared/appInfo.ts similarity index 62% rename from packages/react-native-detour/src/analytics/utils/appInfo.ts rename to packages/react-native-detour/src/shared/appInfo.ts index aa21932..cea9444 100644 --- a/packages/react-native-detour/src/analytics/utils/appInfo.ts +++ b/packages/react-native-detour/src/shared/appInfo.ts @@ -1,7 +1,5 @@ import * as Application from "expo-application"; -// Marketing version + build number of the host app — analytics-only context -// Both are synchronous and static for the app's lifetime, no caching needed. export const getAppVersion = (): string | undefined => Application.nativeApplicationVersion ?? undefined; diff --git a/packages/react-native-detour/src/shared/consent.ts b/packages/react-native-detour/src/shared/consent.ts new file mode 100644 index 0000000..009bdd9 --- /dev/null +++ b/packages/react-native-detour/src/shared/consent.ts @@ -0,0 +1,23 @@ +// In-memory only, reset on every cold start. `ad`/`tracking` self-heal via +// ATT/AAID re-derivation (the OS remembers); manual overrides don't — the +// host must call setConsent() again each cold start for those. +export type Consent = { + ad?: boolean; + analytics?: boolean; + tracking?: boolean; + source?: "att" | "aaid-optout" | "manual"; + updatedAt?: number; +}; + +let consent: Consent | undefined; + +export const setConsent = (update: Consent): void => { + consent = { ...consent, ...update, source: update.source ?? "manual", updatedAt: Date.now() }; +}; + +export const applyAutoConsent = (update: Omit): void => { + if (consent?.source === "manual") return; + consent = { ...consent, ...update, updatedAt: Date.now() }; +}; + +export const getConsent = (): Consent | undefined => consent; diff --git a/packages/react-native-detour/src/links/utils/deviceIdentifiers.ts b/packages/react-native-detour/src/shared/deviceIdentifiers.ts similarity index 61% rename from packages/react-native-detour/src/links/utils/deviceIdentifiers.ts rename to packages/react-native-detour/src/shared/deviceIdentifiers.ts index 85ae4fb..51428a9 100644 --- a/packages/react-native-detour/src/links/utils/deviceIdentifiers.ts +++ b/packages/react-native-detour/src/shared/deviceIdentifiers.ts @@ -2,21 +2,18 @@ import { Platform } from "react-native"; import * as Application from "expo-application"; -import { setConsent } from "../../analytics/utils/consent"; +import { applyAutoConsent } from "./consent"; -// Android AAID / iOS IDFA opt-out sentinel — never send as-is, it collides -// every opted-out device into the same fake "identifier". +// Opt-out sentinel — collides every opted-out device into the same fake AAID. const AD_ID_OPT_OUT = "00000000-0000-0000-0000-000000000000"; -// Host-supplied override — set via setAdvertisingId() when the host already -// collected the ID through another native module/SDK, so we skip our own -// native call entirely (see getAaid/getIdfa precedence below). +const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + let manualAdvertisingId: string | undefined; export type AttStatus = "granted" | "denied" | "undetermined" | "unavailable"; +const VALID_ATT_STATUSES: AttStatus[] = ["granted", "denied", "undetermined", "unavailable"]; -// Host-supplied override — set via setTrackingAuthorizationStatus() when the -// host's native ATT module isn't expo-tracking-transparency. let manualAttStatus: AttStatus | undefined; type TrackingTransparencyModule = { @@ -25,7 +22,6 @@ type TrackingTransparencyModule = { getTrackingPermissionsAsync?: () => Promise<{ status: string }>; }; -// Metro needs string literal in require() during bundle time (same constraint as deviceInfo.ts) const trackingTransparency = (() => { try { return require("expo-tracking-transparency") as TrackingTransparencyModule; @@ -59,16 +55,11 @@ const getRawAdvertisingId = async (): Promise => { return id; }; -// Android's equivalent of ATT: Google Play Services returns the opt-out -// sentinel when the user enabled "Opt out of Ads Personalization" in device -// settings. `null` means the fetch failed/unavailable, not an -// opt-out. const applyAaidAutoConsent = (rawId: string | null): void => { if (!rawId) return; - setConsent({ ad: rawId !== AD_ID_OPT_OUT, source: "aaid-optout" }); + applyAutoConsent({ ad: rawId !== AD_ID_OPT_OUT, source: "aaid-optout" }); }; -// Android advertising ID — feeds the backend's deterministic device_uuid. export const getAaid = async (): Promise => { if (Platform.OS !== "android") return undefined; if (manualAdvertisingId) return manualAdvertisingId; @@ -78,20 +69,17 @@ export const getAaid = async (): Promise => { return rawId; }; -// iOS advertising ID — ad-attribution signal only, does not feed device_uuid (IDFV does). export const getIdfa = async (): Promise => { if (Platform.OS !== "ios") return undefined; if (manualAdvertisingId) return manualAdvertisingId; return getRawAdvertisingId(); }; -// ATT is one gate for both tracking and ad personalization on iOS (Apple ties -// them together) — so once we know the status, we know both consent flags -// without asking the host. Only fires for a determined status; "undetermined" -// means we don't know yet, so we don't want to assert a false negative. +// Apple ties tracking + ad personalization to one ATT prompt, so a determined +// status implies both consent flags. Skips "undetermined" — we don't know yet. const applyAttAutoConsent = (status: AttStatus): void => { if (status !== "granted" && status !== "denied") return; - setConsent({ tracking: status === "granted", ad: status === "granted", source: "att" }); + applyAutoConsent({ tracking: status === "granted", ad: status === "granted", source: "att" }); }; const getRawAttStatus = async (): Promise => { @@ -106,9 +94,8 @@ const getRawAttStatus = async (): Promise => { } }; -// Reads the current ATT permission state without ever showing the system -// prompt (unlike requestTrackingPermission) — so att_status is known even -// when shouldRequestTrackingPermission is off (host prompts some other way). +// Reads ATT state without showing the system prompt, so att_status is known +// even when shouldRequestTrackingPermission is off. export const getAttStatus = async (): Promise => { if (manualAttStatus) return manualAttStatus; const status = await getRawAttStatus(); @@ -125,10 +112,8 @@ export type DeviceIdentitySignals = { let cachedSignals: DeviceIdentitySignals | null = null; let pendingSignalsPromise: Promise | null = null; +let signalsGeneration = 0; -// These identifiers are static for the lifetime of the app session, so we -// resolve them once and reuse the result instead of re-hitting native APIs -// on every fingerprint/event send. export const collectDeviceIdentitySignals = async (): Promise => { if (cachedSignals) { return cachedSignals; @@ -138,6 +123,8 @@ export const collectDeviceIdentitySignals = async (): Promise { const [idfv, aaid, idfa, attStatus] = await Promise.all([ getIdfv(), @@ -146,7 +133,11 @@ export const collectDeviceIdentitySignals = async (): Promise { cachedSignals = null; pendingSignalsPromise = null; + signalsGeneration++; }; -// Lets the host inject an IDFA/AAID it already collected another way (its own -// native bridge, another attribution SDK) — skips our native call for the -// rest of the session. Resets the cache so a value collected before this call -// (or the pre-override native fallback) doesn't linger. +// Lets the host inject an AAID/IDFA it already collected another way, +// skipping our own native call for the rest of the session. export const setAdvertisingId = (id: string): void => { + if (!UUID_PATTERN.test(id)) { + console.warn( + `🔗[Detour:INVALID_ARGUMENT] setAdvertisingId("${id}") ignored — expected a UUID-formatted AAID/IDFA.`, + ); + return; + } manualAdvertisingId = id; resetDeviceIdentitySignalsCache(); }; - -// Host-app-opt-in helper: only called when `shouldRequestTrackingPermission` -// is set on the Detour config. On Android/web this is a no-op — the OS -// doesn't gate the advertising ID behind a runtime prompt there. export const requestTrackingPermission = async (): Promise => { if (Platform.OS !== "ios" || !trackingTransparency?.requestTrackingPermissionsAsync) return; try { @@ -189,9 +178,15 @@ export const requestTrackingPermission = async (): Promise => { } }; -// Override for hosts whose native ATT module isn't expo-tracking-transparency -// (e.g. a custom native bridge) — same precedence pattern as setAdvertisingId. +// Override for hosts whose native ATT module isn't expo-tracking-transparency. export const setTrackingAuthorizationStatus = (status: AttStatus): void => { + if (!VALID_ATT_STATUSES.includes(status)) { + console.warn( + `🔗[Detour:INVALID_ARGUMENT] setTrackingAuthorizationStatus("${status}") ignored — ` + + `expected one of: ${VALID_ATT_STATUSES.join(", ")}.`, + ); + return; + } manualAttStatus = status; applyAttAutoConsent(status); resetDeviceIdentitySignalsCache(); diff --git a/packages/react-native-detour/src/links/utils/deviceInfo.ts b/packages/react-native-detour/src/shared/deviceInfo.ts similarity index 92% rename from packages/react-native-detour/src/links/utils/deviceInfo.ts rename to packages/react-native-detour/src/shared/deviceInfo.ts index 8615398..98efe40 100644 --- a/packages/react-native-detour/src/links/utils/deviceInfo.ts +++ b/packages/react-native-detour/src/shared/deviceInfo.ts @@ -117,9 +117,6 @@ export const getSyncDeviceInfo = (): SyncDeviceInfo => { }; }; -// Optional analytics context field — unlike getSyncDeviceInfo/getDeviceInfo, -// never throws when no device info library is installed (falls back to undefined -// instead of forcing every event to be dropped for a non-essential field). export const getSafeOsVersion = (): string | undefined => { try { const { osVersion } = getSyncDeviceInfo(); diff --git a/packages/react-native-detour/src/analytics/utils/devicePersistence.ts b/packages/react-native-detour/src/shared/devicePersistence.ts similarity index 90% rename from packages/react-native-detour/src/analytics/utils/devicePersistence.ts rename to packages/react-native-detour/src/shared/devicePersistence.ts index d879606..a5ee7c0 100644 --- a/packages/react-native-detour/src/analytics/utils/devicePersistence.ts +++ b/packages/react-native-detour/src/shared/devicePersistence.ts @@ -1,5 +1,5 @@ -import type { DetourStorage } from "../../links/types"; -import { StorageKeys } from "../../links/utils/storage"; +import type { DetourStorage } from "./storage"; +import { StorageKeys } from "./storage"; import { generateUUID } from "./uuid"; const saveDeviceId = async (storage: DetourStorage, id: string) => { diff --git a/packages/react-native-detour/src/links/utils/storage.ts b/packages/react-native-detour/src/shared/storage.ts similarity index 81% rename from packages/react-native-detour/src/links/utils/storage.ts rename to packages/react-native-detour/src/shared/storage.ts index 5b28614..d0db8c4 100644 --- a/packages/react-native-detour/src/links/utils/storage.ts +++ b/packages/react-native-detour/src/shared/storage.ts @@ -1,4 +1,8 @@ -import type { DetourStorage } from "../types"; +export interface DetourStorage { + getItem(key: string): Promise | string | null; + setItem(key: string, value: string): Promise | void; + removeItem?(key: string): Promise | void; +} const STORAGE_KEY_PREFIX = "Detour_"; const FIRST_ENTRANCE_FLAG = `${STORAGE_KEY_PREFIX}firstEntranceFlag`; diff --git a/packages/react-native-detour/src/shared/userIdentity.ts b/packages/react-native-detour/src/shared/userIdentity.ts new file mode 100644 index 0000000..8539aba --- /dev/null +++ b/packages/react-native-detour/src/shared/userIdentity.ts @@ -0,0 +1,7 @@ +let customerUserId: string | undefined; + +export const setUserId = (id: string | null): void => { + customerUserId = id ?? undefined; +}; + +export const getUserId = (): string | undefined => customerUserId; diff --git a/packages/react-native-detour/src/shared/uuid.ts b/packages/react-native-detour/src/shared/uuid.ts new file mode 100644 index 0000000..51c0cd0 --- /dev/null +++ b/packages/react-native-detour/src/shared/uuid.ts @@ -0,0 +1,6 @@ +export const generateUUID = (): string => + "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (char) => { + const random = Math.floor(Math.random() * 16); + if (char === "x") return random.toString(16); + return (8 + (random % 4)).toString(16); + }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9ce0dcb..5b2d074 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5,7 +5,6 @@ settings: excludeLinksFromLockfile: false overrides: - '@react-native/babel-preset': 0.83.4 metro: 0.83.5 metro-babel-transformer: 0.83.5 metro-cache: 0.83.5 @@ -95,9 +94,6 @@ importers: expo-constants: specifier: ~55.0.11 version: 55.0.11(expo@55.0.11)(react-native@0.83.4(@babel/core@7.29.0)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@react-native/metro-config@0.83.4(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(typescript@5.9.3) - expo-crypto: - specifier: ~14.1.5 - version: 14.1.5(expo@55.0.11) expo-dev-client: specifier: ~55.0.22 version: 55.0.22(expo@55.0.11)(typescript@5.9.3) @@ -3621,11 +3617,6 @@ packages: expo: '*' react-native: '*' - expo-crypto@14.1.5: - resolution: {integrity: sha512-ZXJoUMoUeiMNEoSD4itItFFz3cKrit6YJ/BR0hjuwNC+NczbV9rorvhvmeJmrU9O2cFQHhJQQR1fjQnt45Vu4Q==} - peerDependencies: - expo: '*' - expo-dev-client@55.0.22: resolution: {integrity: sha512-3AB4n28A07uWsUlu7mOxr76V/jjEpjc5TSlmR37u/qdaX4qwjTy+2GDZWCZumY8dSgy/hKgn1CThnKoVnIM5KA==} peerDependencies: @@ -10783,11 +10774,6 @@ snapshots: - supports-color - typescript - expo-crypto@14.1.5(expo@55.0.11): - dependencies: - base64-js: 1.5.1 - expo: 55.0.11(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.9)(expo-router@55.0.10)(react-dom@19.2.0(react@19.2.0))(react-native@0.83.4(@babel/core@7.29.0)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@react-native/metro-config@0.83.4(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3) - expo-dev-client@55.0.22(expo@55.0.11)(typescript@5.9.3): dependencies: expo: 55.0.11(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.9)(expo-router@55.0.10)(react-dom@19.2.0(react@19.2.0))(react-native@0.83.4(@babel/core@7.29.0)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@react-native/metro-config@0.83.4(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3) From 951c779188ccb55f61575cb540b37c72c91c0099 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Turba=C5=84ski?= Date: Fri, 24 Jul 2026 11:12:19 +0200 Subject: [PATCH 17/18] fix: expo-tracking-transparency as peer dependency --- packages/react-native-detour/package.json | 5 +++++ pnpm-lock.yaml | 3 +++ 2 files changed, 8 insertions(+) diff --git a/packages/react-native-detour/package.json b/packages/react-native-detour/package.json index 71b346a..2d517ac 100644 --- a/packages/react-native-detour/package.json +++ b/packages/react-native-detour/package.json @@ -78,6 +78,7 @@ "expo-constants": "~55.0.11", "expo-device": "~55.0.12", "expo-localization": "*", + "expo-tracking-transparency": "~55.0.11", "jest": "^29.7.0", "react": "19.2.0", "react-native": "0.83.4", @@ -92,6 +93,7 @@ "expo-constants": ">=17.1.7", "expo-device": ">=7.0.0", "expo-localization": ">=15.0.0", + "expo-tracking-transparency": ">=5.0.0", "react": ">=18", "react-native": ">=0.72", "react-native-device-info": ">=10.0.0" @@ -103,6 +105,9 @@ "expo-device": { "optional": true }, + "expo-tracking-transparency": { + "optional": true + }, "react-native-device-info": { "optional": true } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5b2d074..110dbc0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -527,6 +527,9 @@ importers: expo-localization: specifier: '*' version: 16.1.6(expo@55.0.11)(react@19.2.0) + expo-tracking-transparency: + specifier: ~55.0.11 + version: 55.0.16(expo@55.0.11)(react-native@0.83.4(@babel/core@7.29.0)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@react-native/metro-config@0.83.4(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0)) jest: specifier: ^29.7.0 version: 29.7.0(@types/node@25.5.1) From b56396203914ca7ec9aa5fe44d762dec6fc01a9f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Turba=C5=84ski?= Date: Wed, 29 Jul 2026 11:07:25 +0200 Subject: [PATCH 18/18] feat: separate conversion to new endpoint --- packages/react-native-detour/FIELDS.md | 49 +++++++++++------- .../src/analytics/analytics.ts | 15 ++---- .../src/analytics/api/conversion.ts | 50 +++++++++++++++++++ .../src/analytics/api/events.ts | 8 ++- .../src/analytics/utils/analyticsEmitter.ts | 10 ++-- .../analytics/utils/buildAnalyticsContext.ts | 10 +--- .../analytics/utils/dispatchAnalyticsEvent.ts | 30 +++++++++-- 7 files changed, 120 insertions(+), 52 deletions(-) create mode 100644 packages/react-native-detour/src/analytics/api/conversion.ts diff --git a/packages/react-native-detour/FIELDS.md b/packages/react-native-detour/FIELDS.md index 0c79e78..a53db28 100644 --- a/packages/react-native-detour/FIELDS.md +++ b/packages/react-native-detour/FIELDS.md @@ -6,7 +6,7 @@ not user-facing documentation (see the main [README](./README.md) for that). ## The process, in short -The SDK talks to three backend endpoints, each covering a different moment in the app's lifecycle: +The SDK talks to four backend endpoints, each covering a different moment in the app's lifecycle: 1. **`/api/link/match-link`** — called once per install, from `getDeferredLink.ts`. Tries to match the current device to a click that happened before install (deferred deep linking). Sends a @@ -16,17 +16,22 @@ The SDK talks to three backend endpoints, each covering a different moment in th 2. **`/api/link/universal-link-click`** — called from `sendUniversalLinkClick.ts` whenever the app is opened via a Universal/App Link at runtime (not a deferred install). 3. **`/api/analytics/event`** and **`/api/analytics/retention`** — called from `events.ts` / - `retention.ts` whenever the host calls `DetourAnalytics.logEvent` / `logRetention` / - `logConversion`, or when the SDK's own `useAppOpenRetention`/`useSessionTracking` hooks fire. - -All three share the same **identity signals** (`device_id`/`install_id`, `idfv`, `aaid`, `idfa`, -`customer_user_id`) — that's what lets the backend stitch together "the click that led to this -install" and "the events this install later produced" into one continuous record, instead of -seeing them as unrelated facts. See `shared/` for where each signal is collected, and -`analytics/utils/buildAnalyticsContext.ts` / `links/utils/fingerprint.ts#collectIdentityFields` for -where they get assembled into a request. - -## Identity fields (shared across all three endpoints) + `retention.ts` whenever the host calls `DetourAnalytics.logEvent` / `logRetention`, or when the + SDK's own `useAppOpenRetention`/`useSessionTracking` hooks fire. +4. **`/api/analytics/conversion`** — called from `conversion.ts` whenever the host calls + `DetourAnalytics.logConversion()`. Own endpoint rather than riding `/api/analytics/event`, since + revenue reporting is a distinct signal from generic event logging (see + `analyticsEmitter.ts#AnalyticsEmitterPayload`'s `"conversion"` kind) — not an event with revenue + bolted on. + +The match-link, event, retention, and conversion endpoints all share the same **identity signals** +(`device_id`/`install_id`, `idfv`, `aaid`, `idfa`, `customer_user_id`) — that's what lets the +backend stitch together "the click that led to this install" and "the events/conversions this +install later produced" into one continuous record, instead of seeing them as unrelated facts. See +`shared/` for where each signal is collected, and `analytics/utils/buildAnalyticsContext.ts` / +`links/utils/fingerprint.ts#collectIdentityFields` for where they get assembled into a request. + +## Identity fields (shared across match-link, event, retention, and conversion) | Field | Source | Why | |---|---|---| @@ -42,10 +47,9 @@ where they get assembled into a request. top-level — that's exactly what the backend's current `/api/analytics/event` and `/api/analytics/retention` endpoints already read and store today. Everything below is new and not yet persisted server-side (see "Not yet stored server-side" below) — it's grouped under one -`metadata` object (`buildAnalyticsContext.ts#toMetadataFields`/`toConversionFields`) instead of -more top-level keys, so the existing endpoint stays backward-compatible (it already ignores unknown -body fields) and adding backend support later means parsing one object, not hunting down -individually-added fields. +`metadata` object (`buildAnalyticsContext.ts#toMetadataFields`) instead of more top-level keys, so +the existing endpoint stays backward-compatible (it already ignores unknown body fields) and adding +backend support later means parsing one object, not hunting down individually-added fields. | Field (inside `metadata`) | Source | Why | |---|---|---| @@ -56,7 +60,18 @@ individually-added fields. | `att_status` | `shared/deviceIdentifiers.ts` | Explicit ATT state (`granted`/`denied`/`undetermined`/`unavailable`) — otherwise indistinguishable from "not asked yet" if inferred only from a missing `idfa`. | | `consent` | `shared/consent.ts` (`{ ad, analytics, tracking, source, updatedAt }`) | Audit trail of what the user had consented to when the event was logged. Auto-derived from ATT (iOS) / AAID opt-out (Android) unless the host calls `setConsent()` directly, which always wins (see `applyAutoConsent`'s manual-source guard). | | `session_id` | `analytics/hooks/useSessionTracking.tsx` | Groups a contiguous stretch of activity (funnels, time-in-session). Rotates after 30 min in background. | -| `revenue` / `currency` / `product_id` / `quantity` / `transaction_id` | Host-supplied via `DetourAnalytics.logConversion()` (events only) | Revenue as first-class fields (not buried in `data`) so the backend can aggregate ROAS without per-host parsing conventions. | + +## Conversion payload (`analytics/api/conversion.ts`) + +**Wire shape:** `event_name`, `revenue`, `currency`, `product_id`, `quantity`, `transaction_id`, +`timestamp`, `platform`, `device_id` top-level, plus the same identity `metadata` object as +events/retention. `event_name` is a required argument to `logConversion()` (no default) — the +reviewer flagged that defaulting silently to `DetourEventNames.Purchase` on a caller typo would +misattribute revenue to the wrong event. + +| Field | Source | Why | +|---|---|---| +| `revenue` / `currency` / `product_id` / `quantity` / `transaction_id` | Host-supplied via `DetourAnalytics.logConversion()` | Revenue as first-class top-level fields (not buried in `data` or nested under `metadata`) so the backend can aggregate ROAS without per-host parsing conventions. | ## Fingerprint payload (`links/utils/fingerprint.ts`) diff --git a/packages/react-native-detour/src/analytics/analytics.ts b/packages/react-native-detour/src/analytics/analytics.ts index 82540a8..cf53e62 100644 --- a/packages/react-native-detour/src/analytics/analytics.ts +++ b/packages/react-native-detour/src/analytics/analytics.ts @@ -6,24 +6,19 @@ import type { Conversion } from "./types"; import { analyticsEmitter } from "./utils/analyticsEmitter"; export const logEvent = (eventName: DetourEventNames | `${DetourEventNames}`, data?: any) => { - analyticsEmitter.emit({ eventName, data }); + analyticsEmitter.emit({ kind: "event", eventName, data }); }; export const logRetention = (retentionEventName: string) => { - analyticsEmitter.emit({ eventName: retentionEventName, isRetention: true }); + analyticsEmitter.emit({ kind: "retention", eventName: retentionEventName }); }; export type ConversionParams = Conversion & { - eventName?: DetourEventNames | `${DetourEventNames}`; + eventName: string; }; -// The SDK has no signal for transaction amount, so revenue reporting always -// needs an explicit host call. Rides the existing event endpoint (events.ts). -export const logConversion = ({ - eventName = DetourEventNames.Purchase, - ...conversion -}: ConversionParams) => { - analyticsEmitter.emit({ eventName, conversion }); +export const logConversion = ({ eventName, ...conversion }: ConversionParams) => { + analyticsEmitter.emit({ kind: "conversion", eventName, conversion }); }; export const DetourAnalytics = { diff --git a/packages/react-native-detour/src/analytics/api/conversion.ts b/packages/react-native-detour/src/analytics/api/conversion.ts new file mode 100644 index 0000000..ce923bb --- /dev/null +++ b/packages/react-native-detour/src/analytics/api/conversion.ts @@ -0,0 +1,50 @@ +import { Platform } from "react-native"; + +import { SDK_HEADER_VALUE } from "../../version"; +import type { AnalyticsContext, Conversion } from "../types"; +import { toMetadataFields } from "../utils/buildAnalyticsContext"; + +const CONVERSION_API_URL = "https://godetour.dev/api/analytics/conversion"; + +export const sendConversion = async ({ + apiKey, + appID, + eventName, + conversion, + ...ctx +}: { + apiKey: string; + appID: string; + eventName: string; + conversion: Conversion; +} & AnalyticsContext) => { + try { + const response = await fetch(CONVERSION_API_URL, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${apiKey}`, + "X-App-ID": appID, + "X-SDK": SDK_HEADER_VALUE, + }, + body: JSON.stringify({ + event_name: eventName, + revenue: conversion.revenue, + currency: conversion.currency, + product_id: conversion.productId, + quantity: conversion.quantity, + transaction_id: conversion.transactionId, + timestamp: new Date().toISOString(), + platform: Platform.OS, + device_id: ctx.deviceId, + metadata: toMetadataFields(ctx), + }), + }); + + if (!response.ok) { + console.warn(`🔗[Detour:ANALYTICS_ERROR] Failed to log conversion: ${response.status}`); + } + } catch (error) { + console.error("🔗[Detour:ANALYTICS_ERROR] Network error logging conversion:", error); + } +}; diff --git a/packages/react-native-detour/src/analytics/api/events.ts b/packages/react-native-detour/src/analytics/api/events.ts index 34a7fbd..3ef3d80 100644 --- a/packages/react-native-detour/src/analytics/api/events.ts +++ b/packages/react-native-detour/src/analytics/api/events.ts @@ -1,8 +1,8 @@ import { Platform } from "react-native"; import { SDK_HEADER_VALUE } from "../../version"; -import type { AnalyticsContext, Conversion, DetourEvent } from "../types"; -import { toConversionFields, toMetadataFields } from "../utils/buildAnalyticsContext"; +import type { AnalyticsContext, DetourEvent } from "../types"; +import { toMetadataFields } from "../utils/buildAnalyticsContext"; const EVENT_API_URL = "https://godetour.dev/api/analytics/event"; @@ -10,13 +10,11 @@ export const sendEvent = async ({ apiKey, appID, event, - conversion, ...ctx }: { apiKey: string; appID: string; event: DetourEvent; - conversion?: Conversion; } & AnalyticsContext) => { try { const response = await fetch(EVENT_API_URL, { @@ -33,7 +31,7 @@ export const sendEvent = async ({ timestamp: new Date().toISOString(), platform: Platform.OS, device_id: ctx.deviceId, - metadata: { ...toMetadataFields(ctx), ...toConversionFields(conversion) }, + metadata: toMetadataFields(ctx), }), }); diff --git a/packages/react-native-detour/src/analytics/utils/analyticsEmitter.ts b/packages/react-native-detour/src/analytics/utils/analyticsEmitter.ts index dad88ca..9cf4a1a 100644 --- a/packages/react-native-detour/src/analytics/utils/analyticsEmitter.ts +++ b/packages/react-native-detour/src/analytics/utils/analyticsEmitter.ts @@ -1,11 +1,9 @@ import type { Conversion, DetourEventNames } from "../types"; -export type AnalyticsEmitterPayload = { - eventName: string | DetourEventNames; - data?: any; - isRetention?: boolean; - conversion?: Conversion; -}; +export type AnalyticsEmitterPayload = + | { kind: "event"; eventName: string | DetourEventNames; data?: any } + | { kind: "retention"; eventName: string } + | { kind: "conversion"; eventName: string; conversion: Conversion }; type AnalyticsListener = (payload: AnalyticsEmitterPayload) => void; diff --git a/packages/react-native-detour/src/analytics/utils/buildAnalyticsContext.ts b/packages/react-native-detour/src/analytics/utils/buildAnalyticsContext.ts index a141b30..5309885 100644 --- a/packages/react-native-detour/src/analytics/utils/buildAnalyticsContext.ts +++ b/packages/react-native-detour/src/analytics/utils/buildAnalyticsContext.ts @@ -8,7 +8,7 @@ import { prepareDeviceIdForApi } from "../../shared/devicePersistence"; import type { DetourStorage } from "../../shared/storage"; import { getUserId } from "../../shared/userIdentity"; import { getSessionId } from "../hooks/useSessionTracking"; -import type { AnalyticsContext, Conversion } from "../types"; +import type { AnalyticsContext } from "../types"; export async function buildAnalyticsContext(storage: DetourStorage): Promise { const [deviceId, { idfv, aaid, idfa, attStatus }] = await Promise.all([ @@ -42,14 +42,6 @@ export async function buildAnalyticsContext(storage: DetourStorage): Promise ({ - revenue: conversion?.revenue, - currency: conversion?.currency, - product_id: conversion?.productId, - quantity: conversion?.quantity, - transaction_id: conversion?.transactionId, -}); - export const toMetadataFields = (ctx: AnalyticsContext) => ({ idfv: ctx.idfv, aaid: ctx.aaid, diff --git a/packages/react-native-detour/src/analytics/utils/dispatchAnalyticsEvent.ts b/packages/react-native-detour/src/analytics/utils/dispatchAnalyticsEvent.ts index dd2379c..dd3e58b 100644 --- a/packages/react-native-detour/src/analytics/utils/dispatchAnalyticsEvent.ts +++ b/packages/react-native-detour/src/analytics/utils/dispatchAnalyticsEvent.ts @@ -1,4 +1,5 @@ import type { DetourStorage } from "../../shared/storage"; +import { sendConversion } from "../api/conversion"; import { sendEvent } from "../api/events"; import { sendRetentionEvent } from "../api/retention"; import type { DetourEvent, DetourEventNames } from "../types"; @@ -12,19 +13,38 @@ export type DispatchAnalyticsEventConfig = { }; export const dispatchAnalyticsEvent = async ( - { eventName, data, isRetention, conversion }: AnalyticsEmitterPayload, + payload: AnalyticsEmitterPayload, { apiKey, appID, storage }: DispatchAnalyticsEventConfig, ): Promise => { try { const analyticsContext = await buildAnalyticsContext(storage); - if (isRetention) { - await sendRetentionEvent({ apiKey, appID, eventName, ...analyticsContext }); + if (payload.kind === "retention") { + await sendRetentionEvent({ + apiKey, + appID, + eventName: payload.eventName, + ...analyticsContext, + }); return; } - const event: DetourEvent = { eventName: eventName as DetourEventNames, data }; - await sendEvent({ apiKey, appID, event, ...analyticsContext, conversion }); + if (payload.kind === "conversion") { + await sendConversion({ + apiKey, + appID, + eventName: payload.eventName, + conversion: payload.conversion, + ...analyticsContext, + }); + return; + } + + const event: DetourEvent = { + eventName: payload.eventName as DetourEventNames, + data: payload.data, + }; + await sendEvent({ apiKey, appID, event, ...analyticsContext }); } catch (error) { console.error( "[Detour:ANALYTICS_ERROR] Analytics disabled due to storage/runtime failure:",