Skip to content

Commit b01846b

Browse files
authored
[BA-2104] Functional Telemetry in SDK (#1654)
* inline script * fix inline script * opt-out * renamed to telemetry * space * test
1 parent dd2c87a commit b01846b

File tree

14 files changed

+869
-7
lines changed

14 files changed

+869
-7
lines changed

packages/wallet-sdk/compile-assets.cjs

Lines changed: 33 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,19 +3,49 @@
33
const fs = require('fs');
44
const glob = require('glob');
55
const sass = require('sass');
6+
const path = require('path');
67

78
async function main() {
8-
// compile SCSS
9+
// ================================
10+
// Compiling SCSS
11+
// ================================
912
const scssFiles = glob.sync(`${__dirname}/src/**/*.scss`);
1013
for (const filePath of scssFiles) {
11-
console.info(`Compiling ${filePath}...`);
14+
console.info(`\nCompiling SCSS\n${filePath}...`);
1215
const css = sass.renderSync({ file: filePath, outputStyle: 'compressed' }).css.toString('utf8');
1316
const ts = `export default (() => \`${css}\`)();`;
1417
fs.writeFileSync(filePath.replace(/\.scss$/, '-css.ts'), ts, {
1518
mode: 0o644,
1619
});
1720
}
18-
console.info('DONE!');
21+
22+
// ================================
23+
// Compiling Telemetry Script
24+
// ================================
25+
const telemetrySource = path.join(__dirname, 'src/vendor-js/CCA/ca.js');
26+
const telemetryOutputTs = path.join(__dirname, 'src/core/telemetry/telemetry-content.ts');
27+
if (fs.existsSync(telemetrySource)) {
28+
console.info('\nGenerating Stringified Telemetry Script...');
29+
const telemetryContent = fs.readFileSync(telemetrySource, 'utf8');
30+
31+
// Escape backticks and backslashes for template literal
32+
const escapedContent = telemetryContent
33+
.replace(/\\/g, '\\\\') // Escape backslashes
34+
.replace(/`/g, '\\`') // Escape backticks
35+
.replace(/\$\{/g, '\\${'); // Escape template literal expressions
36+
37+
// Generate TypeScript file with stringified content
38+
const tsContent = `// This file is auto-generated by compile-assets.cjs
39+
// Do not edit manually - changes will be overwritten
40+
41+
export const TELEMETRY_SCRIPT_CONTENT = \`${escapedContent}\`;
42+
`;
43+
44+
fs.writeFileSync(telemetryOutputTs, tsContent, { mode: 0o644 });
45+
console.info(`\nGenerated Stringified Telemetry Script: ${telemetryOutputTs}`);
46+
}
47+
48+
console.info('\nAsset compilation complete!');
1949
}
2050

2151
main();

packages/wallet-sdk/src/CoinbaseWalletSDK.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
1-
// Copyright (c) 2018-2024 Coinbase, Inc. <https://www.coinbase.com/>
2-
1+
import { loadTelemetryScript } from ':core/telemetry/initCCA.js';
32
import { getFavicon } from ':core/type/util.js';
43
import { store } from ':store/store.js';
54
import { checkCrossOriginOpenerPolicy } from ':util/checkCrossOriginOpenerPolicy.js';
@@ -43,6 +42,9 @@ export class CoinbaseWalletSDK {
4342
}
4443
): ProviderInterface {
4544
validatePreferences(preference);
45+
if (preference.telemetry !== false) {
46+
void loadTelemetryScript();
47+
}
4648
store.config.set({
4749
preference,
4850
});

packages/wallet-sdk/src/core/provider/interface.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,11 @@ export type Preference = {
8282
* the Smart Wallet will generate a 16 byte hex string from the apps origin.
8383
*/
8484
attribution?: Attribution;
85+
/**
86+
* Whether to enable functional telemetry.
87+
* @default true
88+
*/
89+
telemetry?: boolean;
8590
} & Record<string, unknown>;
8691

8792
export type SubAccountOptions = {
Lines changed: 253 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,253 @@
1+
import { store } from ':store/store.js';
2+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
3+
import { loadTelemetryScript } from './initCCA.js';
4+
5+
vi.mock('./telemetry-content.js', () => ({
6+
TELEMETRY_SCRIPT_CONTENT: 'mock-telemetry-script-content',
7+
}));
8+
9+
vi.mock(':store/store.js', () => ({
10+
store: {
11+
config: {
12+
get: vi.fn(),
13+
set: vi.fn(),
14+
},
15+
},
16+
}));
17+
18+
const mockStore = store as any;
19+
20+
describe('initCCA', () => {
21+
let mockClientAnalytics: any;
22+
let originalDocument: Document;
23+
let originalWindow: Window & typeof globalThis;
24+
let mockCrypto: any;
25+
26+
beforeEach(() => {
27+
mockClientAnalytics = {
28+
init: vi.fn(),
29+
identify: vi.fn(),
30+
PlatformName: {
31+
web: 'web',
32+
},
33+
};
34+
35+
mockCrypto = {
36+
randomUUID: vi.fn().mockReturnValue('mock-uuid-123'),
37+
};
38+
39+
originalDocument = global.document;
40+
originalWindow = global.window;
41+
42+
const mockScript = {
43+
textContent: '',
44+
type: '',
45+
};
46+
47+
const mockHead = {
48+
appendChild: vi.fn(),
49+
removeChild: vi.fn(),
50+
};
51+
52+
global.document = {
53+
createElement: vi.fn().mockReturnValue(mockScript),
54+
head: mockHead,
55+
} as any;
56+
57+
global.window = {
58+
crypto: mockCrypto,
59+
ClientAnalytics: undefined,
60+
} as any;
61+
62+
mockStore.config.get.mockReturnValue({});
63+
mockStore.config.set.mockImplementation(() => {});
64+
65+
vi.clearAllMocks();
66+
});
67+
68+
afterEach(() => {
69+
global.document = originalDocument;
70+
global.window = originalWindow;
71+
delete (global.window as any).ClientAnalytics;
72+
});
73+
74+
describe('loadTelemetryScript', () => {
75+
it('should create and execute telemetry script when ClientAnalytics does not exist', async () => {
76+
const mockScript = {
77+
textContent: '',
78+
type: '',
79+
};
80+
81+
(global.document.createElement as any).mockReturnValue(mockScript);
82+
83+
const mockAppendChild = vi.fn().mockImplementation(() => {
84+
(global.window as any).ClientAnalytics = mockClientAnalytics;
85+
});
86+
87+
global.document.head.appendChild = mockAppendChild;
88+
89+
const result = await loadTelemetryScript();
90+
91+
expect(result).toBeUndefined();
92+
expect(global.document.createElement).toHaveBeenCalledWith('script');
93+
expect(mockScript.textContent).toBe('mock-telemetry-script-content');
94+
expect(mockScript.type).toBe('text/javascript');
95+
expect(mockAppendChild).toHaveBeenCalledWith(mockScript);
96+
expect(global.document.head.removeChild).toHaveBeenCalledWith(mockScript);
97+
});
98+
99+
it('should initialize ClientAnalytics with correct parameters after loading script', async () => {
100+
const mockScript = {
101+
textContent: '',
102+
type: '',
103+
};
104+
105+
(global.document.createElement as any).mockReturnValue(mockScript);
106+
107+
const mockAppendChild = vi.fn().mockImplementation(() => {
108+
(global.window as any).ClientAnalytics = mockClientAnalytics;
109+
});
110+
111+
global.document.head.appendChild = mockAppendChild;
112+
113+
await loadTelemetryScript();
114+
115+
expect(mockClientAnalytics.init).toHaveBeenCalledWith({
116+
isProd: true,
117+
amplitudeApiKey: 'c66737ad47ec354ced777935b0af822e',
118+
platform: 'web',
119+
projectName: 'base_account_sdk',
120+
showDebugLogging: false,
121+
version: '1.0.0',
122+
apiEndpoint: 'https://cca-lite.coinbase.com',
123+
});
124+
});
125+
126+
it('should use deviceId from store.config.get() when available', async () => {
127+
const mockScript = {
128+
textContent: '',
129+
type: '',
130+
};
131+
132+
(global.document.createElement as any).mockReturnValue(mockScript);
133+
mockStore.config.get.mockReturnValue({ deviceId: 'store-device-id-123' });
134+
135+
const mockAppendChild = vi.fn().mockImplementation(() => {
136+
(global.window as any).ClientAnalytics = mockClientAnalytics;
137+
});
138+
139+
global.document.head.appendChild = mockAppendChild;
140+
141+
await loadTelemetryScript();
142+
143+
expect(mockClientAnalytics.identify).toHaveBeenCalledWith({
144+
deviceId: 'store-device-id-123',
145+
});
146+
expect(mockStore.config.set).toHaveBeenCalledWith({
147+
deviceId: 'store-device-id-123',
148+
});
149+
expect(mockCrypto.randomUUID).not.toHaveBeenCalled();
150+
});
151+
152+
it('should fall back to crypto.randomUUID when store deviceId is not available', async () => {
153+
const mockScript = {
154+
textContent: '',
155+
type: '',
156+
};
157+
158+
(global.document.createElement as any).mockReturnValue(mockScript);
159+
mockStore.config.get.mockReturnValue({});
160+
161+
const mockAppendChild = vi.fn().mockImplementation(() => {
162+
(global.window as any).ClientAnalytics = mockClientAnalytics;
163+
});
164+
165+
global.document.head.appendChild = mockAppendChild;
166+
167+
await loadTelemetryScript();
168+
169+
expect(mockClientAnalytics.identify).toHaveBeenCalledWith({
170+
deviceId: 'mock-uuid-123',
171+
});
172+
expect(mockStore.config.set).toHaveBeenCalledWith({
173+
deviceId: 'mock-uuid-123',
174+
});
175+
expect(mockCrypto.randomUUID).toHaveBeenCalled();
176+
});
177+
178+
it('should handle case when both store deviceId and crypto.randomUUID are not available', async () => {
179+
const mockScript = {
180+
textContent: '',
181+
type: '',
182+
};
183+
184+
(global.document.createElement as any).mockReturnValue(mockScript);
185+
mockStore.config.get.mockReturnValue({});
186+
(global.window as any).crypto = undefined;
187+
188+
const mockAppendChild = vi.fn().mockImplementation(() => {
189+
(global.window as any).ClientAnalytics = mockClientAnalytics;
190+
});
191+
192+
global.document.head.appendChild = mockAppendChild;
193+
194+
await loadTelemetryScript();
195+
196+
expect(mockClientAnalytics.identify).toHaveBeenCalledWith({
197+
deviceId: '',
198+
});
199+
expect(mockStore.config.set).toHaveBeenCalledWith({
200+
deviceId: '',
201+
});
202+
});
203+
204+
it('should handle case when store deviceId is null', async () => {
205+
const mockScript = {
206+
textContent: '',
207+
type: '',
208+
};
209+
210+
(global.document.createElement as any).mockReturnValue(mockScript);
211+
mockStore.config.get.mockReturnValue({ deviceId: null });
212+
213+
const mockAppendChild = vi.fn().mockImplementation(() => {
214+
(global.window as any).ClientAnalytics = mockClientAnalytics;
215+
});
216+
217+
global.document.head.appendChild = mockAppendChild;
218+
219+
await loadTelemetryScript();
220+
221+
expect(mockClientAnalytics.identify).toHaveBeenCalledWith({
222+
deviceId: 'mock-uuid-123',
223+
});
224+
expect(mockStore.config.set).toHaveBeenCalledWith({
225+
deviceId: 'mock-uuid-123',
226+
});
227+
expect(mockCrypto.randomUUID).toHaveBeenCalled();
228+
});
229+
230+
it('should reject promise when script execution fails', async () => {
231+
const mockScript = {
232+
textContent: '',
233+
type: '',
234+
};
235+
236+
(global.document.createElement as any).mockReturnValue(mockScript);
237+
238+
const mockAppendChild = vi.fn().mockImplementation(() => {
239+
throw new Error('Script execution failed');
240+
});
241+
242+
global.document.head.appendChild = mockAppendChild;
243+
244+
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
245+
246+
await expect(loadTelemetryScript()).rejects.toBeUndefined();
247+
248+
expect(consoleSpy).toHaveBeenCalledWith('Failed to execute inlined telemetry script');
249+
250+
consoleSpy.mockRestore();
251+
});
252+
});
253+
});
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { store } from ':store/store.js';
2+
import { TELEMETRY_SCRIPT_CONTENT } from './telemetry-content.js';
3+
4+
export const loadTelemetryScript = (): Promise<void> => {
5+
return new Promise((resolve, reject) => {
6+
if (window.ClientAnalytics) {
7+
return resolve();
8+
}
9+
10+
try {
11+
const script = document.createElement('script');
12+
script.textContent = TELEMETRY_SCRIPT_CONTENT;
13+
script.type = 'text/javascript';
14+
document.head.appendChild(script);
15+
16+
initCCA();
17+
18+
document.head.removeChild(script);
19+
resolve();
20+
} catch {
21+
console.error('Failed to execute inlined telemetry script');
22+
reject();
23+
}
24+
});
25+
};
26+
27+
const initCCA = () => {
28+
if (typeof window !== 'undefined') {
29+
const deviceId = store.config.get().deviceId ?? window.crypto?.randomUUID() ?? '';
30+
31+
if (window.ClientAnalytics) {
32+
const { init, identify, PlatformName } = window.ClientAnalytics;
33+
34+
init({
35+
isProd: true,
36+
amplitudeApiKey: 'c66737ad47ec354ced777935b0af822e',
37+
platform: PlatformName.web,
38+
projectName: 'base_account_sdk',
39+
showDebugLogging: false,
40+
version: '1.0.0',
41+
apiEndpoint: 'https://cca-lite.coinbase.com',
42+
});
43+
44+
identify({ deviceId });
45+
store.config.set({ deviceId });
46+
}
47+
}
48+
};

0 commit comments

Comments
 (0)