my fork of the bluesky client
1import format from 'date-fns/format'
2import {nanoid} from 'nanoid/non-secure'
3
4import {isNetworkError} from '#/lib/strings/errors'
5import {DebugContext} from '#/logger/debugContext'
6import {add} from '#/logger/logDump'
7import {Sentry} from '#/logger/sentry'
8import * as env from '#/env'
9
10export enum LogLevel {
11 Debug = 'debug',
12 Info = 'info',
13 Log = 'log',
14 Warn = 'warn',
15 Error = 'error',
16}
17
18type Transport = (
19 level: LogLevel,
20 message: string | Error,
21 metadata: Metadata,
22 timestamp: number,
23) => void
24
25/**
26 * A union of some of Sentry's breadcrumb properties as well as Sentry's
27 * `captureException` parameter, `CaptureContext`.
28 */
29type Metadata = {
30 /**
31 * Applied as Sentry breadcrumb types. Defaults to `default`.
32 *
33 * @see https://develop.sentry.dev/sdk/event-payloads/breadcrumbs/#breadcrumb-types
34 */
35 type?:
36 | 'default'
37 | 'debug'
38 | 'error'
39 | 'navigation'
40 | 'http'
41 | 'info'
42 | 'query'
43 | 'transaction'
44 | 'ui'
45 | 'user'
46
47 /**
48 * Passed through to `Sentry.captureException`
49 *
50 * @see https://github.com/getsentry/sentry-javascript/blob/903addf9a1a1534a6cb2ba3143654b918a86f6dd/packages/types/src/misc.ts#L65
51 */
52 tags?: {
53 [key: string]:
54 | number
55 | string
56 | boolean
57 | bigint
58 | symbol
59 | null
60 | undefined
61 }
62
63 /**
64 * Any additional data, passed through to Sentry as `extra` param on
65 * exceptions, or the `data` param on breadcrumbs.
66 */
67 [key: string]: unknown
68} & Parameters<typeof Sentry.captureException>[1]
69
70export type ConsoleTransportEntry = {
71 id: string
72 timestamp: number
73 level: LogLevel
74 message: string | Error
75 metadata: Metadata
76}
77
78const enabledLogLevels: {
79 [key in LogLevel]: LogLevel[]
80} = {
81 [LogLevel.Debug]: [
82 LogLevel.Debug,
83 LogLevel.Info,
84 LogLevel.Log,
85 LogLevel.Warn,
86 LogLevel.Error,
87 ],
88 [LogLevel.Info]: [LogLevel.Info, LogLevel.Log, LogLevel.Warn, LogLevel.Error],
89 [LogLevel.Log]: [LogLevel.Log, LogLevel.Warn, LogLevel.Error],
90 [LogLevel.Warn]: [LogLevel.Warn, LogLevel.Error],
91 [LogLevel.Error]: [LogLevel.Error],
92}
93
94export function prepareMetadata(metadata: Metadata): Metadata {
95 return Object.keys(metadata).reduce((acc, key) => {
96 let value = metadata[key]
97 if (value instanceof Error) {
98 value = value.toString()
99 }
100 return {...acc, [key]: value}
101 }, {})
102}
103
104/**
105 * Used in dev mode to nicely log to the console
106 */
107export const consoleTransport: Transport = (
108 level,
109 message,
110 metadata,
111 timestamp,
112) => {
113 const extra = Object.keys(metadata).length
114 ? ' ' + JSON.stringify(prepareMetadata(metadata), null, ' ')
115 : ''
116 const log = {
117 [LogLevel.Debug]: console.debug,
118 [LogLevel.Info]: console.info,
119 [LogLevel.Log]: console.log,
120 [LogLevel.Warn]: console.warn,
121 [LogLevel.Error]: console.error,
122 }[level]
123
124 if (message instanceof Error) {
125 console.info(
126 `${format(timestamp, 'HH:mm:ss')} ${message.toString()}${extra}`,
127 )
128 log(message)
129 } else {
130 log(`${format(timestamp, 'HH:mm:ss')} ${message.toString()}${extra}`)
131 }
132}
133
134export const sentryTransport: Transport = (
135 level,
136 message,
137 {type, tags, ...metadata},
138 timestamp,
139) => {
140 const meta = prepareMetadata(metadata)
141
142 /**
143 * If a string, report a breadcrumb
144 */
145 if (typeof message === 'string') {
146 const severity = (
147 {
148 [LogLevel.Debug]: 'debug',
149 [LogLevel.Info]: 'info',
150 [LogLevel.Log]: 'log', // Sentry value here is undefined
151 [LogLevel.Warn]: 'warning',
152 [LogLevel.Error]: 'error',
153 } as const
154 )[level]
155
156 Sentry.addBreadcrumb({
157 message,
158 data: meta,
159 type: type || 'default',
160 level: severity,
161 timestamp: timestamp / 1000, // Sentry expects seconds
162 })
163
164 // We don't want to send any network errors to sentry
165 if (isNetworkError(message)) {
166 return
167 }
168
169 /**
170 * Send all higher levels with `captureMessage`, with appropriate severity
171 * level
172 */
173 if (level === 'error' || level === 'warn' || level === 'log') {
174 const messageLevel = ({
175 [LogLevel.Log]: 'log',
176 [LogLevel.Warn]: 'warning',
177 [LogLevel.Error]: 'error',
178 }[level] || 'log') as Sentry.Breadcrumb['level']
179 // Defer non-critical messages so they're sent in a batch
180 queueMessageForSentry(message, {
181 level: messageLevel,
182 tags,
183 extra: meta,
184 })
185 }
186 } else {
187 /**
188 * It's otherwise an Error and should be reported with captureException
189 */
190 Sentry.captureException(message, {
191 tags,
192 extra: meta,
193 })
194 }
195}
196
197const queuedMessages: [string, Parameters<typeof Sentry.captureMessage>[1]][] =
198 []
199let sentrySendTimeout: ReturnType<typeof setTimeout> | null = null
200function queueMessageForSentry(
201 message: string,
202 captureContext: Parameters<typeof Sentry.captureMessage>[1],
203) {
204 queuedMessages.push([message, captureContext])
205 if (!sentrySendTimeout) {
206 // Throttle sending messages with a leading delay
207 // so that we can get Sentry out of the critical path.
208 sentrySendTimeout = setTimeout(() => {
209 sentrySendTimeout = null
210 sendQueuedMessages()
211 }, 7000)
212 }
213}
214function sendQueuedMessages() {
215 while (queuedMessages.length > 0) {
216 const record = queuedMessages.shift()
217 if (record) {
218 Sentry.captureMessage(record[0], record[1])
219 }
220 }
221}
222
223/**
224 * Main class. Defaults are provided in the constructor so that subclasses are
225 * technically possible, if we need to go that route in the future.
226 */
227export class Logger {
228 LogLevel = LogLevel
229 DebugContext = DebugContext
230
231 enabled: boolean
232 level: LogLevel
233 transports: Transport[] = []
234
235 protected debugContextRegexes: RegExp[] = []
236
237 constructor({
238 enabled = !env.IS_TEST,
239 level = env.LOG_LEVEL as LogLevel,
240 debug = env.LOG_DEBUG || '',
241 }: {
242 enabled?: boolean
243 level?: LogLevel
244 debug?: string
245 } = {}) {
246 this.enabled = enabled !== false
247 this.level = debug ? LogLevel.Debug : level ?? LogLevel.Info // default to info
248 this.debugContextRegexes = (debug || '').split(',').map(context => {
249 return new RegExp(context.replace(/[^\w:*]/, '').replace(/\*/g, '.*'))
250 })
251 }
252
253 debug(message: string, metadata: Metadata = {}, context?: string) {
254 if (context && !this.debugContextRegexes.find(reg => reg.test(context)))
255 return
256 this.transport(LogLevel.Debug, message, metadata)
257 }
258
259 info(message: string, metadata: Metadata = {}) {
260 this.transport(LogLevel.Info, message, metadata)
261 }
262
263 log(message: string, metadata: Metadata = {}) {
264 this.transport(LogLevel.Log, message, metadata)
265 }
266
267 warn(message: string, metadata: Metadata = {}) {
268 this.transport(LogLevel.Warn, message, metadata)
269 }
270
271 error(error: Error | string, metadata: Metadata = {}) {
272 this.transport(LogLevel.Error, error, metadata)
273 }
274
275 addTransport(transport: Transport) {
276 this.transports.push(transport)
277 return () => {
278 this.transports.splice(this.transports.indexOf(transport), 1)
279 }
280 }
281
282 disable() {
283 this.enabled = false
284 }
285
286 enable() {
287 this.enabled = true
288 }
289
290 protected transport(
291 level: LogLevel,
292 message: string | Error,
293 metadata: Metadata = {},
294 ) {
295 if (!this.enabled) return
296
297 const timestamp = Date.now()
298 const meta = metadata || {}
299
300 // send every log to syslog
301 add({
302 id: nanoid(),
303 timestamp,
304 level,
305 message,
306 metadata: meta,
307 })
308
309 if (!enabledLogLevels[this.level].includes(level)) return
310
311 for (const transport of this.transports) {
312 transport(level, message, meta, timestamp)
313 }
314 }
315}
316
317/**
318 * Logger instance. See `@/logger/README` for docs.
319 *
320 * Basic usage:
321 *
322 * `logger.debug(message[, metadata, debugContext])`
323 * `logger.info(message[, metadata])`
324 * `logger.warn(message[, metadata])`
325 * `logger.error(error[, metadata])`
326 * `logger.disable()`
327 * `logger.enable()`
328 */
329export const logger = new Logger()
330
331if (env.IS_DEV && !env.IS_TEST) {
332 logger.addTransport(consoleTransport)
333
334 /*
335 * Comment this out to disable Sentry transport in dev
336 */
337 // logger.addTransport(sentryTransport)
338} else if (env.IS_PROD) {
339 logger.addTransport(sentryTransport)
340}