Skip to content

Commit c6dde65

Browse files
authored
Merge pull request #6184 from Shopify/theme-analytics
Introduce theme analytics component to improve o11y
2 parents f93fc60 + a744dda commit c6dde65

File tree

10 files changed

+1327
-1
lines changed

10 files changed

+1327
-1
lines changed
Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
import {recordTiming, recordError, recordRetry, recordEvent} from './analytics.js'
2+
import * as store from './analytics/storage.js'
3+
import {describe, test, expect, vi} from 'vitest'
4+
5+
vi.mock('./analytics/storage.js', () => ({
6+
recordTiming: vi.fn(),
7+
recordError: vi.fn(),
8+
recordRetry: vi.fn(),
9+
recordEvent: vi.fn(),
10+
}))
11+
12+
describe('analytics/index', () => {
13+
describe('recordTiming', () => {
14+
test('delegates to store.recordTiming', () => {
15+
// Given
16+
const eventName = 'test-timing-event'
17+
18+
// When
19+
recordTiming(eventName)
20+
21+
// Then
22+
expect(store.recordTiming).toHaveBeenCalledOnce()
23+
expect(store.recordTiming).toHaveBeenCalledWith(eventName)
24+
})
25+
26+
test('passes through different event names correctly', () => {
27+
// When
28+
recordTiming('event-1')
29+
recordTiming('event-2')
30+
recordTiming('another-event')
31+
32+
// Then
33+
expect(store.recordTiming).toHaveBeenCalledTimes(3)
34+
expect(store.recordTiming).toHaveBeenNthCalledWith(1, 'event-1')
35+
expect(store.recordTiming).toHaveBeenNthCalledWith(2, 'event-2')
36+
expect(store.recordTiming).toHaveBeenNthCalledWith(3, 'another-event')
37+
})
38+
})
39+
40+
describe('recordError', () => {
41+
test('delegates to store.recordError with Error object', () => {
42+
// Given
43+
const error = new Error('Test error message')
44+
45+
// When
46+
recordError(error)
47+
48+
// Then
49+
expect(store.recordError).toHaveBeenCalledOnce()
50+
expect(store.recordError).toHaveBeenCalledWith(error)
51+
})
52+
53+
test('delegates to store.recordError with string', () => {
54+
// Given
55+
const errorString = 'String error message'
56+
57+
// When
58+
recordError(errorString)
59+
60+
// Then
61+
expect(store.recordError).toHaveBeenCalledOnce()
62+
expect(store.recordError).toHaveBeenCalledWith(errorString)
63+
})
64+
65+
test('delegates to store.recordError with arbitrary objects', () => {
66+
// Given
67+
const errorObj = {code: 'ERR_001', message: 'Custom error'}
68+
69+
// When
70+
recordError(errorObj)
71+
72+
// Then
73+
expect(store.recordError).toHaveBeenCalledOnce()
74+
expect(store.recordError).toHaveBeenCalledWith(errorObj)
75+
})
76+
77+
test('passes through null and undefined', () => {
78+
// When
79+
recordError(null)
80+
recordError(undefined)
81+
82+
// Then
83+
expect(store.recordError).toHaveBeenCalledTimes(2)
84+
expect(store.recordError).toHaveBeenNthCalledWith(1, null)
85+
expect(store.recordError).toHaveBeenNthCalledWith(2, undefined)
86+
})
87+
})
88+
89+
describe('recordRetry', () => {
90+
test('delegates to store.recordRetry', () => {
91+
// Given
92+
const url = 'https://api.example.com/themes'
93+
const operation = 'upload'
94+
95+
// When
96+
recordRetry(url, operation)
97+
98+
// Then
99+
expect(store.recordRetry).toHaveBeenCalledOnce()
100+
expect(store.recordRetry).toHaveBeenCalledWith(url, operation)
101+
})
102+
103+
test('passes through different URLs and operations', () => {
104+
// When
105+
recordRetry('https://api1.com', 'upload')
106+
recordRetry('https://api2.com', 'download')
107+
recordRetry('https://api3.com', 'sync')
108+
109+
// Then
110+
expect(store.recordRetry).toHaveBeenCalledTimes(3)
111+
expect(store.recordRetry).toHaveBeenNthCalledWith(1, 'https://api1.com', 'upload')
112+
expect(store.recordRetry).toHaveBeenNthCalledWith(2, 'https://api2.com', 'download')
113+
expect(store.recordRetry).toHaveBeenNthCalledWith(3, 'https://api3.com', 'sync')
114+
})
115+
116+
test('handles empty strings', () => {
117+
// When
118+
recordRetry('', '')
119+
120+
// Then
121+
expect(store.recordRetry).toHaveBeenCalledOnce()
122+
expect(store.recordRetry).toHaveBeenCalledWith('', '')
123+
})
124+
})
125+
126+
describe('recordEvent', () => {
127+
test('delegates to store.recordEvent', () => {
128+
// Given
129+
const eventName = 'custom-event'
130+
131+
// When
132+
recordEvent(eventName)
133+
134+
// Then
135+
expect(store.recordEvent).toHaveBeenCalledOnce()
136+
expect(store.recordEvent).toHaveBeenCalledWith(eventName)
137+
})
138+
139+
test('passes through various event names', () => {
140+
// When
141+
recordEvent('theme-dev-started')
142+
recordEvent('file-watcher-connected')
143+
recordEvent('user-action:save')
144+
recordEvent('system-event:reload')
145+
146+
// Then
147+
expect(store.recordEvent).toHaveBeenCalledTimes(4)
148+
expect(store.recordEvent).toHaveBeenNthCalledWith(1, 'theme-dev-started')
149+
expect(store.recordEvent).toHaveBeenNthCalledWith(2, 'file-watcher-connected')
150+
expect(store.recordEvent).toHaveBeenNthCalledWith(3, 'user-action:save')
151+
expect(store.recordEvent).toHaveBeenNthCalledWith(4, 'system-event:reload')
152+
})
153+
154+
test('handles special characters in event names', () => {
155+
// When
156+
recordEvent('event:with:colons')
157+
recordEvent('event-with-dashes')
158+
recordEvent('event_with_underscores')
159+
recordEvent('event.with.dots')
160+
161+
// Then
162+
expect(store.recordEvent).toHaveBeenCalledTimes(4)
163+
})
164+
})
165+
166+
describe('public API integration', () => {
167+
test('all functions are properly exported and callable', () => {
168+
// When
169+
// Then
170+
expect(typeof recordTiming).toBe('function')
171+
expect(typeof recordError).toBe('function')
172+
expect(typeof recordRetry).toBe('function')
173+
expect(typeof recordEvent).toBe('function')
174+
})
175+
176+
test('functions can be called in sequence', () => {
177+
// When
178+
recordEvent('operation-start')
179+
recordTiming('file-upload')
180+
recordRetry('https://api.example.com', 'upload')
181+
recordError(new Error('Upload failed'))
182+
recordTiming('file-upload')
183+
recordEvent('operation-end')
184+
185+
// Then
186+
expect(store.recordEvent).toHaveBeenCalledTimes(2)
187+
expect(store.recordTiming).toHaveBeenCalledTimes(2)
188+
expect(store.recordRetry).toHaveBeenCalledTimes(1)
189+
expect(store.recordError).toHaveBeenCalledTimes(1)
190+
})
191+
})
192+
})
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import {
2+
recordTiming as storeRecordTiming,
3+
recordError as storeRecordError,
4+
recordRetry as storeRecordRetry,
5+
recordEvent as storeRecordEvent,
6+
} from './analytics/storage.js'
7+
8+
/**
9+
* Records timing data for performance monitoring. Call twice with the same
10+
* event name to start and stop timing. First call starts the timer, second
11+
* call stops it and records the duration.
12+
*
13+
* @example
14+
* ```ts
15+
* recordTiming('theme-upload') // Start timing
16+
* // ... do work ...
17+
* recordTiming('theme-upload') // Stop timing and record duration
18+
* ```
19+
*
20+
* @param eventName - Unique identifier for the timing event
21+
*/
22+
export function recordTiming(eventName: string): void {
23+
storeRecordTiming(eventName)
24+
}
25+
26+
/**
27+
* Records error information for debugging and monitoring. Use this to track
28+
* any exceptions or error conditions that occur during theme operations.
29+
* Errors are automatically categorized for easier analysis.
30+
*
31+
* @example
32+
* ```ts
33+
* try {
34+
* // ... risky operation ...
35+
* } catch (error) {
36+
* recordError(error)
37+
* }
38+
* ```
39+
*
40+
* @param error - Error object or message to record
41+
*/
42+
export function recordError<T>(error: T): T {
43+
storeRecordError(error)
44+
return error
45+
}
46+
47+
/**
48+
* Records retry attempts for network operations. Use this to track when
49+
* operations are retried due to transient failures. Helps identify
50+
* problematic endpoints or operations that frequently fail.
51+
*
52+
* @example
53+
* ```ts
54+
* recordRetry('https://api.shopify.com/themes', 'upload')
55+
* ```
56+
*
57+
* @param url - The URL or endpoint being retried
58+
* @param operation - Description of the operation being retried
59+
*/
60+
export function recordRetry(url: string, operation: string): void {
61+
storeRecordRetry(url, operation)
62+
}
63+
64+
/**
65+
* Records custom events for tracking specific user actions or system events.
66+
* Use this for important milestones, user interactions, or significant
67+
* state changes in the application.
68+
*
69+
* @example
70+
* ```ts
71+
* recordEvent('theme-dev-started')
72+
* recordEvent('file-watcher-connected')
73+
* ```
74+
*
75+
* @param eventName - Descriptive name for the event
76+
*/
77+
export function recordEvent(eventName: string): void {
78+
storeRecordEvent(eventName)
79+
}

0 commit comments

Comments
 (0)