Bluesky app fork with some witchin' additions 💫
at jean/pds-label 118 lines 3.1 kB view raw
1import {onAppStateChange} from '#/lib/appState' 2import {isNetworkError} from '#/lib/strings/errors' 3import {Logger} from '#/logger' 4import * as env from '#/env' 5 6type Event<M extends Record<string, any>> = { 7 source: 'app' 8 time: number 9 event: keyof M 10 payload: M[keyof M] 11 metadata: Record<string, any> 12} 13 14const TRACKING_ENDPOINT = env.METRICS_API_HOST + '/t' 15const logger = Logger.create(Logger.Context.Metric, {}) 16 17export class MetricsClient<M extends Record<string, any>> { 18 maxBatchSize = 100 19 20 private started: boolean = false 21 private queue: Event<M>[] = [] 22 private failedQueue: Event<M>[] = [] 23 private flushInterval: NodeJS.Timeout | null = null 24 25 start() { 26 if (this.started) return 27 this.started = true 28 this.flushInterval = setInterval(() => { 29 this.flush() 30 }, 10_000) 31 onAppStateChange(state => { 32 if (state === 'active') { 33 this.retryFailedLogs() 34 } else { 35 this.flush() 36 } 37 }) 38 } 39 40 track<E extends keyof M>( 41 event: E, 42 payload: M[E], 43 metadata: Record<string, any> = {}, 44 ) { 45 this.start() 46 47 const e: Event<M> = { 48 source: 'app', 49 time: Date.now(), 50 event, 51 payload, 52 metadata, 53 } 54 this.queue.push(e) 55 56 logger.debug(`event: ${e.event as string}`, e) 57 58 if (this.queue.length > this.maxBatchSize) { 59 this.flush() 60 } 61 } 62 63 flush() { 64 if (!this.queue.length) return 65 const events = this.queue.splice(0, this.queue.length) 66 this.sendBatch(events) 67 } 68 69 private async sendBatch(events: Event<M>[], isRetry: boolean = false) { 70 logger.debug(`sendBatch: ${events.length}`, { 71 isRetry, 72 }) 73 74 try { 75 const body = JSON.stringify({events}) 76 if (env.IS_WEB && 'navigator' in globalThis && navigator.sendBeacon) { 77 const success = navigator.sendBeacon( 78 TRACKING_ENDPOINT, 79 new Blob([body], {type: 'application/json'}), 80 ) 81 if (!success) { 82 // construct a "network error" for `isNetworkError` to work 83 throw new Error(`Failed to fetch: sendBeacon returned false`) 84 } 85 } else { 86 const res = await fetch(TRACKING_ENDPOINT, { 87 method: 'POST', 88 headers: { 89 'Content-Type': 'application/json', 90 }, 91 body: JSON.stringify({events}), 92 keepalive: true, 93 }) 94 95 if (!res.ok) { 96 const error = await res.text().catch(() => 'Unknown error') 97 // construct a "network error" for `isNetworkError` to work 98 throw new Error(`${res.status} Failed to fetch — ${error}`) 99 } 100 } 101 } catch (e: any) { 102 if (isNetworkError(e)) { 103 if (isRetry) return // retry once 104 this.failedQueue.push(...events) 105 return 106 } 107 logger.error(`Failed to send metrics`, { 108 safeMessage: e.toString(), 109 }) 110 } 111 } 112 113 private retryFailedLogs() { 114 if (!this.failedQueue.length) return 115 const events = this.failedQueue.splice(0, this.failedQueue.length) 116 this.sendBatch(events, true) 117 } 118}