xref: /expo/packages/create-expo/src/telemetry.ts (revision b7d15820)
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