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 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}