Skip to content
Open
Show file tree
Hide file tree
Changes from 15 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 12 additions & 3 deletions examples/expo-bare/app.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,12 @@
"bundleIdentifier": "detourreactnative.expobare",
"supportsTablet": true,
"icon": "./assets/detour-logo.png",
"associatedDomains": ["applinks:<your-org>.godetour.link"]
"associatedDomains": [
"applinks:<your-org>.godetour.link"
],
"infoPlist": {
"NSUserTrackingUsageDescription": "This identifier will be used to deliver personalized ads and measure their effectiveness."
}
},
"android": {
"package": "detourreactnative.expobare",
Expand All @@ -31,7 +36,10 @@
"pathPrefix": "/<your-app-hash>"
}
],
"category": ["BROWSABLE", "DEFAULT"]
"category": [
"BROWSABLE",
"DEFAULT"
]
}
]
},
Expand All @@ -47,7 +55,8 @@
"imageWidth": 120
}
}
]
],
"expo-tracking-transparency"
],
"experiments": {
"reactCompiler": true
Expand Down
3 changes: 3 additions & 0 deletions examples/expo-bare/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,12 @@
"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-splash-screen": "~55.0.15",
"expo-tracking-transparency": "~55.0.11",
"react": "19.2.0",
"react-dom": "19.2.0",
"react-native": "0.83.4",
Expand Down
71 changes: 69 additions & 2 deletions examples/expo-bare/src/Screen.tsx
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -76,6 +80,69 @@ export const Screen = () => {
</Text>

{link?.params && <Text style={styles.code}>{JSON.stringify(link.params, null, 2)}</Text>}

<View style={styles.divider} />

<Text style={styles.sectionHeader}>Test Actions</Text>
<Text style={styles.bullet}>
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.
</Text>

<Pressable onPress={() => DetourAnalytics.setUserId("test-user-123")}>
<Text style={styles.linkButton}>Set test customer_user_id</Text>
</Pressable>

<Pressable
onPress={() => DetourAnalytics.logEvent(DetourEventNames.Purchase, { test: true })}
>
<Text style={styles.linkButton}>Log test event (purchase)</Text>
</Pressable>

<Pressable
onPress={() =>
DetourAnalytics.logConversion({
revenue: 9.99,
currency: "USD",
productId: "test_sku_1",
quantity: 1,
transactionId: "test-txn-001",
})
}
>
<Text style={styles.linkButton}>Log test conversion (revenue)</Text>
</Pressable>

<Pressable
onPress={() =>
DetourAnalytics.setConsent({ ad: true, analytics: true, tracking: true })
}
>
<Text style={styles.linkButton}>Set consent: all granted</Text>
</Pressable>

<Pressable
onPress={() =>
DetourAnalytics.setConsent({ ad: false, analytics: false, tracking: false })
}
>
<Text style={styles.linkButton}>Set consent: all denied</Text>
</Pressable>

<Pressable
onPress={() => DetourAnalytics.setAdvertisingId("11111111-2222-3333-4444-555555555555")}
>
<Text style={styles.linkButton}>Override advertising id (manual)</Text>
</Pressable>

<Pressable onPress={() => DetourAnalytics.setTrackingAuthorizationStatus("granted")}>
<Text style={styles.linkButton}>Override ATT status: granted</Text>
</Pressable>

<Pressable onPress={() => DetourAnalytics.setTrackingAuthorizationStatus("denied")}>
<Text style={styles.linkButton}>Override ATT status: denied</Text>
</Pressable>
</View>
</ScrollView>
</View>
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
},
"pnpm": {
"overrides": {
"@react-native/babel-preset": "0.83.4",

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why do we need it?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That was needed to keep test app working, but after cleanup of testing app i will also get rid of this dependency

"metro": "0.83.5",
"metro-babel-transformer": "0.83.5",
"metro-cache": "0.83.5",
Expand Down
124 changes: 91 additions & 33 deletions packages/react-native-detour/src/DetourContext.tsx
Comment thread
SikoraKam marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,25 @@ 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 { 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";
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";

type Props = PropsWithChildren & { config: Config };
Expand All @@ -32,49 +43,95 @@ 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++;

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);
Comment thread
SikoraKam marked this conversation as resolved.
Outdated
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) {
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 = await prepareDeviceIdForApi(storage);

if (isRetention) {
sendRetentionEvent({ apiKey, appID, eventName, deviceId });
} else {
const event: DetourEvent = {
eventName: eventName as DetourEventNames,
data,
};
sendEvent({
apiKey,
appID,
event,
deviceId,
});
}
} catch (error) {
console.error(
"[Detour:ANALYTICS_ERROR] Analytics disabled due to storage/runtime failure:",
error,
);
}
});
},
);

return () => {
activeProviderCount--;
Expand All @@ -90,6 +147,7 @@ const DetourProviderNative = ({ config, children }: Props) => {
linkProcessingMode,
});
useAppOpenRetention();
useSessionTracking();

return <DetourContext.Provider value={value}>{children}</DetourContext.Provider>;
};
Expand Down
24 changes: 24 additions & 0 deletions packages/react-native-detour/src/analytics/analytics.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
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";

export const logEvent = (eventName: DetourEventNames | `${DetourEventNames}`, data?: any) => {
analyticsEmitter.emit({ eventName, data });
Expand All @@ -9,7 +13,27 @@ 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 });
};

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

don't assume such things when you create tool for community and you don't know the use case
remove comment and make event name required with no default or completly let's separate it from our events and let's make new separate signal with new backend endpoint

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're right. We should consider which option is better.
Option 1:
no backend work needed, ships now; conversions automatically get all the same enrichment (device_id, idfv/aaid, customer_user_id, consent, session_id, etc.) for free; stays in one unified event timeline, so funnel/session analysis doesn't need joining two tables

but revenue fields stay as nullable columns on the generic events table — gets wider/sparser over time as more signal types get added.

Option 2:
clean, dedicated schema for conversions; easier to add revenue-specific stuff later (receipt validation, transaction_id dedup, refunds); matches how other MMPs (AppsFlyer/Branch) structure this

but needs new backend work (endpoint + table), blocks this PR; have to duplicate the enrichment logic for the new endpoint; breaks the single event timeline, so funnels need to join across tables

export const DetourAnalytics = {
logEvent,
logRetention,
logConversion,
setUserId,
setConsent,
setAdvertisingId,
setTrackingAuthorizationStatus,
};
Loading
Loading