1import JsonFile from '@expo/json-file'; 2import crypto from 'crypto'; 3import fs from 'fs'; 4import fetch from 'node-fetch'; 5import os from 'os'; 6 7import { dotExpoHomeDirectory, getStateJsonPath } from './paths'; 8import { getSession } from './sessionStorage'; 9import { env } from './utils/env'; 10 11const packageJSON = require('../package.json'); 12 13const xdlUnifiedWriteKey = '1wabJGd5IiuF9Q8SGlcI90v8WTs'; 14const analyticsEndpoint = 'https://cdp.expo.dev/v1/batch'; 15const version = '1.0.0'; 16const library = packageJSON.name; 17 18//#region mostly copied from @expo/rudder-sdk-node https://github.com/expo/rudder-sdk-node/blob/main/index.ts 19// some changes include: 20// - the identity being injected inside of the enqueue method, rather than as a function argument. 21// - a global event queue that gets cleared after each flush 22// - no support for large message queues and concurrent flushes 23// - using node's crypto library for hashing and uuidv4 (available on node14+) 24type AnalyticsPayload = { 25 messageId: string; 26 _metadata: any; 27 context: any; 28 type: string; 29 originalTimestamp: Date; 30 [key: string]: any; 31}; 32type AnalyticsMessage = { 33 context?: { 34 [key: string]: unknown; 35 }; 36 integrations?: { 37 [destination: string]: boolean; 38 }; 39 properties?: { 40 [key: string]: unknown; 41 }; 42 timestamp?: Date; 43 [key: string]: unknown; 44}; 45type AnalyticsIdentity = 46 | { 47 userId: string; 48 } 49 | { 50 userId?: string; 51 anonymousId: string; 52 }; 53 54const messageBatch = [] as AnalyticsPayload[]; 55 56let analyticsIdentity: AnalyticsIdentity | null = null; 57 58// jest does not clear global variables inbetween tests so we need this helper 59export function _resetGlobals() { 60 if (env.EXPO_NO_TELEMETRY) return; 61 62 analyticsIdentity = null; 63 messageBatch.splice(0, messageBatch.length); 64} 65 66// call before tracking any analytics events. 67// if track/identify are called before this method they will be dropped 68export async function initializeAnalyticsIdentityAsync() { 69 if (env.EXPO_NO_TELEMETRY) return; 70 71 if (analyticsIdentity) { 72 return; 73 } 74 analyticsIdentity = await getAnalyticsIdentityAsync(); 75} 76 77export function identify() { 78 if (env.EXPO_NO_TELEMETRY) return; 79 enqueue('identify', {}); 80} 81 82export function track( 83 message: AnalyticsMessage & { 84 event: string; 85 } 86) { 87 if (env.EXPO_NO_TELEMETRY) return; 88 enqueue('track', { ...message, context: getAnalyticsContext() }); 89} 90 91function enqueue(type: 'identify' | 'track', message: any) { 92 if (!analyticsIdentity) { 93 // do not send messages without identities to our backend 94 return; 95 } 96 97 message = { ...message, ...analyticsIdentity }; 98 message.type = type; 99 100 if (message.type === 'identify') { 101 message.traits ??= {}; 102 message.context ??= {}; 103 message.context.traits = message.traits; 104 } 105 106 message.context = { 107 library: { 108 name: library, 109 version, 110 }, 111 ...message.context, 112 }; 113 114 message._metadata = { 115 nodeVersion: process.versions.node, 116 ...message._metadata, 117 }; 118 119 if (!message.originalTimestamp) { 120 message.originalTimestamp = new Date(); 121 } 122 123 if (!message.messageId) { 124 // We md5 the messaage to add more randomness. This is primarily meant 125 // for use in the browser where the uuid package falls back to Math.random() 126 // which is not a great source of randomness. 127 // Borrowed from analytics.js (https://github.com/segment-integrations/analytics.js-integration-segmentio/blob/a20d2a2d222aeb3ab2a8c7e72280f1df2618440e/lib/index.js#L255-L256). 128 message.messageId = `node-${crypto 129 .createHash('md5') 130 .update(JSON.stringify(message)) 131 .digest('hex')}-${uuidv4()}`; 132 } 133 messageBatch.push(message); 134} 135 136// very barebones implemention... 137// does not support multiple concurrent flushes or large numbers of messages 138export async function flushAsync() { 139 if (env.EXPO_NO_TELEMETRY) return; 140 141 if (!messageBatch.length) { 142 return; 143 } 144 145 const request = { 146 method: 'POST', 147 headers: { 148 accept: 'application/json, text/plain, */*', 149 'content-type': 'application/json;charset=utf-8', 150 'user-agent': `${library}/${version}`, 151 authorization: 'Basic ' + Buffer.from(`${xdlUnifiedWriteKey}:`).toString('base64'), 152 }, 153 body: JSON.stringify({ 154 batch: messageBatch.map((message) => ({ ...message, sentAt: new Date() })), 155 sentAt: new Date(), 156 }), 157 }; 158 try { 159 await fetch(analyticsEndpoint, request); 160 } catch { 161 // supress errors - likely due to network connectivity or endpoint health 162 } 163 // clear array so we don't resend events in subsequent flushes 164 messageBatch.splice(0, messageBatch.length); 165} 166//#endregion 167 168//#region copied from eas cli https://github.com/expo/eas-cli/blob/f0c958e58bc7aa90ee8f822e075d40703563708e/packages/eas-cli/src/analytics/rudderstackClient.ts#L9-L13 169const PLATFORM_TO_ANALYTICS_PLATFORM: { [platform: string]: string } = { 170 darwin: 'Mac', 171 win32: 'Windows', 172 linux: 'Linux', 173}; 174 175function getAnalyticsContext(): Record<string, any> { 176 const platform = PLATFORM_TO_ANALYTICS_PLATFORM[os.platform()] || os.platform(); 177 return { 178 os: { name: platform, version: os.release() }, 179 device: { type: platform, model: platform }, 180 app: { name: library, version: packageJSON.version }, 181 }; 182} 183//#endregion 184 185function uuidv4() { 186 try { 187 // available on node 14+ 188 // https://github.com/denoland/deno/issues/12754 189 return (crypto as any).randomUUID(); 190 } catch { 191 // supress errors due to node 13 or less not having randomUUID 192 return null; 193 } 194} 195 196export enum AnalyticsEventTypes { 197 CREATE_EXPO_APP = 'create expo app', 198} 199 200export enum AnalyticsEventPhases { 201 ATTEMPT = 'attempt', 202 SUCCESS = 'success', 203 FAIL = 'fail', 204} 205 206async function getAnalyticsIdentityAsync(): Promise<AnalyticsIdentity | null> { 207 if (!fs.existsSync(dotExpoHomeDirectory())) { 208 fs.mkdirSync(dotExpoHomeDirectory(), { recursive: true }); 209 } 210 if (!fs.existsSync(getStateJsonPath())) { 211 fs.writeFileSync(getStateJsonPath(), JSON.stringify({})); 212 } 213 const savedDeviceId = await JsonFile.getAsync(getStateJsonPath(), 'analyticsDeviceId', null); 214 const deviceId = savedDeviceId ?? uuidv4(); 215 216 if (!deviceId) { 217 // unable to generate an id or load one from disk 218 return null; 219 } 220 if (!savedDeviceId) { 221 await JsonFile.setAsync(getStateJsonPath(), 'analyticsDeviceId', deviceId); 222 } 223 const userId = getSession()?.userId ?? null; 224 return userId ? { anonymousId: deviceId, userId } : { anonymousId: deviceId }; 225} 226