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