about summary refs log tree commit diff
path: root/src/logger
diff options
context:
space:
mode:
Diffstat (limited to 'src/logger')
-rw-r--r--src/logger/README.md102
-rw-r--r--src/logger/__tests__/logDump.test.ts7
-rw-r--r--src/logger/__tests__/logger.test.ts194
-rw-r--r--src/logger/bitdriftTransport.ts22
-rw-r--r--src/logger/debugContext.ts13
-rw-r--r--src/logger/index.ts290
-rw-r--r--src/logger/logDump.ts11
-rw-r--r--src/logger/sentry/lib/index.ts (renamed from src/logger/sentry/index.ts)0
-rw-r--r--src/logger/sentry/lib/index.web.ts (renamed from src/logger/sentry/index.web.ts)0
-rw-r--r--src/logger/sentry/setup/index.ts40
-rw-r--r--src/logger/transports/bitdrift.ts30
-rw-r--r--src/logger/transports/console.ts90
-rw-r--r--src/logger/transports/sentry.ts102
-rw-r--r--src/logger/types.ts49
-rw-r--r--src/logger/util.ts29
15 files changed, 553 insertions, 426 deletions
diff --git a/src/logger/README.md b/src/logger/README.md
index 8da7deb14..e3476efdf 100644
--- a/src/logger/README.md
+++ b/src/logger/README.md
@@ -1,99 +1,45 @@
 # Logger
 
-Simple logger for Bluesky. Supports log levels, debug contexts, and separate
-transports for production, dev, and test mode.
+Simple logger for Bluesky.
 
 ## At a Glance
 
 ```typescript
-import { logger } from '#/logger'
+import { logger, Logger } from '#/logger'
 
-logger.debug(message[, metadata, debugContext])
-logger.info(message[, metadata])
-logger.log(message[, metadata])
-logger.warn(message[, metadata])
-logger.error(error[, metadata])
-```
-
-#### Modes
+// or, optionally create new instance with custom context
+// const logger = Logger.create(Logger.Context.Notifications)
 
-The "modes" referred to here are inferred from `process.env.NODE_ENV`,
-which matches how React Native sets the `__DEV__` global.
-
-#### Log Levels
+// for dev-only logs
+logger.debug(message, {})
 
-Log levels are used to filter which logs are either printed to the console
-and/or sent to Sentry and other reporting services. To configure, set the
-`EXPO_PUBLIC_LOG_LEVEL` environment variable in `.env` to one of `debug`,
-`info`, `log`, `warn`, or `error`.
+// for production breadcrumbs
+logger.info(message, {})
 
-This variable should be `info` in production, and `debug` in dev. If it gets too
-noisy in dev, simply set it to a higher level, such as `warn`.
+// seldom used, prefer `info`
+logger.log(message, {})
 
-## Usage
-
-```typescript
-import { logger } from '#/logger';
-```
+// for non-error issues to look into, seldom used, prefer `error`
+logger.warn(message, {})
 
-### `logger.error`
+// for known errors without an exception, use a string
+logger.error(`known error`, {})
 
-The `error` level is for... well, errors. These are sent to Sentry in production mode.
-
-`error`, along with all log levels, supports an additional parameter, `metadata: Record<string, unknown>`. Use this to provide values to the [Sentry
-breadcrumb](https://docs.sentry.io/platforms/react-native/enriching-events/breadcrumbs/#manual-breadcrumbs).
-
-```typescript
+// for unknown exceptions
 try {
-  // some async code
 } catch (e) {
-  logger.error(e, { ...metadata });
+  logger.error(e, {message: `explain error`}])
 }
 ```
 
-### `logger.warn`
-
-Warnings will be sent to Sentry as a separate Issue with level `warning`, as
-well as as breadcrumbs, with a severity level of `warning`
-
-### `logger.log`
-
-Logs with level `log` will be sent to Sentry as a separate Issue with level `log`, as
-well as as breadcrumbs, with a severity level of `default`.
-
-### `logger.info`
-
-The `info` level should be used for information that would be helpful in a
-tracing context, like Sentry. In production mode, `info` logs are sent
-to Sentry as breadcrumbs, which decorate log levels above `info` such as `log`,
-`warn`, and `error`.
-
-### `logger.debug`
-
-Debug level is really only intended for local development. Use this instead of
-`console.log`.
-
-```typescript
-logger.debug(message, { ...metadata });
-```
-
-Inspired by [debug](https://www.npmjs.com/package/debug), when writing debug
-logs, you can optionally pass a _context_, which can be then filtered when in
-debug mode.
-
-This value should be related to the feature, component, or screen
-the code is running within, and **it should be defined in `#/logger/debugContext`**.
-This way we know if a relevant context already exists, and we can trace all
-active contexts in use in our app. This const enum is conveniently available on
-the `logger` at `logger.DebugContext`.
+#### Log Levels
 
-For example, a debug log like this:
+Log level defaults to `info`. You can set this via the `EXPO_PUBLIC_LOG_LEVEL`
+env var in `.env.local`.
 
-```typescript
-logger.debug(message, {}, logger.DebugContext.composer);
-```
+#### Filtering debugs by context
 
-Would be logged to the console in dev mode if `EXPO_PUBLIC_LOG_LEVEL=debug`, _or_ if you
-pass a separate environment variable `LOG_DEBUG=composer`. This variable supports
-multiple contexts using commas like `LOG_DEBUG=composer,profile`, and _automatically
-sets the log level to `debug`, regardless of `EXPO_PUBLIC_LOG_LEVEL`._
+Debug logs are dev-only, and not enabled by default. Once enabled, they can get
+noisy. So you can filter them by setting the `EXPO_PUBLIC_LOG_DEBUG` env var
+e.g. `EXPO_PUBLIC_LOG_DEBUG=notifications`. These values can be comma-separated
+and include wildcards.
diff --git a/src/logger/__tests__/logDump.test.ts b/src/logger/__tests__/logDump.test.ts
index 135998223..165bfac08 100644
--- a/src/logger/__tests__/logDump.test.ts
+++ b/src/logger/__tests__/logDump.test.ts
@@ -1,13 +1,14 @@
 import {expect, test} from '@jest/globals'
 
-import {ConsoleTransportEntry, LogLevel} from '#/logger'
-import {add, getEntries} from '#/logger/logDump'
+import {add, ConsoleTransportEntry, getEntries} from '#/logger/logDump'
+import {LogContext, LogLevel} from '#/logger/types'
 
 test('works', () => {
   const items: ConsoleTransportEntry[] = [
     {
       id: '1',
       level: LogLevel.Debug,
+      context: LogContext.Default,
       message: 'hello',
       metadata: {},
       timestamp: Date.now(),
@@ -15,6 +16,7 @@ test('works', () => {
     {
       id: '2',
       level: LogLevel.Debug,
+      context: LogContext.Default,
       message: 'hello',
       metadata: {},
       timestamp: Date.now(),
@@ -22,6 +24,7 @@ test('works', () => {
     {
       id: '3',
       level: LogLevel.Debug,
+      context: LogContext.Default,
       message: 'hello',
       metadata: {},
       timestamp: Date.now(),
diff --git a/src/logger/__tests__/logger.test.ts b/src/logger/__tests__/logger.test.ts
index be2391e12..270b0a187 100644
--- a/src/logger/__tests__/logger.test.ts
+++ b/src/logger/__tests__/logger.test.ts
@@ -2,16 +2,9 @@ import {beforeAll, describe, expect, jest, test} from '@jest/globals'
 import * as Sentry from '@sentry/react-native'
 import {nanoid} from 'nanoid/non-secure'
 
-import {Logger, LogLevel, sentryTransport} from '#/logger'
-
-jest.mock('#/env', () => ({
-  /*
-   * Forces debug mode for tests using the default logger. Most tests create
-   * their own logger instance.
-   */
-  LOG_LEVEL: 'debug',
-  LOG_DEBUG: '',
-}))
+import {Logger} from '#/logger'
+import {sentryTransport} from '#/logger/transports/sentry'
+import {LogLevel} from '#/logger/types'
 
 jest.mock('@sentry/react-native', () => ({
   addBreadcrumb: jest.fn(),
@@ -26,57 +19,27 @@ beforeAll(() => {
 describe('general functionality', () => {
   test('default params', () => {
     const logger = new Logger()
-    expect(logger.enabled).toBeFalsy()
-    expect(logger.level).toEqual(LogLevel.Debug) // mocked above
-  })
-
-  test('can override default params', () => {
-    const logger = new Logger({
-      enabled: true,
-      level: LogLevel.Info,
-    })
-    expect(logger.enabled).toBeTruthy()
     expect(logger.level).toEqual(LogLevel.Info)
   })
 
-  test('disabled logger does not report', () => {
+  test('can override default params', () => {
     const logger = new Logger({
-      enabled: false,
       level: LogLevel.Debug,
     })
-
-    const mockTransport = jest.fn()
-
-    logger.addTransport(mockTransport)
-    logger.debug('message')
-
-    expect(mockTransport).not.toHaveBeenCalled()
+    expect(logger.level).toEqual(LogLevel.Debug)
   })
 
-  test('disablement', () => {
+  test('contextFilter overrides level', () => {
     const logger = new Logger({
-      enabled: true,
-      level: LogLevel.Debug,
+      level: LogLevel.Info,
+      contextFilter: 'test',
     })
-
-    logger.disable()
-
-    const mockTransport = jest.fn()
-
-    logger.addTransport(mockTransport)
-    logger.debug('message')
-
-    expect(mockTransport).not.toHaveBeenCalled()
-  })
-
-  test('passing debug contexts automatically enables debug mode', () => {
-    const logger = new Logger({debug: 'specific'})
     expect(logger.level).toEqual(LogLevel.Debug)
   })
 
   test('supports extra metadata', () => {
     const timestamp = Date.now()
-    const logger = new Logger({enabled: true})
+    const logger = new Logger({})
 
     const mockTransport = jest.fn()
 
@@ -87,6 +50,7 @@ describe('general functionality', () => {
 
     expect(mockTransport).toHaveBeenCalledWith(
       LogLevel.Warn,
+      undefined,
       'message',
       extra,
       timestamp,
@@ -95,7 +59,7 @@ describe('general functionality', () => {
 
   test('supports nullish/falsy metadata', () => {
     const timestamp = Date.now()
-    const logger = new Logger({enabled: true})
+    const logger = new Logger({})
 
     const mockTransport = jest.fn()
 
@@ -105,6 +69,7 @@ describe('general functionality', () => {
     logger.warn('a', null)
     expect(mockTransport).toHaveBeenCalledWith(
       LogLevel.Warn,
+      undefined,
       'a',
       {},
       timestamp,
@@ -114,6 +79,7 @@ describe('general functionality', () => {
     logger.warn('b', false)
     expect(mockTransport).toHaveBeenCalledWith(
       LogLevel.Warn,
+      undefined,
       'b',
       {},
       timestamp,
@@ -123,6 +89,7 @@ describe('general functionality', () => {
     logger.warn('c', 0)
     expect(mockTransport).toHaveBeenCalledWith(
       LogLevel.Warn,
+      undefined,
       'c',
       {},
       timestamp,
@@ -130,7 +97,7 @@ describe('general functionality', () => {
 
     remove()
 
-    logger.addTransport((level, message, metadata) => {
+    logger.addTransport((level, context, message, metadata) => {
       expect(typeof metadata).toEqual('object')
     })
 
@@ -143,10 +110,17 @@ describe('general functionality', () => {
     const timestamp = Date.now()
     const sentryTimestamp = timestamp / 1000
 
-    sentryTransport(LogLevel.Debug, message, {}, timestamp)
+    sentryTransport(
+      LogLevel.Debug,
+      Logger.Context.Default,
+      message,
+      {},
+      timestamp,
+    )
     expect(Sentry.addBreadcrumb).toHaveBeenCalledWith({
+      category: Logger.Context.Default,
       message,
-      data: {},
+      data: {context: 'logger'},
       type: 'default',
       level: LogLevel.Debug,
       timestamp: sentryTimestamp,
@@ -154,22 +128,31 @@ describe('general functionality', () => {
 
     sentryTransport(
       LogLevel.Info,
+      Logger.Context.Default,
       message,
       {type: 'info', prop: true},
       timestamp,
     )
     expect(Sentry.addBreadcrumb).toHaveBeenCalledWith({
+      category: Logger.Context.Default,
       message,
-      data: {prop: true},
+      data: {prop: true, context: 'logger'},
       type: 'info',
       level: LogLevel.Info,
       timestamp: sentryTimestamp,
     })
 
-    sentryTransport(LogLevel.Log, message, {}, timestamp)
+    sentryTransport(
+      LogLevel.Log,
+      Logger.Context.Default,
+      message,
+      {},
+      timestamp,
+    )
     expect(Sentry.addBreadcrumb).toHaveBeenCalledWith({
+      category: Logger.Context.Default,
       message,
-      data: {},
+      data: {context: 'logger'},
       type: 'default',
       level: 'debug', // Sentry bug, log becomes debug
       timestamp: sentryTimestamp,
@@ -177,14 +160,21 @@ describe('general functionality', () => {
     jest.runAllTimers()
     expect(Sentry.captureMessage).toHaveBeenCalledWith(message, {
       level: 'log',
-      tags: undefined,
-      extra: {},
+      tags: {category: 'logger'},
+      extra: {context: 'logger'},
     })
 
-    sentryTransport(LogLevel.Warn, message, {}, timestamp)
+    sentryTransport(
+      LogLevel.Warn,
+      Logger.Context.Default,
+      message,
+      {},
+      timestamp,
+    )
     expect(Sentry.addBreadcrumb).toHaveBeenCalledWith({
+      category: Logger.Context.Default,
       message,
-      data: {},
+      data: {context: 'logger'},
       type: 'default',
       level: 'warning',
       timestamp: sentryTimestamp,
@@ -192,8 +182,8 @@ describe('general functionality', () => {
     jest.runAllTimers()
     expect(Sentry.captureMessage).toHaveBeenCalledWith(message, {
       level: 'warning',
-      tags: undefined,
-      extra: {},
+      tags: {category: 'logger'},
+      extra: {context: 'logger'},
     })
 
     const e = new Error('error')
@@ -203,6 +193,7 @@ describe('general functionality', () => {
 
     sentryTransport(
       LogLevel.Error,
+      Logger.Context.Default,
       e,
       {
         tags,
@@ -212,9 +203,13 @@ describe('general functionality', () => {
     )
 
     expect(Sentry.captureException).toHaveBeenCalledWith(e, {
-      tags,
+      tags: {
+        ...tags,
+        category: 'logger',
+      },
       extra: {
         prop: true,
+        context: 'logger',
       },
     })
   })
@@ -226,6 +221,7 @@ describe('general functionality', () => {
 
     sentryTransport(
       LogLevel.Debug,
+      undefined,
       message,
       {error: new Error('foo')},
       timestamp,
@@ -241,7 +237,7 @@ describe('general functionality', () => {
 
   test('add/remove transport', () => {
     const timestamp = Date.now()
-    const logger = new Logger({enabled: true})
+    const logger = new Logger({})
     const mockTransport = jest.fn()
 
     const remove = logger.addTransport(mockTransport)
@@ -256,6 +252,7 @@ describe('general functionality', () => {
     expect(mockTransport).toHaveBeenNthCalledWith(
       1,
       LogLevel.Warn,
+      undefined,
       'warn',
       {},
       timestamp,
@@ -263,22 +260,43 @@ describe('general functionality', () => {
   })
 })
 
-describe('debug contexts', () => {
-  const mockTransport = jest.fn()
+describe('create', () => {
+  test('create', () => {
+    const mockTransport = jest.fn()
+    const timestamp = Date.now()
+    const message = nanoid()
+    const logger = Logger.create(Logger.Context.Default)
+
+    logger.addTransport(mockTransport)
+    logger.info(message, {})
+
+    expect(mockTransport).toHaveBeenCalledWith(
+      LogLevel.Info,
+      Logger.Context.Default,
+      message,
+      {},
+      timestamp,
+    )
+  })
+})
 
+describe('debug contexts', () => {
   test('specific', () => {
+    const mockTransport = jest.fn()
     const timestamp = Date.now()
     const message = nanoid()
     const logger = new Logger({
-      enabled: true,
-      debug: 'specific',
+      // @ts-ignore
+      context: 'specific',
+      level: LogLevel.Debug,
     })
 
     logger.addTransport(mockTransport)
-    logger.debug(message, {}, 'specific')
+    logger.debug(message, {})
 
     expect(mockTransport).toHaveBeenCalledWith(
       LogLevel.Debug,
+      'specific',
       message,
       {},
       timestamp,
@@ -286,18 +304,22 @@ describe('debug contexts', () => {
   })
 
   test('namespaced', () => {
+    const mockTransport = jest.fn()
     const timestamp = Date.now()
     const message = nanoid()
     const logger = new Logger({
-      enabled: true,
-      debug: 'namespace*',
+      // @ts-ignore
+      context: 'namespace:foo',
+      contextFilter: 'namespace:*',
+      level: LogLevel.Debug,
     })
 
     logger.addTransport(mockTransport)
-    logger.debug(message, {}, 'namespace')
+    logger.debug(message, {})
 
     expect(mockTransport).toHaveBeenCalledWith(
       LogLevel.Debug,
+      'namespace:foo',
       message,
       {},
       timestamp,
@@ -305,18 +327,21 @@ describe('debug contexts', () => {
   })
 
   test('ignores inactive', () => {
+    const mockTransport = jest.fn()
     const timestamp = Date.now()
     const message = nanoid()
     const logger = new Logger({
-      enabled: true,
-      debug: 'namespace:foo:*',
+      // @ts-ignore
+      context: 'namespace:bar:baz',
+      contextFilter: 'namespace:foo:*',
     })
 
     logger.addTransport(mockTransport)
-    logger.debug(message, {}, 'namespace:bar:baz')
+    logger.debug(message, {})
 
     expect(mockTransport).not.toHaveBeenCalledWith(
       LogLevel.Debug,
+      'namespace:bar:baz',
       message,
       {},
       timestamp,
@@ -328,7 +353,6 @@ describe('supports levels', () => {
   test('debug', () => {
     const timestamp = Date.now()
     const logger = new Logger({
-      enabled: true,
       level: LogLevel.Debug,
     })
     const message = nanoid()
@@ -339,6 +363,7 @@ describe('supports levels', () => {
     logger.debug(message)
     expect(mockTransport).toHaveBeenCalledWith(
       LogLevel.Debug,
+      undefined,
       message,
       {},
       timestamp,
@@ -347,6 +372,7 @@ describe('supports levels', () => {
     logger.info(message)
     expect(mockTransport).toHaveBeenCalledWith(
       LogLevel.Info,
+      undefined,
       message,
       {},
       timestamp,
@@ -355,6 +381,7 @@ describe('supports levels', () => {
     logger.warn(message)
     expect(mockTransport).toHaveBeenCalledWith(
       LogLevel.Warn,
+      undefined,
       message,
       {},
       timestamp,
@@ -362,13 +389,18 @@ describe('supports levels', () => {
 
     const e = new Error(message)
     logger.error(e)
-    expect(mockTransport).toHaveBeenCalledWith(LogLevel.Error, e, {}, timestamp)
+    expect(mockTransport).toHaveBeenCalledWith(
+      LogLevel.Error,
+      undefined,
+      e,
+      {},
+      timestamp,
+    )
   })
 
   test('info', () => {
     const timestamp = Date.now()
     const logger = new Logger({
-      enabled: true,
       level: LogLevel.Info,
     })
     const message = nanoid()
@@ -382,6 +414,7 @@ describe('supports levels', () => {
     logger.info(message)
     expect(mockTransport).toHaveBeenCalledWith(
       LogLevel.Info,
+      undefined,
       message,
       {},
       timestamp,
@@ -391,7 +424,6 @@ describe('supports levels', () => {
   test('warn', () => {
     const timestamp = Date.now()
     const logger = new Logger({
-      enabled: true,
       level: LogLevel.Warn,
     })
     const message = nanoid()
@@ -408,6 +440,7 @@ describe('supports levels', () => {
     logger.warn(message)
     expect(mockTransport).toHaveBeenCalledWith(
       LogLevel.Warn,
+      undefined,
       message,
       {},
       timestamp,
@@ -417,7 +450,6 @@ describe('supports levels', () => {
   test('error', () => {
     const timestamp = Date.now()
     const logger = new Logger({
-      enabled: true,
       level: LogLevel.Error,
     })
     const message = nanoid()
@@ -436,6 +468,12 @@ describe('supports levels', () => {
 
     const e = new Error('original message')
     logger.error(e)
-    expect(mockTransport).toHaveBeenCalledWith(LogLevel.Error, e, {}, timestamp)
+    expect(mockTransport).toHaveBeenCalledWith(
+      LogLevel.Error,
+      undefined,
+      e,
+      {},
+      timestamp,
+    )
   })
 })
diff --git a/src/logger/bitdriftTransport.ts b/src/logger/bitdriftTransport.ts
deleted file mode 100644
index 159b86300..000000000
--- a/src/logger/bitdriftTransport.ts
+++ /dev/null
@@ -1,22 +0,0 @@
-import {
-  debug as bdDebug,
-  error as bdError,
-  info as bdInfo,
-  warn as bdWarn,
-} from '../lib/bitdrift'
-import {LogLevel, Transport} from './types'
-
-export function createBitdriftTransport(): Transport {
-  const logFunctions = {
-    [LogLevel.Debug]: bdDebug,
-    [LogLevel.Info]: bdInfo,
-    [LogLevel.Log]: bdInfo,
-    [LogLevel.Warn]: bdWarn,
-    [LogLevel.Error]: bdError,
-  } as const
-
-  return (level, message) => {
-    const log = logFunctions[level]
-    log('' + message)
-  }
-}
diff --git a/src/logger/debugContext.ts b/src/logger/debugContext.ts
deleted file mode 100644
index 997120786..000000000
--- a/src/logger/debugContext.ts
+++ /dev/null
@@ -1,13 +0,0 @@
-/**
- * *Do not import this directly.* Instead, use the shortcut reference `logger.DebugContext`.
- *
- * Add debug contexts here. Although convention typically calls for enums ito
- * be capitalized, for parity with the `LOG_DEBUG` env var, please use all
- * lowercase.
- */
-export const DebugContext = {
-  // e.g. composer: 'composer'
-  session: 'session',
-  notifications: 'notifications',
-  convo: 'convo',
-} as const
diff --git a/src/logger/index.ts b/src/logger/index.ts
index 102bccef7..70b7ee8be 100644
--- a/src/logger/index.ts
+++ b/src/logger/index.ts
@@ -1,213 +1,89 @@
-import format from 'date-fns/format'
 import {nanoid} from 'nanoid/non-secure'
 
-import {isNetworkError} from '#/lib/strings/errors'
-import {DebugContext} from '#/logger/debugContext'
 import {add} from '#/logger/logDump'
-import {Sentry} from '#/logger/sentry'
-import * as env from '#/env'
-import {createBitdriftTransport} from './bitdriftTransport'
-import {Metadata} from './types'
-import {ConsoleTransportEntry, LogLevel, Transport} from './types'
-
-export {LogLevel}
-export type {ConsoleTransportEntry, Transport}
-
-const enabledLogLevels: {
-  [key in LogLevel]: LogLevel[]
-} = {
-  [LogLevel.Debug]: [
-    LogLevel.Debug,
-    LogLevel.Info,
-    LogLevel.Log,
-    LogLevel.Warn,
-    LogLevel.Error,
-  ],
-  [LogLevel.Info]: [LogLevel.Info, LogLevel.Log, LogLevel.Warn, LogLevel.Error],
-  [LogLevel.Log]: [LogLevel.Log, LogLevel.Warn, LogLevel.Error],
-  [LogLevel.Warn]: [LogLevel.Warn, LogLevel.Error],
-  [LogLevel.Error]: [LogLevel.Error],
-}
-
-export function prepareMetadata(metadata: Metadata): Metadata {
-  return Object.keys(metadata).reduce((acc, key) => {
-    let value = metadata[key]
-    if (value instanceof Error) {
-      value = value.toString()
+import {bitdriftTransport} from '#/logger/transports/bitdrift'
+import {consoleTransport} from '#/logger/transports/console'
+import {sentryTransport} from '#/logger/transports/sentry'
+import {LogContext, LogLevel, Metadata, Transport} from '#/logger/types'
+import {enabledLogLevels} from '#/logger/util'
+
+const TRANSPORTS: Transport[] = (function configureTransports() {
+  switch (process.env.NODE_ENV) {
+    case 'production': {
+      return [sentryTransport, bitdriftTransport].filter(Boolean) as Transport[]
     }
-    return {...acc, [key]: value}
-  }, {})
-}
-
-/**
- * Used in dev mode to nicely log to the console
- */
-export const consoleTransport: Transport = (
-  level,
-  message,
-  metadata,
-  timestamp,
-) => {
-  const extra = Object.keys(metadata).length
-    ? ' ' + JSON.stringify(prepareMetadata(metadata), null, '  ')
-    : ''
-  const log = {
-    [LogLevel.Debug]: console.debug,
-    [LogLevel.Info]: console.info,
-    [LogLevel.Log]: console.log,
-    [LogLevel.Warn]: console.warn,
-    [LogLevel.Error]: console.error,
-  }[level]
-
-  if (message instanceof Error) {
-    console.info(
-      `${format(timestamp, 'HH:mm:ss')} ${message.toString()}${extra}`,
-    )
-    log(message)
-  } else {
-    log(`${format(timestamp, 'HH:mm:ss')} ${message.toString()}${extra}`)
-  }
-}
-
-export const sentryTransport: Transport = (
-  level,
-  message,
-  {type, tags, ...metadata},
-  timestamp,
-) => {
-  const meta = prepareMetadata(metadata)
-
-  /**
-   * If a string, report a breadcrumb
-   */
-  if (typeof message === 'string') {
-    const severity = (
-      {
-        [LogLevel.Debug]: 'debug',
-        [LogLevel.Info]: 'info',
-        [LogLevel.Log]: 'log', // Sentry value here is undefined
-        [LogLevel.Warn]: 'warning',
-        [LogLevel.Error]: 'error',
-      } as const
-    )[level]
-
-    Sentry.addBreadcrumb({
-      message,
-      data: meta,
-      type: type || 'default',
-      level: severity,
-      timestamp: timestamp / 1000, // Sentry expects seconds
-    })
-
-    // We don't want to send any network errors to sentry
-    if (isNetworkError(message)) {
-      return
+    case 'test': {
+      return []
     }
-
-    /**
-     * Send all higher levels with `captureMessage`, with appropriate severity
-     * level
-     */
-    if (level === 'error' || level === 'warn' || level === 'log') {
-      const messageLevel = ({
-        [LogLevel.Log]: 'log',
-        [LogLevel.Warn]: 'warning',
-        [LogLevel.Error]: 'error',
-      }[level] || 'log') as Sentry.Breadcrumb['level']
-      // Defer non-critical messages so they're sent in a batch
-      queueMessageForSentry(message, {
-        level: messageLevel,
-        tags,
-        extra: meta,
-      })
+    default: {
+      return [consoleTransport]
     }
-  } else {
-    /**
-     * It's otherwise an Error and should be reported with captureException
-     */
-    Sentry.captureException(message, {
-      tags,
-      extra: meta,
-    })
   }
-}
+})()
 
-const queuedMessages: [string, Parameters<typeof Sentry.captureMessage>[1]][] =
-  []
-let sentrySendTimeout: ReturnType<typeof setTimeout> | null = null
-function queueMessageForSentry(
-  message: string,
-  captureContext: Parameters<typeof Sentry.captureMessage>[1],
-) {
-  queuedMessages.push([message, captureContext])
-  if (!sentrySendTimeout) {
-    // Throttle sending messages with a leading delay
-    // so that we can get Sentry out of the critical path.
-    sentrySendTimeout = setTimeout(() => {
-      sentrySendTimeout = null
-      sendQueuedMessages()
-    }, 7000)
-  }
-}
-function sendQueuedMessages() {
-  while (queuedMessages.length > 0) {
-    const record = queuedMessages.shift()
-    if (record) {
-      Sentry.captureMessage(record[0], record[1])
-    }
-  }
-}
-
-/**
- * Main class. Defaults are provided in the constructor so that subclasses are
- * technically possible, if we need to go that route in the future.
- */
 export class Logger {
-  LogLevel = LogLevel
-  DebugContext = DebugContext
+  static Level = LogLevel
+  static Context = LogContext
 
-  enabled: boolean
   level: LogLevel
-  transports: Transport[] = []
+  context: LogContext | undefined = undefined
+  contextFilter: string = ''
 
   protected debugContextRegexes: RegExp[] = []
+  protected transports: Transport[] = []
+
+  static create(context?: LogContext) {
+    const logger = new Logger({
+      level: process.env.EXPO_PUBLIC_LOG_LEVEL as LogLevel,
+      context,
+      contextFilter: process.env.EXPO_PUBLIC_LOG_DEBUG || '',
+    })
+    for (const transport of TRANSPORTS) {
+      logger.addTransport(transport)
+    }
+    return logger
+  }
 
   constructor({
-    enabled = process.env.NODE_ENV !== 'test',
-    level = env.LOG_LEVEL as LogLevel,
-    debug = env.LOG_DEBUG || '',
+    level,
+    context,
+    contextFilter,
   }: {
-    enabled?: boolean
     level?: LogLevel
-    debug?: string
+    context?: LogContext
+    contextFilter?: string
   } = {}) {
-    this.enabled = enabled !== false
-    this.level = debug ? LogLevel.Debug : level ?? LogLevel.Info // default to info
-    this.debugContextRegexes = (debug || '').split(',').map(context => {
-      return new RegExp(context.replace(/[^\w:*]/, '').replace(/\*/g, '.*'))
-    })
+    this.context = context
+    this.level = level || LogLevel.Info
+    this.contextFilter = contextFilter || ''
+    if (this.contextFilter) {
+      this.level = LogLevel.Debug
+    }
+    this.debugContextRegexes = (this.contextFilter || '')
+      .split(',')
+      .map(filter => {
+        return new RegExp(filter.replace(/[^\w:*-]/, '').replace(/\*/g, '.*'))
+      })
   }
 
-  debug(message: string, metadata: Metadata = {}, context?: string) {
-    if (context && !this.debugContextRegexes.find(reg => reg.test(context)))
-      return
-    this.transport(LogLevel.Debug, message, metadata)
+  debug(message: string, metadata: Metadata = {}) {
+    this.transport({level: LogLevel.Debug, message, metadata})
   }
 
   info(message: string, metadata: Metadata = {}) {
-    this.transport(LogLevel.Info, message, metadata)
+    this.transport({level: LogLevel.Info, message, metadata})
   }
 
   log(message: string, metadata: Metadata = {}) {
-    this.transport(LogLevel.Log, message, metadata)
+    this.transport({level: LogLevel.Log, message, metadata})
   }
 
   warn(message: string, metadata: Metadata = {}) {
-    this.transport(LogLevel.Warn, message, metadata)
+    this.transport({level: LogLevel.Warn, message, metadata})
   }
 
   error(error: Error | string, metadata: Metadata = {}) {
-    this.transport(LogLevel.Error, error, metadata)
+    this.transport({level: LogLevel.Error, message: error, metadata})
   }
 
   addTransport(transport: Transport) {
@@ -217,20 +93,22 @@ export class Logger {
     }
   }
 
-  disable() {
-    this.enabled = false
-  }
-
-  enable() {
-    this.enabled = true
-  }
-
-  protected transport(
-    level: LogLevel,
-    message: string | Error,
-    metadata: Metadata = {},
-  ) {
-    if (!this.enabled) return
+  protected transport({
+    level,
+    message,
+    metadata = {},
+  }: {
+    level: LogLevel
+    message: string | Error
+    metadata: Metadata
+  }) {
+    if (
+      level === LogLevel.Debug &&
+      !!this.contextFilter &&
+      !!this.context &&
+      !this.debugContextRegexes.find(reg => reg.test(this.context!))
+    )
+      return
 
     const timestamp = Date.now()
     const meta = metadata || {}
@@ -240,6 +118,7 @@ export class Logger {
       id: nanoid(),
       timestamp,
       level,
+      context: this.context,
       message,
       metadata: meta,
     })
@@ -247,37 +126,20 @@ export class Logger {
     if (!enabledLogLevels[this.level].includes(level)) return
 
     for (const transport of this.transports) {
-      transport(level, message, meta, timestamp)
+      transport(level, this.context, message, meta, timestamp)
     }
   }
 }
 
 /**
- * Logger instance. See `@/logger/README` for docs.
+ * Default logger instance. See `@/logger/README` for docs.
  *
  * Basic usage:
  *
- *   `logger.debug(message[, metadata, debugContext])`
+ *   `logger.debug(message[, metadata])`
  *   `logger.info(message[, metadata])`
+ *   `logger.log(message[, metadata])`
  *   `logger.warn(message[, metadata])`
  *   `logger.error(error[, metadata])`
- *   `logger.disable()`
- *   `logger.enable()`
  */
-export const logger = new Logger()
-
-if (process.env.NODE_ENV !== 'test') {
-  logger.addTransport(createBitdriftTransport())
-}
-
-if (process.env.NODE_ENV !== 'test') {
-  if (__DEV__) {
-    logger.addTransport(consoleTransport)
-    /*
-     * Comment this out to enable Sentry transport in dev
-     */
-    // logger.addTransport(sentryTransport)
-  } else {
-    logger.addTransport(sentryTransport)
-  }
-}
+export const logger = Logger.create(Logger.Context.Default)
diff --git a/src/logger/logDump.ts b/src/logger/logDump.ts
index 563b12aa4..12cf899eb 100644
--- a/src/logger/logDump.ts
+++ b/src/logger/logDump.ts
@@ -1,4 +1,13 @@
-import type {ConsoleTransportEntry} from '#/logger'
+import type {LogContext, LogLevel, Metadata} from '#/logger/types'
+
+export type ConsoleTransportEntry = {
+  id: string
+  timestamp: number
+  level: LogLevel
+  context: LogContext | undefined
+  message: string | Error
+  metadata: Metadata
+}
 
 let entries: ConsoleTransportEntry[] = []
 
diff --git a/src/logger/sentry/index.ts b/src/logger/sentry/lib/index.ts
index e771560e7..e771560e7 100644
--- a/src/logger/sentry/index.ts
+++ b/src/logger/sentry/lib/index.ts
diff --git a/src/logger/sentry/index.web.ts b/src/logger/sentry/lib/index.web.ts
index e771560e7..e771560e7 100644
--- a/src/logger/sentry/index.web.ts
+++ b/src/logger/sentry/lib/index.web.ts
diff --git a/src/logger/sentry/setup/index.ts b/src/logger/sentry/setup/index.ts
new file mode 100644
index 000000000..b2695694d
--- /dev/null
+++ b/src/logger/sentry/setup/index.ts
@@ -0,0 +1,40 @@
+/**
+ * Importing these separately from `platform/detection` and `lib/app-info` to
+ * avoid future conflicts and/or circular deps
+ */
+
+import {Platform} from 'react-native'
+import {nativeApplicationVersion, nativeBuildVersion} from 'expo-application'
+import {init} from '@sentry/react-native'
+
+import {IS_TESTFLIGHT} from '#/lib/app-info'
+
+/**
+ * Examples:
+ * - `dev`
+ * - `1.57.0`
+ */
+const release = nativeApplicationVersion ?? 'dev'
+
+/**
+ * Examples:
+ * - `web.dev`
+ * - `ios.dev`
+ * - `android.dev`
+ * - `web.1.57.0`
+ * - `ios.1.57.0.3`
+ * - `android.1.57.0.46`
+ */
+const dist = `${Platform.OS}.${nativeBuildVersion}.${
+  IS_TESTFLIGHT ? 'tf' : ''
+}${__DEV__ ? 'dev' : ''}`
+
+init({
+  enabled: !__DEV__,
+  autoSessionTracking: false,
+  dsn: 'https://05bc3789bf994b81bd7ce20c86ccd3ae@o4505071687041024.ingest.sentry.io/4505071690514432',
+  debug: false, // If `true`, Sentry will try to print out useful debugging information if something goes wrong with sending the event. Set it to `false` in production
+  environment: process.env.NODE_ENV,
+  dist,
+  release,
+})
diff --git a/src/logger/transports/bitdrift.ts b/src/logger/transports/bitdrift.ts
new file mode 100644
index 000000000..6e335f29c
--- /dev/null
+++ b/src/logger/transports/bitdrift.ts
@@ -0,0 +1,30 @@
+import {
+  debug as bdDebug,
+  error as bdError,
+  info as bdInfo,
+  warn as bdWarn,
+} from '#/lib/bitdrift'
+import {LogLevel, Transport} from '#/logger/types'
+import {prepareMetadata} from '#/logger/util'
+
+const logFunctions = {
+  [LogLevel.Debug]: bdDebug,
+  [LogLevel.Info]: bdInfo,
+  [LogLevel.Log]: bdInfo,
+  [LogLevel.Warn]: bdWarn,
+  [LogLevel.Error]: bdError,
+} as const
+
+export const bitdriftTransport: Transport = (
+  level,
+  context,
+  message,
+  metadata,
+) => {
+  const log = logFunctions[level]
+  log(message.toString(), {
+    // match Sentry payload
+    context,
+    ...prepareMetadata(metadata),
+  })
+}
diff --git a/src/logger/transports/console.ts b/src/logger/transports/console.ts
new file mode 100644
index 000000000..6a687c69b
--- /dev/null
+++ b/src/logger/transports/console.ts
@@ -0,0 +1,90 @@
+import format from 'date-fns/format'
+
+import {LogLevel, Transport} from '#/logger/types'
+import {prepareMetadata} from '#/logger/util'
+import {isWeb} from '#/platform/detection'
+
+/**
+ * Used in dev mode to nicely log to the console
+ */
+export const consoleTransport: Transport = (
+  level,
+  context,
+  message,
+  metadata,
+  timestamp,
+) => {
+  const hasMetadata = Object.keys(metadata).length
+  const colorize = withColor(
+    {
+      [LogLevel.Debug]: colors.magenta,
+      [LogLevel.Info]: colors.blue,
+      [LogLevel.Log]: colors.green,
+      [LogLevel.Warn]: colors.yellow,
+      [LogLevel.Error]: colors.red,
+    }[level],
+  )
+
+  let msg = `${colorize(format(timestamp, 'HH:mm:ss'))}`
+  if (context) {
+    msg += ` ${colorize(`(${context})`)}`
+  }
+  if (message) {
+    msg += ` ${message.toString()}`
+  }
+
+  if (isWeb) {
+    if (hasMetadata) {
+      console.groupCollapsed(msg)
+      console.log(metadata)
+      console.groupEnd()
+    } else {
+      console.log(msg)
+    }
+    if (message instanceof Error) {
+      // for stacktrace
+      console.error(message)
+    }
+  } else {
+    if (hasMetadata) {
+      msg += ` ${JSON.stringify(prepareMetadata(metadata), null, 2)}`
+    }
+    console.log(msg)
+    if (message instanceof Error) {
+      // for stacktrace
+      console.error(message)
+    }
+  }
+}
+
+/**
+ * Color handling copied from Kleur
+ *
+ * @see https://github.com/lukeed/kleur/blob/fa3454483899ddab550d08c18c028e6db1aab0e5/colors.mjs#L13
+ */
+const colors: {
+  [key: string]: [number, number]
+} = {
+  default: [0, 0],
+  blue: [36, 39],
+  green: [32, 39],
+  magenta: [35, 39],
+  red: [31, 39],
+  yellow: [33, 39],
+}
+
+function withColor([x, y]: [number, number]) {
+  const rgx = new RegExp(`\\x1b\\[${y}m`, 'g')
+  const open = `\x1b[${x}m`,
+    close = `\x1b[${y}m`
+
+  return function (txt: string) {
+    if (txt == null) return txt
+
+    return (
+      open +
+      (~('' + txt).indexOf(close) ? txt.replace(rgx, close + open) : txt) +
+      close
+    )
+  }
+}
diff --git a/src/logger/transports/sentry.ts b/src/logger/transports/sentry.ts
new file mode 100644
index 000000000..890918d67
--- /dev/null
+++ b/src/logger/transports/sentry.ts
@@ -0,0 +1,102 @@
+import {isNetworkError} from '#/lib/strings/errors'
+import {Sentry} from '#/logger/sentry/lib'
+import {LogLevel, Transport} from '#/logger/types'
+import {prepareMetadata} from '#/logger/util'
+
+export const sentryTransport: Transport = (
+  level,
+  context,
+  message,
+  {type, tags, ...metadata},
+  timestamp,
+) => {
+  const meta = {
+    // match Bitdrift payload
+    context,
+    ...prepareMetadata(metadata),
+  }
+  let _tags = tags || {}
+  _tags = {
+    // use `category` to match breadcrumbs
+    category: context,
+    ...tags,
+  }
+
+  /**
+   * If a string, report a breadcrumb
+   */
+  if (typeof message === 'string') {
+    const severity = (
+      {
+        [LogLevel.Debug]: 'debug',
+        [LogLevel.Info]: 'info',
+        [LogLevel.Log]: 'log', // Sentry value here is undefined
+        [LogLevel.Warn]: 'warning',
+        [LogLevel.Error]: 'error',
+      } as const
+    )[level]
+
+    Sentry.addBreadcrumb({
+      category: context,
+      message,
+      data: meta,
+      type: type || 'default',
+      level: severity,
+      timestamp: timestamp / 1000, // Sentry expects seconds
+    })
+
+    // We don't want to send any network errors to sentry
+    if (isNetworkError(message)) {
+      return
+    }
+
+    /**
+     * Send all higher levels with `captureMessage`, with appropriate severity
+     * level
+     */
+    if (level === 'error' || level === 'warn' || level === 'log') {
+      // Defer non-critical messages so they're sent in a batch
+      queueMessageForSentry(message, {
+        level: severity,
+        tags: _tags,
+        extra: meta,
+      })
+    }
+  } else {
+    /**
+     * It's otherwise an Error and should be reported with captureException
+     */
+    Sentry.captureException(message, {
+      tags: _tags,
+      extra: meta,
+    })
+  }
+}
+
+const queuedMessages: [string, Parameters<typeof Sentry.captureMessage>[1]][] =
+  []
+let sentrySendTimeout: ReturnType<typeof setTimeout> | null = null
+
+function queueMessageForSentry(
+  message: string,
+  captureContext: Parameters<typeof Sentry.captureMessage>[1],
+) {
+  queuedMessages.push([message, captureContext])
+  if (!sentrySendTimeout) {
+    // Throttle sending messages with a leading delay
+    // so that we can get Sentry out of the critical path.
+    sentrySendTimeout = setTimeout(() => {
+      sentrySendTimeout = null
+      sendQueuedMessages()
+    }, 7000)
+  }
+}
+
+function sendQueuedMessages() {
+  while (queuedMessages.length > 0) {
+    const record = queuedMessages.shift()
+    if (record) {
+      Sentry.captureMessage(record[0], record[1])
+    }
+  }
+}
diff --git a/src/logger/types.ts b/src/logger/types.ts
index 252e7373b..517893d29 100644
--- a/src/logger/types.ts
+++ b/src/logger/types.ts
@@ -1,4 +1,15 @@
-import type {Sentry} from '#/logger/sentry'
+/**
+ * DO NOT IMPORT THIS DIRECTLY
+ *
+ * Logger contexts, defined here and used via `Logger.Context.*` static prop.
+ */
+export enum LogContext {
+  Default = 'logger',
+  Session = 'session',
+  Notifications = 'notifications',
+  ConversationAgent = 'conversation-agent',
+  DMsAgent = 'dms-agent',
+}
 
 export enum LogLevel {
   Debug = 'debug',
@@ -10,6 +21,7 @@ export enum LogLevel {
 
 export type Transport = (
   level: LogLevel,
+  context: LogContext | undefined,
   message: string | Error,
   metadata: Metadata,
   timestamp: number,
@@ -21,6 +33,11 @@ export type Transport = (
  */
 export type Metadata = {
   /**
+   * Reserved for appending `LogContext` to logging payloads
+   */
+  context?: undefined
+
+  /**
    * Applied as Sentry breadcrumb types. Defaults to `default`.
    *
    * @see https://develop.sentry.dev/sdk/event-payloads/breadcrumbs/#breadcrumb-types
@@ -43,27 +60,23 @@ export type Metadata = {
    * @see https://github.com/getsentry/sentry-javascript/blob/903addf9a1a1534a6cb2ba3143654b918a86f6dd/packages/types/src/misc.ts#L65
    */
   tags?: {
-    [key: string]:
-      | number
-      | string
-      | boolean
-      | bigint
-      | symbol
-      | null
-      | undefined
+    [key: string]: number | string | boolean | null | undefined
   }
 
   /**
    * Any additional data, passed through to Sentry as `extra` param on
    * exceptions, or the `data` param on breadcrumbs.
    */
-  [key: string]: unknown
-} & Parameters<typeof Sentry.captureException>[1]
-
-export type ConsoleTransportEntry = {
-  id: string
-  timestamp: number
-  level: LogLevel
-  message: string | Error
-  metadata: Metadata
+  [key: string]: Serializable | Error | unknown
 }
+
+export type Serializable =
+  | string
+  | number
+  | boolean
+  | null
+  | undefined
+  | Serializable[]
+  | {
+      [key: string]: Serializable
+    }
diff --git a/src/logger/util.ts b/src/logger/util.ts
new file mode 100644
index 000000000..f1e12b164
--- /dev/null
+++ b/src/logger/util.ts
@@ -0,0 +1,29 @@
+import {LogLevel, Metadata, Serializable} from '#/logger/types'
+
+export const enabledLogLevels: {
+  [key in LogLevel]: LogLevel[]
+} = {
+  [LogLevel.Debug]: [
+    LogLevel.Debug,
+    LogLevel.Info,
+    LogLevel.Log,
+    LogLevel.Warn,
+    LogLevel.Error,
+  ],
+  [LogLevel.Info]: [LogLevel.Info, LogLevel.Log, LogLevel.Warn, LogLevel.Error],
+  [LogLevel.Log]: [LogLevel.Log, LogLevel.Warn, LogLevel.Error],
+  [LogLevel.Warn]: [LogLevel.Warn, LogLevel.Error],
+  [LogLevel.Error]: [LogLevel.Error],
+}
+
+export function prepareMetadata(
+  metadata: Metadata,
+): Record<string, Serializable> {
+  return Object.keys(metadata).reduce((acc, key) => {
+    let value = metadata[key]
+    if (value instanceof Error) {
+      value = value.toString()
+    }
+    return {...acc, [key]: value}
+  }, {})
+}