forked from
jollywhoppers.com/witchsky.app
Bluesky app fork with some witchin' additions 💫
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}