Bluesky app fork with some witchin' additions 馃挮
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

at main 234 lines 6.4 kB view raw
1import {MetricsClient} from './client' 2 3let appStateCallback: (state: string) => void 4const mockCaptureMessage = jest.fn() 5let mockMetricsApiHost: string | undefined = 'https://test.metrics.api' 6let mockSentryDsn: string | undefined 7 8jest.mock('#/lib/appState', () => ({ 9 onAppStateChange: jest.fn(cb => { 10 appStateCallback = cb 11 return {remove: jest.fn()} 12 }), 13})) 14 15jest.mock('#/logger', () => ({ 16 Logger: { 17 create: () => ({ 18 info: jest.fn(), 19 debug: jest.fn(), 20 error: jest.fn(), 21 }), 22 Context: {Metric: 'metric'}, 23 }, 24})) 25 26jest.mock('@sentry/react-native', () => ({ 27 captureMessage: mockCaptureMessage, 28})) 29 30jest.mock('#/env', () => ({ 31 get METRICS_API_HOST() { 32 return mockMetricsApiHost 33 }, 34 get SENTRY_DSN() { 35 return mockSentryDsn 36 }, 37 IS_WEB: false, 38})) 39 40type TestEvents = { 41 click: {button: string} 42 view: {screen: string} 43} 44 45type FetchRequestBody = { 46 events: Array<{ 47 event: string 48 }> 49} 50 51function parseFetchBody(options?: RequestInit): FetchRequestBody { 52 const {body} = options ?? {} 53 const raw = 54 typeof body === 'string' ? body : body == null ? '{}' : JSON.stringify(body) 55 return JSON.parse(raw) as FetchRequestBody 56} 57 58describe('MetricsClient', () => { 59 let fetchMock: jest.Mock 60 let fetchRequests: {body: FetchRequestBody}[] 61 62 beforeEach(() => { 63 jest.useFakeTimers({advanceTimers: true}) 64 mockMetricsApiHost = 'https://test.metrics.api' 65 mockSentryDsn = undefined 66 mockCaptureMessage.mockReset() 67 fetchRequests = [] 68 fetchMock = jest.fn().mockImplementation((_url, options?: RequestInit) => { 69 const body = parseFetchBody(options) 70 fetchRequests.push({body}) 71 return Promise.resolve({ok: true, status: 200}) 72 }) 73 global.fetch = fetchMock 74 }) 75 76 afterEach(() => { 77 jest.useRealTimers() 78 jest.clearAllMocks() 79 }) 80 81 it('flushes events on interval', async () => { 82 const client = new MetricsClient<TestEvents>() 83 client.track('click', {button: 'submit'}) 84 client.track('view', {screen: 'home'}) 85 86 expect(fetchRequests).toHaveLength(0) 87 88 // Advance past the 10 second interval 89 await jest.advanceTimersByTimeAsync(10_000) 90 91 expect(fetchRequests).toHaveLength(1) 92 expect(fetchRequests[0].body.events).toHaveLength(2) 93 expect(fetchRequests[0].body.events[0].event).toBe('click') 94 expect(fetchRequests[0].body.events[1].event).toBe('view') 95 }) 96 97 it('flushes when maxBatchSize is exceeded', async () => { 98 const client = new MetricsClient<TestEvents>() 99 client.maxBatchSize = 5 100 101 // Add events up to maxBatchSize (should not flush yet) 102 for (let i = 0; i < 5; i++) { 103 client.track('click', {button: `btn-${i}`}) 104 } 105 106 expect(fetchRequests).toHaveLength(0) 107 108 // One more event should trigger flush (> maxBatchSize) 109 client.track('click', {button: 'btn-trigger'}) 110 111 // Allow microtasks to run 112 await jest.advanceTimersByTimeAsync(0) 113 114 expect(fetchRequests).toHaveLength(1) 115 expect(fetchRequests[0].body.events).toHaveLength(6) 116 }) 117 118 it('retries failed events once on 500 response', async () => { 119 let requestCount = 0 120 121 fetchMock.mockImplementation((_url, options?: RequestInit) => { 122 requestCount++ 123 const body = parseFetchBody(options) 124 125 if (requestCount === 1) { 126 // First request fails with 500 - "Failed to fetch" triggers isNetworkError 127 return Promise.resolve({ 128 ok: false, 129 status: 500, 130 text: () => Promise.resolve('Internal Server Error'), 131 }) 132 } 133 134 // Retry succeeds 135 fetchRequests.push({body}) 136 return Promise.resolve({ok: true, status: 200}) 137 }) 138 139 const client = new MetricsClient<TestEvents>() 140 client.track('click', {button: 'submit'}) 141 142 // Trigger flush via interval 143 await jest.advanceTimersByTimeAsync(10_000) 144 145 expect(requestCount).toBe(1) 146 expect(fetchRequests).toHaveLength(0) 147 148 // Simulate app coming to foreground to trigger retry 149 appStateCallback('active') 150 await jest.advanceTimersByTimeAsync(0) 151 152 expect(requestCount).toBe(2) 153 expect(fetchRequests).toHaveLength(1) 154 expect(fetchRequests[0].body.events).toHaveLength(1) 155 expect(fetchRequests[0].body.events[0].event).toBe('click') 156 }) 157 158 it('does not retry more than once', async () => { 159 let requestCount = 0 160 161 fetchMock.mockImplementation(() => { 162 requestCount++ 163 // Always fail with network-like error 164 return Promise.resolve({ 165 ok: false, 166 status: 500, 167 text: () => Promise.resolve('Internal Server Error'), 168 }) 169 }) 170 171 const client = new MetricsClient<TestEvents>() 172 client.track('click', {button: 'submit'}) 173 174 // First flush fails 175 await jest.advanceTimersByTimeAsync(10_000) 176 177 expect(requestCount).toBe(1) 178 179 // Retry also fails 180 appStateCallback('active') 181 await jest.advanceTimersByTimeAsync(0) 182 183 expect(requestCount).toBe(2) 184 185 // Another foreground event should not retry again (events are dropped) 186 appStateCallback('active') 187 await jest.advanceTimersByTimeAsync(0) 188 189 expect(requestCount).toBe(2) // No additional requests 190 }) 191 192 it('flushes when app goes to background', async () => { 193 const client = new MetricsClient<TestEvents>() 194 client.track('click', {button: 'submit'}) 195 196 expect(fetchRequests).toHaveLength(0) 197 198 // Simulate app going to background 199 appStateCallback('background') 200 await jest.advanceTimersByTimeAsync(0) 201 202 expect(fetchRequests).toHaveLength(1) 203 }) 204 205 it('sends metrics through sentry when metrics api host is unset', async () => { 206 mockMetricsApiHost = undefined 207 mockSentryDsn = 'https://public@example.glitchtip.com/1' 208 209 const client = new MetricsClient<TestEvents>() 210 client.track('click', {button: 'submit'}) 211 client.track('view', {screen: 'home'}) 212 213 await jest.advanceTimersByTimeAsync(10_000) 214 215 expect(fetchRequests).toHaveLength(0) 216 expect(mockCaptureMessage).toHaveBeenCalledTimes(2) 217 expect(mockCaptureMessage).toHaveBeenNthCalledWith( 218 1, 219 'metric:click', 220 expect.objectContaining({ 221 level: 'info', 222 fingerprint: ['metric', 'click'], 223 tags: expect.objectContaining({ 224 metric_name: 'click', 225 metric_source: 'app', 226 }), 227 extra: expect.objectContaining({ 228 logger: 'metric', 229 payload: {button: 'submit'}, 230 }), 231 }), 232 ) 233 }) 234})