···11+# Logger
22+33+Simple logger for Bluesky. Supports log levels, debug contexts, and separate
44+transports for production, dev, and test mode.
55+66+## At a Glance
77+88+```typescript
99+import { logger } from '#/logger'
1010+1111+logger.debug(message[, metadata, debugContext])
1212+logger.info(message[, metadata])
1313+logger.log(message[, metadata])
1414+logger.warn(message[, metadata])
1515+logger.error(error[, metadata])
1616+```
1717+1818+#### Modes
1919+2020+The "modes" referred to here are inferred from the values exported from `#/env`.
2121+Basically, the booleans `IS_DEV`, `IS_TEST`, and `IS_PROD`.
2222+2323+#### Log Levels
2424+2525+Log levels are used to filter which logs are either printed to the console
2626+and/or sent to Sentry and other reporting services. To configure, set the
2727+`EXPO_PUBLIC_LOG_LEVEL` environment variable in `.env` to one of `debug`,
2828+`info`, `log`, `warn`, or `error`.
2929+3030+This variable should be `info` in production, and `debug` in dev. If it gets too
3131+noisy in dev, simply set it to a higher level, such as `warn`.
3232+3333+## Usage
3434+3535+```typescript
3636+import { logger } from '#/logger';
3737+```
3838+3939+### `logger.error`
4040+4141+The `error` level is for... well, errors. These are sent to Sentry in production mode.
4242+4343+`error`, along with all log levels, supports an additional parameter, `metadata: Record<string, unknown>`. Use this to provide values to the [Sentry
4444+breadcrumb](https://docs.sentry.io/platforms/react-native/enriching-events/breadcrumbs/#manual-breadcrumbs).
4545+4646+```typescript
4747+try {
4848+ // some async code
4949+} catch (e) {
5050+ logger.error(e, { ...metadata });
5151+}
5252+```
5353+5454+### `logger.warn`
5555+5656+Warnings will be sent to Sentry as a separate Issue with level `warning`, as
5757+well as as breadcrumbs, with a severity level of `warning`
5858+5959+### `logger.log`
6060+6161+Logs with level `log` will be sent to Sentry as a separate Issue with level `log`, as
6262+well as as breadcrumbs, with a severity level of `default`.
6363+6464+### `logger.info`
6565+6666+The `info` level should be used for information that would be helpful in a
6767+tracing context, like Sentry. In production mode, `info` logs are sent
6868+to Sentry as breadcrumbs, which decorate log levels above `info` such as `log`,
6969+`warn`, and `error`.
7070+7171+### `logger.debug`
7272+7373+Debug level is really only intended for local development. Use this instead of
7474+`console.log`.
7575+7676+```typescript
7777+logger.debug(message, { ...metadata });
7878+```
7979+8080+Inspired by [debug](https://www.npmjs.com/package/debug), when writing debug
8181+logs, you can optionally pass a _context_, which can be then filtered when in
8282+debug mode.
8383+8484+This value should be related to the feature, component, or screen
8585+the code is running within, and **it should be defined in `#/logger/debugContext`**.
8686+This way we know if a relevant context already exists, and we can trace all
8787+active contexts in use in our app. This const enum is conveniently available on
8888+the `logger` at `logger.DebugContext`.
8989+9090+For example, a debug log like this:
9191+9292+```typescript
9393+logger.debug(message, {}, logger.DebugContext.composer);
9494+```
9595+9696+Would be logged to the console in dev mode if `EXPO_PUBLIC_LOG_LEVEL=debug`, _or_ if you
9797+pass a separate environment variable `LOG_DEBUG=composer`. This variable supports
9898+multiple contexts using commas like `LOG_DEBUG=composer,profile`, and _automatically
9999+sets the log level to `debug`, regardless of `EXPO_PUBLIC_LOG_LEVEL`._
···11+/**
22+ * *Do not import this directly.* Instead, use the shortcut reference `logger.DebugContext`.
33+ *
44+ * Add debug contexts here. Although convention typically calls for enums ito
55+ * be capitalized, for parity with the `LOG_DEBUG` env var, please use all
66+ * lowercase.
77+ */
88+export const DebugContext = {
99+ // e.g. composer: 'composer'
1010+} as const
+290
src/logger/index.ts
···11+import format from 'date-fns/format'
22+import {nanoid} from 'nanoid/non-secure'
33+44+import {Sentry} from '#/logger/sentry'
55+import * as env from '#/env'
66+import {DebugContext} from '#/logger/debugContext'
77+import {add} from '#/logger/logDump'
88+99+export enum LogLevel {
1010+ Debug = 'debug',
1111+ Info = 'info',
1212+ Log = 'log',
1313+ Warn = 'warn',
1414+ Error = 'error',
1515+}
1616+1717+type Transport = (
1818+ level: LogLevel,
1919+ message: string | Error,
2020+ metadata: Metadata,
2121+ timestamp: number,
2222+) => void
2323+2424+/**
2525+ * A union of some of Sentry's breadcrumb properties as well as Sentry's
2626+ * `captureException` parameter, `CaptureContext`.
2727+ */
2828+type Metadata = {
2929+ /**
3030+ * Applied as Sentry breadcrumb types. Defaults to `default`.
3131+ *
3232+ * @see https://develop.sentry.dev/sdk/event-payloads/breadcrumbs/#breadcrumb-types
3333+ */
3434+ type?:
3535+ | 'default'
3636+ | 'debug'
3737+ | 'error'
3838+ | 'navigation'
3939+ | 'http'
4040+ | 'info'
4141+ | 'query'
4242+ | 'transaction'
4343+ | 'ui'
4444+ | 'user'
4545+4646+ /**
4747+ * Passed through to `Sentry.captureException`
4848+ *
4949+ * @see https://github.com/getsentry/sentry-javascript/blob/903addf9a1a1534a6cb2ba3143654b918a86f6dd/packages/types/src/misc.ts#L65
5050+ */
5151+ tags?: {
5252+ [key: string]:
5353+ | number
5454+ | string
5555+ | boolean
5656+ | bigint
5757+ | symbol
5858+ | null
5959+ | undefined
6060+ }
6161+6262+ /**
6363+ * Any additional data, passed through to Sentry as `extra` param on
6464+ * exceptions, or the `data` param on breadcrumbs.
6565+ */
6666+ [key: string]: unknown
6767+} & Parameters<typeof Sentry.captureException>[1]
6868+6969+export type ConsoleTransportEntry = {
7070+ id: string
7171+ timestamp: number
7272+ level: LogLevel
7373+ message: string | Error
7474+ metadata: Metadata
7575+}
7676+7777+const enabledLogLevels: {
7878+ [key in LogLevel]: LogLevel[]
7979+} = {
8080+ [LogLevel.Debug]: [
8181+ LogLevel.Debug,
8282+ LogLevel.Info,
8383+ LogLevel.Log,
8484+ LogLevel.Warn,
8585+ LogLevel.Error,
8686+ ],
8787+ [LogLevel.Info]: [LogLevel.Info, LogLevel.Log, LogLevel.Warn, LogLevel.Error],
8888+ [LogLevel.Log]: [LogLevel.Log, LogLevel.Warn, LogLevel.Error],
8989+ [LogLevel.Warn]: [LogLevel.Warn, LogLevel.Error],
9090+ [LogLevel.Error]: [LogLevel.Error],
9191+}
9292+9393+/**
9494+ * Used in dev mode to nicely log to the console
9595+ */
9696+export const consoleTransport: Transport = (
9797+ level,
9898+ message,
9999+ metadata,
100100+ timestamp,
101101+) => {
102102+ const extra = Object.keys(metadata).length
103103+ ? ' ' + JSON.stringify(metadata, null, ' ')
104104+ : ''
105105+ const log = {
106106+ [LogLevel.Debug]: console.debug,
107107+ [LogLevel.Info]: console.info,
108108+ [LogLevel.Log]: console.log,
109109+ [LogLevel.Warn]: console.warn,
110110+ [LogLevel.Error]: console.error,
111111+ }[level]
112112+113113+ log(`${format(timestamp, 'HH:mm:ss')} ${message.toString()}${extra}`)
114114+}
115115+116116+export const sentryTransport: Transport = (
117117+ level,
118118+ message,
119119+ {type, tags, ...metadata},
120120+ timestamp,
121121+) => {
122122+ /**
123123+ * If a string, report a breadcrumb
124124+ */
125125+ if (typeof message === 'string') {
126126+ const severity = (
127127+ {
128128+ [LogLevel.Debug]: 'debug',
129129+ [LogLevel.Info]: 'info',
130130+ [LogLevel.Log]: 'log', // Sentry value here is undefined
131131+ [LogLevel.Warn]: 'warning',
132132+ [LogLevel.Error]: 'error',
133133+ } as const
134134+ )[level]
135135+136136+ Sentry.addBreadcrumb({
137137+ message,
138138+ data: metadata,
139139+ type: type || 'default',
140140+ level: severity,
141141+ timestamp: timestamp / 1000, // Sentry expects seconds
142142+ })
143143+144144+ /**
145145+ * Send all higher levels with `captureMessage`, with appropriate severity
146146+ * level
147147+ */
148148+ if (level === 'error' || level === 'warn' || level === 'log') {
149149+ const messageLevel = ({
150150+ [LogLevel.Log]: 'log',
151151+ [LogLevel.Warn]: 'warning',
152152+ [LogLevel.Error]: 'error',
153153+ }[level] || 'log') as Sentry.Breadcrumb['level']
154154+155155+ Sentry.captureMessage(message, {
156156+ level: messageLevel,
157157+ tags,
158158+ extra: metadata,
159159+ })
160160+ }
161161+ } else {
162162+ /**
163163+ * It's otherwise an Error and should be reported with captureException
164164+ */
165165+ Sentry.captureException(message, {
166166+ tags,
167167+ extra: metadata,
168168+ })
169169+ }
170170+}
171171+172172+/**
173173+ * Main class. Defaults are provided in the constructor so that subclasses are
174174+ * technically possible, if we need to go that route in the future.
175175+ */
176176+export class Logger {
177177+ LogLevel = LogLevel
178178+ DebugContext = DebugContext
179179+180180+ enabled: boolean
181181+ level: LogLevel
182182+ transports: Transport[] = []
183183+184184+ protected debugContextRegexes: RegExp[] = []
185185+186186+ constructor({
187187+ enabled = !env.IS_TEST,
188188+ level = env.LOG_LEVEL as LogLevel,
189189+ debug = env.LOG_DEBUG || '',
190190+ }: {
191191+ enabled?: boolean
192192+ level?: LogLevel
193193+ debug?: string
194194+ } = {}) {
195195+ this.enabled = enabled !== false
196196+ this.level = debug ? LogLevel.Debug : level ?? LogLevel.Info // default to info
197197+ this.debugContextRegexes = (debug || '').split(',').map(context => {
198198+ return new RegExp(context.replace(/[^\w:*]/, '').replace(/\*/g, '.*'))
199199+ })
200200+ }
201201+202202+ debug(message: string, metadata: Metadata = {}, context?: string) {
203203+ if (context && !this.debugContextRegexes.find(reg => reg.test(context)))
204204+ return
205205+ this.transport(LogLevel.Debug, message, metadata)
206206+ }
207207+208208+ info(message: string, metadata: Metadata = {}) {
209209+ this.transport(LogLevel.Info, message, metadata)
210210+ }
211211+212212+ log(message: string, metadata: Metadata = {}) {
213213+ this.transport(LogLevel.Log, message, metadata)
214214+ }
215215+216216+ warn(message: string, metadata: Metadata = {}) {
217217+ this.transport(LogLevel.Warn, message, metadata)
218218+ }
219219+220220+ error(error: Error | string, metadata: Metadata = {}) {
221221+ this.transport(LogLevel.Error, error, metadata)
222222+ }
223223+224224+ addTransport(transport: Transport) {
225225+ this.transports.push(transport)
226226+ return () => {
227227+ this.transports.splice(this.transports.indexOf(transport), 1)
228228+ }
229229+ }
230230+231231+ disable() {
232232+ this.enabled = false
233233+ }
234234+235235+ enable() {
236236+ this.enabled = true
237237+ }
238238+239239+ protected transport(
240240+ level: LogLevel,
241241+ message: string | Error,
242242+ metadata: Metadata = {},
243243+ ) {
244244+ if (!this.enabled) return
245245+ if (!enabledLogLevels[this.level].includes(level)) return
246246+247247+ const timestamp = Date.now()
248248+ const meta = metadata || {}
249249+250250+ for (const transport of this.transports) {
251251+ transport(level, message, meta, timestamp)
252252+ }
253253+254254+ add({
255255+ id: nanoid(),
256256+ timestamp,
257257+ level,
258258+ message,
259259+ metadata: meta,
260260+ })
261261+ }
262262+}
263263+264264+/**
265265+ * Logger instance. See `@/logger/README` for docs.
266266+ *
267267+ * Basic usage:
268268+ *
269269+ * `logger.debug(message[, metadata, debugContext])`
270270+ * `logger.info(message[, metadata])`
271271+ * `logger.warn(message[, metadata])`
272272+ * `logger.error(error[, metadata])`
273273+ * `logger.disable()`
274274+ * `logger.enable()`
275275+ */
276276+export const logger = new Logger()
277277+278278+/**
279279+ * Report to console in dev, Sentry in prod, nothing in test.
280280+ */
281281+if (env.IS_DEV && !env.IS_TEST) {
282282+ logger.addTransport(consoleTransport)
283283+284284+ /**
285285+ * Uncomment this to test Sentry in dev
286286+ */
287287+ // logger.addTransport(sentryTransport);
288288+} else if (env.IS_PROD) {
289289+ logger.addTransport(sentryTransport)
290290+}
+12
src/logger/logDump.ts
···11+import {ConsoleTransportEntry} from '#/logger'
22+33+let entries: ConsoleTransportEntry[] = []
44+55+export function add(entry: ConsoleTransportEntry) {
66+ entries.unshift(entry)
77+ entries = entries.slice(0, 50)
88+}
99+1010+export function getEntries() {
1111+ return entries
1212+}
+1
src/logger/sentry/index.ts
···11+export {Native as Sentry} from 'sentry-expo'
+1
src/logger/sentry/index.web.ts
···11+export {Browser as Sentry} from 'sentry-expo'
+2-2
src/state/models/root-store.ts
···88import {DeviceEventEmitter, EmitterSubscription} from 'react-native'
99import {z} from 'zod'
1010import {isObj, hasProp} from 'lib/type-guards'
1111-import {LogModel} from './log'
1211import {SessionModel} from './session'
1312import {ShellUiModel} from './ui/shell'
1413import {HandleResolutionsCache} from './cache/handle-resolutions'
···2322import {MutedThreads} from './muted-threads'
2423import {Reminders} from './ui/reminders'
2524import {reset as resetNavigation} from '../../Navigation'
2525+import {logger} from '#/logger'
26262727// TEMPORARY (APP-700)
2828// remove after backend testing finishes
···4141export class RootStoreModel {
4242 agent: BskyAgent
4343 appInfo?: AppInfo
4444- log = new LogModel()
4444+ log = logger
4545 session = new SessionModel(this)
4646 shell = new ShellUiModel(this)
4747 preferences = new PreferencesModel(this)