Skip to content

Commit a9c0c24

Browse files
committed
Track appleAdsAttribution to reporting and firstOpenInfo cache
1 parent 81e2d1e commit a9c0c24

File tree

2 files changed

+102
-7
lines changed

2 files changed

+102
-7
lines changed

src/actions/FirstOpenActions.tsx

Lines changed: 98 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,36 @@
1+
import { getAttributionToken } from '@brigad/react-native-adservices'
12
import { asNumber, asObject, asOptional, asString, asValue } from 'cleaners'
23
import { makeReactNativeDisklet } from 'disklet'
4+
import { Platform } from 'react-native'
35

46
import { FIRST_OPEN } from '../constants/constantSettings'
57
import { makeUuid } from '../util/rnUtils'
8+
import { snooze } from '../util/utils'
69
import { getCountryCodeByIp } from './AccountReferralActions'
710

811
export const firstOpenDisklet = makeReactNativeDisklet()
912

10-
const asFirstOpenInfo = asObject({
13+
const asAppleAdsAttribution = asObject({
14+
campaignId: asOptional(asString),
15+
keywordId: asOptional(asString)
16+
})
17+
type AppleAdsAttribution = ReturnType<typeof asAppleAdsAttribution>
18+
19+
interface FirstOpenInfo {
20+
isFirstOpen: 'true' | 'false'
21+
deviceId: string
22+
firstOpenEpoch: number
23+
countryCode?: string
24+
appleAdsAttribution?: AppleAdsAttribution
25+
}
26+
type FirstOpenInfoFile = Omit<FirstOpenInfo, 'appleAdsAttribution'>
27+
28+
const asFirstOpenInfoFile = asObject<FirstOpenInfoFile>({
1129
isFirstOpen: asValue('true', 'false'),
1230
deviceId: asString,
1331
firstOpenEpoch: asNumber,
1432
countryCode: asOptional(asString)
1533
})
16-
type FirstOpenInfo = ReturnType<typeof asFirstOpenInfo>
1734

1835
let firstOpenInfo: FirstOpenInfo
1936
let firstLoadPromise: Promise<FirstOpenInfo> | undefined
@@ -40,11 +57,19 @@ const readFirstOpenInfoFromDisk = async (): Promise<FirstOpenInfo> => {
4057
let firstOpenText
4158
try {
4259
firstOpenText = await firstOpenDisklet.getText(FIRST_OPEN)
43-
firstOpenInfo = asFirstOpenInfo(JSON.parse(firstOpenText))
44-
firstOpenInfo.isFirstOpen = 'false'
60+
// Parse the file data using the file-specific cleaner
61+
const fileData = asFirstOpenInfoFile(JSON.parse(firstOpenText))
62+
// Create the full in-memory object with attribution data
63+
firstOpenInfo = {
64+
...fileData,
65+
isFirstOpen: 'false',
66+
appleAdsAttribution: await getAppleAdsAttribution()
67+
}
4568
} catch (error: unknown) {
4669
// Generate new values.
47-
firstOpenInfo = {
70+
71+
// Create file data object (without attribution)
72+
const fileData: FirstOpenInfoFile = {
4873
deviceId: await makeUuid(),
4974
firstOpenEpoch: Date.now(),
5075
countryCode: await getCountryCodeByIp(),
@@ -53,11 +78,78 @@ const readFirstOpenInfoFromDisk = async (): Promise<FirstOpenInfo> => {
5378
// date, just created an empty file.
5479
// Note that 'firstOpenEpoch' won't be accurate in this case, but at
5580
// least make a starting point.
81+
5682
isFirstOpen: firstOpenText != null ? 'false' : 'true'
5783
}
58-
await firstOpenDisklet.setText(FIRST_OPEN, JSON.stringify(firstOpenInfo))
84+
85+
// Create the full in-memory object
86+
firstOpenInfo = {
87+
...fileData,
88+
appleAdsAttribution: await getAppleAdsAttribution()
89+
}
90+
91+
// Only save the file-specific data to disk
92+
await firstOpenDisklet.setText(FIRST_OPEN, JSON.stringify(fileData))
5993
}
6094
}
6195

6296
return firstOpenInfo
6397
}
98+
99+
/**
100+
* Get Apple Search Ads attribution data using the AdServices framework
101+
* and make an API call to get the actual keywordId.
102+
*/
103+
export async function getAppleAdsAttribution(): Promise<AppleAdsAttribution> {
104+
if (Platform.OS !== 'ios') {
105+
return { campaignId: undefined, keywordId: undefined }
106+
}
107+
108+
// Get the attribution token from the device. This package also handles
109+
// checking for the required iOS version.
110+
const attributionToken = await getAttributionToken().catch(error => {
111+
console.log('Apple Ads attribution token unavailable:', error)
112+
return undefined
113+
})
114+
115+
// Send the token to Apple's API to retrieve the campaign and keyword IDs.
116+
if (attributionToken != null) {
117+
// Retry logic as recommended by Apple:
118+
// "A 404 response can occur if you make an API call too quickly after
119+
// receiving a valid token. A best practice is to initiate retries at
120+
// intervals of 5 seconds, with a maximum of three attempts."
121+
// https://developer.apple.com/documentation/adservices/aaattribution/attributiontoken()#Attribution-payload
122+
const maxRetries = 3
123+
const retryDelay = 5000 // 5 seconds
124+
125+
for (let attempt = 1; attempt <= maxRetries; attempt++) {
126+
try {
127+
// Get the attribution data from Apple for the token
128+
const response = await fetch('https://api-adservices.apple.com/api/v1/', {
129+
method: 'POST',
130+
headers: {
131+
'Content-Type': 'text/plain'
132+
},
133+
body: attributionToken
134+
})
135+
136+
// If we get a 404, wait and retry as per Apple's recommendation
137+
if (response.status === 404 && attempt < maxRetries) {
138+
console.log(`Apple Ads attribution API returned 404, retrying in ${retryDelay}ms (attempt ${attempt}/${maxRetries})`)
139+
await snooze(retryDelay)
140+
continue
141+
}
142+
143+
if (!response.ok) throw new Error(`API call failed with status: ${response.status}`)
144+
145+
const data = await response.json()
146+
return asAppleAdsAttribution(data)
147+
} catch (apiError) {
148+
console.warn('Error fetching Apple Ads attribution data:', apiError)
149+
break
150+
}
151+
}
152+
}
153+
154+
return { campaignId: undefined, keywordId: undefined }
155+
}

src/util/tracking.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,7 @@ export interface TrackingValues extends LoginTrackingValues {
139139
numAccounts?: number // Number of full accounts saved on the device
140140
surveyCategory2?: string // User's answer to a survey (first tier response)
141141
surveyResponse2?: string // User's answer to a survey
142+
appleAdsKeywordId?: string // Apple Search Ads attribution keyword ID
142143

143144
// Conversion values
144145
conversionValues?: DollarConversionValues | CryptoConversionValues | SellConversionValues | BuyConversionValues | SwapConversionValues
@@ -201,12 +202,13 @@ export function trackError(
201202
/**
202203
* Send a raw event to all backends.
203204
*/
205+
204206
export function logEvent(event: TrackingEventName, values: TrackingValues = {}): ThunkAction<void> {
205207
return async (dispatch, getState) => {
206208
getExperimentConfig()
207209
.then(async (experimentConfig: ExperimentConfig) => {
208210
// Persistent & Unchanged params:
209-
const { isFirstOpen, deviceId, firstOpenEpoch } = await getFirstOpenInfo()
211+
const { isFirstOpen, deviceId, firstOpenEpoch, appleAdsAttribution } = await getFirstOpenInfo()
210212

211213
const { error, createdWalletCurrencyCode, conversionValues, ...restValue } = values
212214
const params: any = {
@@ -215,6 +217,7 @@ export function logEvent(event: TrackingEventName, values: TrackingValues = {}):
215217
isFirstOpen,
216218
deviceId,
217219
firstOpenEpoch,
220+
appleAdsAttribution,
218221
...restValue
219222
}
220223

0 commit comments

Comments
 (0)