forked from
jollywhoppers.com/witchsky.app
Bluesky app fork with some witchin' additions 馃挮
1import crypto from 'node:crypto'
2
3import {httpLogger} from './logger.js'
4
5/**
6 * New metrics events should be added here
7 */
8type Events = {
9 redirect: {
10 link: string
11 whitelisted: 'unknown' | 'yes'
12 blocked: boolean
13 warned: boolean
14 utm_source?: string
15 utm_medium?: string
16 utm_campaign?: string
17 utm_content?: string
18 utm_term?: string
19 }
20 invalid_redirect: {
21 link: string
22 }
23}
24
25type Event<M extends Record<string, any>> = {
26 time: number
27 event: keyof M
28 payload: M[keyof M]
29 metadata: Record<string, any>
30}
31
32export type Config = {
33 trackingEndpoint?: string
34}
35
36/**
37 * This MetricsClient is duplicated from both `social-app` and `atproto`
38 * codebases.
39 */
40export class MetricsClient<M extends Record<string, any> = Events> {
41 maxBatchSize = 100
42
43 private disabled: boolean = false
44 private started: boolean = false
45 private queue: Event<M>[] = []
46 private flushInterval: NodeJS.Timeout | null = null
47 constructor(private config: Config) {
48 this.disabled = !config.trackingEndpoint
49 }
50
51 start() {
52 if (this.disabled) return
53 if (this.started) return
54 this.started = true
55 this.flushInterval = setInterval(() => {
56 this.flush()
57 }, 10_000)
58 }
59
60 stop() {
61 if (this.flushInterval) {
62 clearInterval(this.flushInterval)
63 this.flushInterval = null
64 }
65 this.flush()
66 }
67
68 track<E extends keyof M>(event: E, payload: M[E]) {
69 if (this.disabled) return
70
71 this.start()
72
73 /**
74 * deviceId is required for sharding events in Middleman. To avoid a hot
75 * shard, we generate a random anonymous IDs for this client.
76 *
77 * @see https://github.com/bluesky-social/tango/blob/d5819cde419d13e0d2cf837f4b30d48529d64060/middleman/handlers_tracking.go#L195
78 */
79 const anonId = `anon-${crypto.randomUUID()}`
80
81 /**
82 * Event structure is like this to ensure compat with Middleman, which
83 * receives events like this from other codebases, including `social-app`.
84 */
85 const e = {
86 source: 'blink',
87 time: Date.now(),
88 event,
89 payload,
90 metadata: {
91 base: {
92 deviceId: anonId,
93 sessionId: anonId,
94 },
95 session: {
96 did: undefined,
97 },
98 },
99 }
100 this.queue.push(e)
101
102 if (this.queue.length > this.maxBatchSize) {
103 this.flush()
104 }
105 }
106
107 flush() {
108 if (this.disabled) return
109 if (!this.queue.length) return
110 const events = this.queue.splice(0, this.queue.length)
111 this.sendBatch(events)
112 }
113
114 private async sendBatch(events: Event<M>[]) {
115 if (this.disabled || !this.config.trackingEndpoint) return
116
117 try {
118 const res = await fetch(this.config.trackingEndpoint, {
119 method: 'POST',
120 headers: {
121 'Content-Type': 'application/json',
122 },
123 body: JSON.stringify({events}),
124 keepalive: true,
125 })
126
127 if (!res.ok) {
128 const errorText = await res.text().catch(() => 'Unknown error')
129 httpLogger.error(
130 {err: new Error(`${res.status} Failed to fetch - ${errorText}`)},
131 'Failed to send metrics',
132 )
133 } else {
134 // Drain response body to allow connection reuse.
135 await res.text().catch(() => {})
136 }
137 } catch (err) {
138 httpLogger.error({err}, 'Failed to send metrics')
139 }
140 }
141}