about summary refs log tree commit diff
diff options
context:
space:
mode:
-rw-r--r--.env.example2
-rw-r--r--package.json22
-rw-r--r--src/env.ts9
-rw-r--r--src/lib/api/index.ts7
-rw-r--r--src/lib/hooks/useFollowProfile.ts4
-rw-r--r--src/lib/hooks/useOTAUpdate.ts12
-rw-r--r--src/lib/notifications/notifications.ts19
-rw-r--r--src/logger/README.md99
-rw-r--r--src/logger/__tests__/logDump.test.ts36
-rw-r--r--src/logger/__tests__/logger.test.ts424
-rw-r--r--src/logger/debugContext.ts10
-rw-r--r--src/logger/index.ts290
-rw-r--r--src/logger/logDump.ts12
-rw-r--r--src/logger/sentry/index.ts1
-rw-r--r--src/logger/sentry/index.web.ts1
-rw-r--r--src/state/index.ts2
-rw-r--r--src/state/models/content/feed-source.ts10
-rw-r--r--src/state/models/content/list.ts8
-rw-r--r--src/state/models/content/post-thread.ts2
-rw-r--r--src/state/models/content/profile.ts2
-rw-r--r--src/state/models/discovery/feeds.ts2
-rw-r--r--src/state/models/discovery/suggested-actors.ts2
-rw-r--r--src/state/models/feeds/notifications.ts19
-rw-r--r--src/state/models/feeds/post.ts17
-rw-r--r--src/state/models/feeds/posts.ts9
-rw-r--r--src/state/models/invited-users.ts7
-rw-r--r--src/state/models/lists/actor-feeds.ts2
-rw-r--r--src/state/models/lists/blocked-accounts.ts2
-rw-r--r--src/state/models/lists/likes.ts2
-rw-r--r--src/state/models/lists/lists-list.ts6
-rw-r--r--src/state/models/lists/muted-accounts.ts2
-rw-r--r--src/state/models/lists/reposted-by.ts2
-rw-r--r--src/state/models/lists/user-followers.ts2
-rw-r--r--src/state/models/me.ts22
-rw-r--r--src/state/models/media/image.ts2
-rw-r--r--src/state/models/root-store.ts8
-rw-r--r--src/state/models/ui/create-account.ts4
-rw-r--r--src/state/models/ui/profile.ts12
-rw-r--r--src/state/models/ui/saved-feeds.ts2
-rw-r--r--src/view/com/auth/login/Login.tsx8
-rw-r--r--src/view/com/composer/photos/OpenCameraBtn.tsx2
-rw-r--r--src/view/com/composer/useExternalLinkFetch.ts8
-rw-r--r--src/view/com/feeds/FeedSourceCard.tsx4
-rw-r--r--src/view/com/lists/ListItems.tsx4
-rw-r--r--src/view/com/lists/ListsList.tsx4
-rw-r--r--src/view/com/modals/AddAppPasswords.tsx2
-rw-r--r--src/view/com/modals/ChangeHandle.tsx6
-rw-r--r--src/view/com/modals/ContentFilteringSettings.tsx4
-rw-r--r--src/view/com/modals/UserAddRemoveLists.tsx4
-rw-r--r--src/view/com/notifications/Feed.tsx8
-rw-r--r--src/view/com/post-thread/PostLikedBy.tsx8
-rw-r--r--src/view/com/post-thread/PostRepostedBy.tsx4
-rw-r--r--src/view/com/post-thread/PostThread.tsx2
-rw-r--r--src/view/com/post-thread/PostThreadItem.tsx8
-rw-r--r--src/view/com/post/Post.tsx8
-rw-r--r--src/view/com/posts/Feed.tsx4
-rw-r--r--src/view/com/posts/FeedErrorMessage.tsx2
-rw-r--r--src/view/com/posts/FeedItem.tsx8
-rw-r--r--src/view/com/profile/ProfileFollowers.tsx14
-rw-r--r--src/view/com/profile/ProfileHeader.tsx10
-rw-r--r--src/view/screens/Log.tsx14
-rw-r--r--src/view/screens/ModerationBlockedAccounts.tsx2
-rw-r--r--src/view/screens/ModerationMutedAccounts.tsx2
-rw-r--r--src/view/screens/PostThread.tsx2
-rw-r--r--src/view/screens/Profile.tsx12
-rw-r--r--src/view/screens/ProfileFeed.tsx6
-rw-r--r--src/view/screens/ProfileList.tsx2
-rw-r--r--src/view/screens/SavedFeeds.tsx6
-rw-r--r--src/view/screens/Settings.tsx2
-rw-r--r--yarn.lock19
70 files changed, 1109 insertions, 176 deletions
diff --git a/.env.example b/.env.example
index b4213aea2..d4db46ab9 100644
--- a/.env.example
+++ b/.env.example
@@ -1 +1,3 @@
 SENTRY_AUTH_TOKEN=
+EXPO_PUBLIC_LOG_LEVEL=debug
+EXPO_PUBLIC_LOG_DEBUG=
diff --git a/package.json b/package.json
index 3e280eb0d..efb0942df 100644
--- a/package.json
+++ b/package.json
@@ -13,21 +13,21 @@
     "start": "expo start --dev-client",
     "start:prod": "expo start --dev-client --no-dev --minify",
     "clean-cache": "rm -rf node_modules/.cache/babel-loader/*",
-    "test": "jest --forceExit --testTimeout=20000 --bail",
-    "test-watch": "jest --watchAll",
-    "test-ci": "jest --ci --forceExit --reporters=default --reporters=jest-junit",
-    "test-coverage": "jest --coverage",
+    "test": "NODE_ENV=test jest --forceExit --testTimeout=20000 --bail",
+    "test-watch": "NODE_ENV=test jest --watchAll",
+    "test-ci": "NODE_ENV=test jest --ci --forceExit --reporters=default --reporters=jest-junit",
+    "test-coverage": "NODE_ENV=test jest --coverage",
     "lint": "eslint ./src --ext .js,.jsx,.ts,.tsx",
     "typecheck": "tsc --project ./tsconfig.check.json",
     "e2e:mock-server": "./jest/dev-infra/with-test-redis-and-db.sh ts-node __e2e__/mock-server.ts",
     "e2e:metro": "RN_SRC_EXT=e2e.ts,e2e.tsx expo run:ios",
     "e2e:build": "detox build -c ios.sim.debug",
     "e2e:run": "detox test --configuration ios.sim.debug --take-screenshots all",
-    "perf:test": "maestro test",
-    "perf:test:run": "maestro test __e2e__/maestro/scroll.yaml",
-    "perf:test:measure": "flashlight test --bundleId xyz.blueskyweb.app --testCommand 'yarn perf:test' --duration 150000 --resultsFilePath .perf/results.json",
-    "perf:test:results": "flashlight report .perf/results.json",
-    "perf:measure": "flashlight measure",
+    "perf:test": "NODE_ENV=test maestro test",
+    "perf:test:run": "NODE_ENV=test maestro test __e2e__/maestro/scroll.yaml",
+    "perf:test:measure": "NODE_ENV=test flashlight test --bundleId xyz.blueskyweb.app --testCommand 'yarn perf:test' --duration 150000 --resultsFilePath .perf/results.json",
+    "perf:test:results": "NODE_ENV=test flashlight report .perf/results.json",
+    "perf:measure": "NODE_ENV=test flashlight measure",
     "build:apk": "eas build -p android --profile dev-android-apk"
   },
   "dependencies": {
@@ -80,6 +80,7 @@
     "babel-plugin-transform-remove-console": "^6.9.4",
     "base64-js": "^1.5.1",
     "bcp-47-match": "^2.0.3",
+    "date-fns": "^2.30.0",
     "email-validator": "^2.0.4",
     "emoji-mart": "^5.5.2",
     "eventemitter3": "^5.0.1",
@@ -118,6 +119,7 @@
     "mobx": "^6.6.1",
     "mobx-react-lite": "^3.4.0",
     "mobx-utils": "^6.0.6",
+    "nanoid": "^5.0.2",
     "normalize-url": "^8.0.0",
     "patch-package": "^6.5.1",
     "postinstall-postinstall": "^2.1.0",
@@ -240,7 +242,7 @@
       "\\.[jt]sx?$": "babel-jest"
     },
     "transformIgnorePatterns": [
-      "node_modules/(?!((jest-)?react-native|@react-native(-community)?)|expo(nent)?|@expo(nent)?/.*|@expo-google-fonts/.*|react-navigation|@react-navigation/.*|@unimodules/.*|unimodules|sentry-expo|native-base|normalize-url|react-native-svg|@sentry/.*|sentry-expo|bcp-47-match)"
+      "node_modules/(?!((jest-)?react-native|@react-native(-community)?)|expo(nent)?|@expo(nent)?/.*|@expo-google-fonts/.*|react-navigation|nanoid|@react-navigation/.*|@unimodules/.*|unimodules|sentry-expo|native-base|normalize-url|react-native-svg|@sentry/.*|sentry-expo|bcp-47-match)"
     ],
     "modulePathIgnorePatterns": [
       "__tests__/.*/__mocks__",
diff --git a/src/env.ts b/src/env.ts
new file mode 100644
index 000000000..7b255e7ea
--- /dev/null
+++ b/src/env.ts
@@ -0,0 +1,9 @@
+export const IS_TEST = process.env.NODE_ENV === 'test'
+export const IS_DEV = __DEV__
+export const IS_PROD = !IS_DEV
+export const LOG_DEBUG = process.env.EXPO_PUBLIC_LOG_DEBUG || ''
+export const LOG_LEVEL = (process.env.EXPO_PUBLIC_LOG_LEVEL || 'info') as
+  | 'debug'
+  | 'info'
+  | 'warn'
+  | 'error'
diff --git a/src/lib/api/index.ts b/src/lib/api/index.ts
index f930bd7b1..f75ebbd96 100644
--- a/src/lib/api/index.ts
+++ b/src/lib/api/index.ts
@@ -178,10 +178,9 @@ export async function post(store: RootStoreModel, opts: PostOpts) {
         ) {
           encoding = 'image/jpeg'
         } else {
-          store.log.warn(
-            'Unexpected image format for thumbnail, skipping',
-            opts.extLink.localThumb.path,
-          )
+          store.log.warn('Unexpected image format for thumbnail, skipping', {
+            thumbnail: opts.extLink.localThumb.path,
+          })
         }
         if (encoding) {
           const thumbUploadRes = await uploadBlob(
diff --git a/src/lib/hooks/useFollowProfile.ts b/src/lib/hooks/useFollowProfile.ts
index 6220daba8..ca3f7ab8e 100644
--- a/src/lib/hooks/useFollowProfile.ts
+++ b/src/lib/hooks/useFollowProfile.ts
@@ -22,7 +22,7 @@ export function useFollowProfile(profile: AppBskyActorDefs.ProfileViewBasic) {
             following: false,
           }
         } catch (e: any) {
-          store.log.error('Failed to delete follow', e)
+          store.log.error('Failed to delete follow', {error: e})
           throw e
         }
       } else if (state === FollowState.NotFollowing) {
@@ -40,7 +40,7 @@ export function useFollowProfile(profile: AppBskyActorDefs.ProfileViewBasic) {
             following: true,
           }
         } catch (e: any) {
-          store.log.error('Failed to create follow', e)
+          store.log.error('Failed to create follow', {error: e})
           throw e
         }
       }
diff --git a/src/lib/hooks/useOTAUpdate.ts b/src/lib/hooks/useOTAUpdate.ts
index 5155a808f..d7855b2d4 100644
--- a/src/lib/hooks/useOTAUpdate.ts
+++ b/src/lib/hooks/useOTAUpdate.ts
@@ -34,18 +34,18 @@ export function useOTAUpdate() {
       // show a popup modal
       showUpdatePopup()
     } catch (e) {
-      console.error('useOTAUpdate: Error while checking for update', e)
-      store.log.error('useOTAUpdate: Error while checking for update', e)
+      store.log.error('useOTAUpdate: Error while checking for update', {
+        error: e,
+      })
     }
   }, [showUpdatePopup, store.log])
   const updateEventListener = useCallback(
     (event: Updates.UpdateEvent) => {
       store.log.debug('useOTAUpdate: Listening for update...')
       if (event.type === Updates.UpdateEventType.ERROR) {
-        store.log.error(
-          'useOTAUpdate: Error while listening for update',
-          event.message,
-        )
+        store.log.error('useOTAUpdate: Error while listening for update', {
+          message: event.message,
+        })
       } else if (event.type === Updates.UpdateEventType.NO_UPDATE_AVAILABLE) {
         // Handle no update available
         // do nothing
diff --git a/src/lib/notifications/notifications.ts b/src/lib/notifications/notifications.ts
index dfc9a42b1..01b0ba935 100644
--- a/src/lib/notifications/notifications.ts
+++ b/src/lib/notifications/notifications.ts
@@ -30,18 +30,18 @@ export function init(store: RootStoreModel) {
           appId: 'xyz.blueskyweb.app',
         })
         store.log.debug('Notifications: Sent push token (init)', {
-          type: token.type,
+          tokenType: token.type,
           token: token.data,
         })
       } catch (error) {
-        store.log.error('Notifications: Failed to set push token', error)
+        store.log.error('Notifications: Failed to set push token', {error})
       }
     }
 
     // listens for new changes to the push token
     // In rare situations, a push token may be changed by the push notification service while the app is running. When a token is rolled, the old one becomes invalid and sending notifications to it will fail. A push token listener will let you handle this situation gracefully by registering the new token with your backend right away.
     Notifications.addPushTokenListener(async ({data: t, type}) => {
-      store.log.debug('Notifications: Push token changed', {t, type})
+      store.log.debug('Notifications: Push token changed', {t, tokenType: type})
       if (t) {
         try {
           await store.agent.api.app.bsky.notification.registerPush({
@@ -51,11 +51,11 @@ export function init(store: RootStoreModel) {
             appId: 'xyz.blueskyweb.app',
           })
           store.log.debug('Notifications: Sent push token (event)', {
-            type,
+            tokenType: type,
             token: t,
           })
         } catch (error) {
-          store.log.error('Notifications: Failed to set push token', error)
+          store.log.error('Notifications: Failed to set push token', {error})
         }
       }
     })
@@ -63,7 +63,7 @@ export function init(store: RootStoreModel) {
 
   // handle notifications that are received, both in the foreground or background
   Notifications.addNotificationReceivedListener(event => {
-    store.log.debug('Notifications: received', event)
+    store.log.debug('Notifications: received', {event})
     if (event.request.trigger.type === 'push') {
       // refresh notifications in the background
       store.me.notifications.syncQueue()
@@ -84,10 +84,9 @@ export function init(store: RootStoreModel) {
   // handle notifications that are tapped on
   const sub = Notifications.addNotificationResponseReceivedListener(
     response => {
-      store.log.debug(
-        'Notifications: response received',
-        response.actionIdentifier,
-      )
+      store.log.debug('Notifications: response received', {
+        actionIdentifier: response.actionIdentifier,
+      })
       if (
         response.actionIdentifier === Notifications.DEFAULT_ACTION_IDENTIFIER
       ) {
diff --git a/src/logger/README.md b/src/logger/README.md
new file mode 100644
index 000000000..1dfd5da23
--- /dev/null
+++ b/src/logger/README.md
@@ -0,0 +1,99 @@
+# Logger
+
+Simple logger for Bluesky. Supports log levels, debug contexts, and separate
+transports for production, dev, and test mode.
+
+## At a Glance
+
+```typescript
+import { 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
+
+The "modes" referred to here are inferred from the values exported from `#/env`.
+Basically, the booleans `IS_DEV`, `IS_TEST`, and `IS_PROD`.
+
+#### Log Levels
+
+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`.
+
+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`.
+
+## Usage
+
+```typescript
+import { logger } from '#/logger';
+```
+
+### `logger.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
+try {
+  // some async code
+} catch (e) {
+  logger.error(e, { ...metadata });
+}
+```
+
+### `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`.
+
+For example, a debug log like this:
+
+```typescript
+logger.debug(message, {}, logger.DebugContext.composer);
+```
+
+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`._
diff --git a/src/logger/__tests__/logDump.test.ts b/src/logger/__tests__/logDump.test.ts
new file mode 100644
index 000000000..135998223
--- /dev/null
+++ b/src/logger/__tests__/logDump.test.ts
@@ -0,0 +1,36 @@
+import {expect, test} from '@jest/globals'
+
+import {ConsoleTransportEntry, LogLevel} from '#/logger'
+import {add, getEntries} from '#/logger/logDump'
+
+test('works', () => {
+  const items: ConsoleTransportEntry[] = [
+    {
+      id: '1',
+      level: LogLevel.Debug,
+      message: 'hello',
+      metadata: {},
+      timestamp: Date.now(),
+    },
+    {
+      id: '2',
+      level: LogLevel.Debug,
+      message: 'hello',
+      metadata: {},
+      timestamp: Date.now(),
+    },
+    {
+      id: '3',
+      level: LogLevel.Debug,
+      message: 'hello',
+      metadata: {},
+      timestamp: Date.now(),
+    },
+  ]
+
+  for (const item of items) {
+    add(item)
+  }
+
+  expect(getEntries()).toEqual(items.reverse())
+})
diff --git a/src/logger/__tests__/logger.test.ts b/src/logger/__tests__/logger.test.ts
new file mode 100644
index 000000000..46a5be610
--- /dev/null
+++ b/src/logger/__tests__/logger.test.ts
@@ -0,0 +1,424 @@
+import {nanoid} from 'nanoid/non-secure'
+import {jest, describe, expect, test, beforeAll} from '@jest/globals'
+import {Native as Sentry} from 'sentry-expo'
+
+import {Logger, LogLevel, sentryTransport} from '#/logger'
+
+jest.mock('#/env', () => ({
+  IS_TEST: true,
+  IS_DEV: false,
+  IS_PROD: false,
+  /*
+   * Forces debug mode for tests using the default logger. Most tests create
+   * their own logger instance.
+   */
+  LOG_LEVEL: 'debug',
+  LOG_DEBUG: '',
+}))
+
+jest.mock('sentry-expo', () => ({
+  Native: {
+    addBreadcrumb: jest.fn(),
+    captureException: jest.fn(),
+    captureMessage: jest.fn(),
+  },
+}))
+
+beforeAll(() => {
+  jest.useFakeTimers()
+})
+
+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', () => {
+    const logger = new Logger({
+      enabled: false,
+      level: LogLevel.Debug,
+    })
+
+    const mockTransport = jest.fn()
+
+    logger.addTransport(mockTransport)
+    logger.debug('message')
+
+    expect(mockTransport).not.toHaveBeenCalled()
+  })
+
+  test('disablement', () => {
+    const logger = new Logger({
+      enabled: true,
+      level: LogLevel.Debug,
+    })
+
+    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 mockTransport = jest.fn()
+
+    logger.addTransport(mockTransport)
+
+    const extra = {foo: true}
+    logger.warn('message', extra)
+
+    expect(mockTransport).toHaveBeenCalledWith(
+      LogLevel.Warn,
+      'message',
+      extra,
+      timestamp,
+    )
+  })
+
+  test('supports nullish/falsy metadata', () => {
+    const timestamp = Date.now()
+    const logger = new Logger({enabled: true})
+
+    const mockTransport = jest.fn()
+
+    const remove = logger.addTransport(mockTransport)
+
+    // @ts-expect-error testing the JS case
+    logger.warn('a', null)
+    expect(mockTransport).toHaveBeenCalledWith(
+      LogLevel.Warn,
+      'a',
+      {},
+      timestamp,
+    )
+
+    // @ts-expect-error testing the JS case
+    logger.warn('b', false)
+    expect(mockTransport).toHaveBeenCalledWith(
+      LogLevel.Warn,
+      'b',
+      {},
+      timestamp,
+    )
+
+    // @ts-expect-error testing the JS case
+    logger.warn('c', 0)
+    expect(mockTransport).toHaveBeenCalledWith(
+      LogLevel.Warn,
+      'c',
+      {},
+      timestamp,
+    )
+
+    remove()
+
+    logger.addTransport((level, message, metadata) => {
+      expect(typeof metadata).toEqual('object')
+    })
+
+    // @ts-expect-error testing the JS case
+    logger.warn('message', null)
+  })
+
+  test('sentryTransport', () => {
+    const message = 'message'
+    const timestamp = Date.now()
+    const sentryTimestamp = timestamp / 1000
+
+    sentryTransport(LogLevel.Debug, message, {}, timestamp)
+    expect(Sentry.addBreadcrumb).toHaveBeenCalledWith({
+      message,
+      data: {},
+      type: 'default',
+      level: LogLevel.Debug,
+      timestamp: sentryTimestamp,
+    })
+
+    sentryTransport(
+      LogLevel.Info,
+      message,
+      {type: 'info', prop: true},
+      timestamp,
+    )
+    expect(Sentry.addBreadcrumb).toHaveBeenCalledWith({
+      message,
+      data: {prop: true},
+      type: 'info',
+      level: LogLevel.Info,
+      timestamp: sentryTimestamp,
+    })
+
+    sentryTransport(LogLevel.Log, message, {}, timestamp)
+    expect(Sentry.addBreadcrumb).toHaveBeenCalledWith({
+      message,
+      data: {},
+      type: 'default',
+      level: 'debug', // Sentry bug, log becomes debug
+      timestamp: sentryTimestamp,
+    })
+    expect(Sentry.captureMessage).toHaveBeenCalledWith(message, {
+      level: 'log',
+      tags: undefined,
+      extra: {},
+    })
+
+    sentryTransport(LogLevel.Warn, message, {}, timestamp)
+    expect(Sentry.addBreadcrumb).toHaveBeenCalledWith({
+      message,
+      data: {},
+      type: 'default',
+      level: 'warning',
+      timestamp: sentryTimestamp,
+    })
+    expect(Sentry.captureMessage).toHaveBeenCalledWith(message, {
+      level: 'warning',
+      tags: undefined,
+      extra: {},
+    })
+
+    const e = new Error('error')
+    const tags = {
+      prop: 'prop',
+    }
+
+    sentryTransport(
+      LogLevel.Error,
+      e,
+      {
+        tags,
+        prop: true,
+      },
+      timestamp,
+    )
+
+    expect(Sentry.captureException).toHaveBeenCalledWith(e, {
+      tags,
+      extra: {
+        prop: true,
+      },
+    })
+  })
+
+  test('add/remove transport', () => {
+    const timestamp = Date.now()
+    const logger = new Logger({enabled: true})
+    const mockTransport = jest.fn()
+
+    const remove = logger.addTransport(mockTransport)
+
+    logger.warn('warn')
+
+    remove()
+
+    logger.warn('warn')
+
+    // only called once bc it was removed
+    expect(mockTransport).toHaveBeenNthCalledWith(
+      1,
+      LogLevel.Warn,
+      'warn',
+      {},
+      timestamp,
+    )
+  })
+})
+
+describe('debug contexts', () => {
+  const mockTransport = jest.fn()
+
+  test('specific', () => {
+    const timestamp = Date.now()
+    const message = nanoid()
+    const logger = new Logger({
+      enabled: true,
+      debug: 'specific',
+    })
+
+    logger.addTransport(mockTransport)
+    logger.debug(message, {}, 'specific')
+
+    expect(mockTransport).toHaveBeenCalledWith(
+      LogLevel.Debug,
+      message,
+      {},
+      timestamp,
+    )
+  })
+
+  test('namespaced', () => {
+    const timestamp = Date.now()
+    const message = nanoid()
+    const logger = new Logger({
+      enabled: true,
+      debug: 'namespace*',
+    })
+
+    logger.addTransport(mockTransport)
+    logger.debug(message, {}, 'namespace')
+
+    expect(mockTransport).toHaveBeenCalledWith(
+      LogLevel.Debug,
+      message,
+      {},
+      timestamp,
+    )
+  })
+
+  test('ignores inactive', () => {
+    const timestamp = Date.now()
+    const message = nanoid()
+    const logger = new Logger({
+      enabled: true,
+      debug: 'namespace:foo:*',
+    })
+
+    logger.addTransport(mockTransport)
+    logger.debug(message, {}, 'namespace:bar:baz')
+
+    expect(mockTransport).not.toHaveBeenCalledWith(
+      LogLevel.Debug,
+      message,
+      {},
+      timestamp,
+    )
+  })
+})
+
+describe('supports levels', () => {
+  test('debug', () => {
+    const timestamp = Date.now()
+    const logger = new Logger({
+      enabled: true,
+      level: LogLevel.Debug,
+    })
+    const message = nanoid()
+    const mockTransport = jest.fn()
+
+    logger.addTransport(mockTransport)
+
+    logger.debug(message)
+    expect(mockTransport).toHaveBeenCalledWith(
+      LogLevel.Debug,
+      message,
+      {},
+      timestamp,
+    )
+
+    logger.info(message)
+    expect(mockTransport).toHaveBeenCalledWith(
+      LogLevel.Info,
+      message,
+      {},
+      timestamp,
+    )
+
+    logger.warn(message)
+    expect(mockTransport).toHaveBeenCalledWith(
+      LogLevel.Warn,
+      message,
+      {},
+      timestamp,
+    )
+
+    const e = new Error(message)
+    logger.error(e)
+    expect(mockTransport).toHaveBeenCalledWith(LogLevel.Error, e, {}, timestamp)
+  })
+
+  test('info', () => {
+    const timestamp = Date.now()
+    const logger = new Logger({
+      enabled: true,
+      level: LogLevel.Info,
+    })
+    const message = nanoid()
+    const mockTransport = jest.fn()
+
+    logger.addTransport(mockTransport)
+
+    logger.debug(message)
+    expect(mockTransport).not.toHaveBeenCalled()
+
+    logger.info(message)
+    expect(mockTransport).toHaveBeenCalledWith(
+      LogLevel.Info,
+      message,
+      {},
+      timestamp,
+    )
+  })
+
+  test('warn', () => {
+    const timestamp = Date.now()
+    const logger = new Logger({
+      enabled: true,
+      level: LogLevel.Warn,
+    })
+    const message = nanoid()
+    const mockTransport = jest.fn()
+
+    logger.addTransport(mockTransport)
+
+    logger.debug(message)
+    expect(mockTransport).not.toHaveBeenCalled()
+
+    logger.info(message)
+    expect(mockTransport).not.toHaveBeenCalled()
+
+    logger.warn(message)
+    expect(mockTransport).toHaveBeenCalledWith(
+      LogLevel.Warn,
+      message,
+      {},
+      timestamp,
+    )
+  })
+
+  test('error', () => {
+    const timestamp = Date.now()
+    const logger = new Logger({
+      enabled: true,
+      level: LogLevel.Error,
+    })
+    const message = nanoid()
+    const mockTransport = jest.fn()
+
+    logger.addTransport(mockTransport)
+
+    logger.debug(message)
+    expect(mockTransport).not.toHaveBeenCalled()
+
+    logger.info(message)
+    expect(mockTransport).not.toHaveBeenCalled()
+
+    logger.warn(message)
+    expect(mockTransport).not.toHaveBeenCalled()
+
+    const e = new Error('original message')
+    logger.error(e)
+    expect(mockTransport).toHaveBeenCalledWith(LogLevel.Error, e, {}, timestamp)
+  })
+})
diff --git a/src/logger/debugContext.ts b/src/logger/debugContext.ts
new file mode 100644
index 000000000..658f4b18b
--- /dev/null
+++ b/src/logger/debugContext.ts
@@ -0,0 +1,10 @@
+/**
+ * *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'
+} as const
diff --git a/src/logger/index.ts b/src/logger/index.ts
new file mode 100644
index 000000000..3de2b9046
--- /dev/null
+++ b/src/logger/index.ts
@@ -0,0 +1,290 @@
+import format from 'date-fns/format'
+import {nanoid} from 'nanoid/non-secure'
+
+import {Sentry} from '#/logger/sentry'
+import * as env from '#/env'
+import {DebugContext} from '#/logger/debugContext'
+import {add} from '#/logger/logDump'
+
+export enum LogLevel {
+  Debug = 'debug',
+  Info = 'info',
+  Log = 'log',
+  Warn = 'warn',
+  Error = 'error',
+}
+
+type Transport = (
+  level: LogLevel,
+  message: string | Error,
+  metadata: Metadata,
+  timestamp: number,
+) => void
+
+/**
+ * A union of some of Sentry's breadcrumb properties as well as Sentry's
+ * `captureException` parameter, `CaptureContext`.
+ */
+type Metadata = {
+  /**
+   * Applied as Sentry breadcrumb types. Defaults to `default`.
+   *
+   * @see https://develop.sentry.dev/sdk/event-payloads/breadcrumbs/#breadcrumb-types
+   */
+  type?:
+    | 'default'
+    | 'debug'
+    | 'error'
+    | 'navigation'
+    | 'http'
+    | 'info'
+    | 'query'
+    | 'transaction'
+    | 'ui'
+    | 'user'
+
+  /**
+   * Passed through to `Sentry.captureException`
+   *
+   * @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
+  }
+
+  /**
+   * 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
+}
+
+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],
+}
+
+/**
+ * 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(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]
+
+  log(`${format(timestamp, 'HH:mm:ss')} ${message.toString()}${extra}`)
+}
+
+export const sentryTransport: Transport = (
+  level,
+  message,
+  {type, tags, ...metadata},
+  timestamp,
+) => {
+  /**
+   * 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: metadata,
+      type: type || 'default',
+      level: severity,
+      timestamp: timestamp / 1000, // Sentry expects seconds
+    })
+
+    /**
+     * 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']
+
+      Sentry.captureMessage(message, {
+        level: messageLevel,
+        tags,
+        extra: metadata,
+      })
+    }
+  } else {
+    /**
+     * It's otherwise an Error and should be reported with captureException
+     */
+    Sentry.captureException(message, {
+      tags,
+      extra: metadata,
+    })
+  }
+}
+
+/**
+ * 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
+
+  enabled: boolean
+  level: LogLevel
+  transports: Transport[] = []
+
+  protected debugContextRegexes: RegExp[] = []
+
+  constructor({
+    enabled = !env.IS_TEST,
+    level = env.LOG_LEVEL as LogLevel,
+    debug = env.LOG_DEBUG || '',
+  }: {
+    enabled?: boolean
+    level?: LogLevel
+    debug?: 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, '.*'))
+    })
+  }
+
+  debug(message: string, metadata: Metadata = {}, context?: string) {
+    if (context && !this.debugContextRegexes.find(reg => reg.test(context)))
+      return
+    this.transport(LogLevel.Debug, message, metadata)
+  }
+
+  info(message: string, metadata: Metadata = {}) {
+    this.transport(LogLevel.Info, message, metadata)
+  }
+
+  log(message: string, metadata: Metadata = {}) {
+    this.transport(LogLevel.Log, message, metadata)
+  }
+
+  warn(message: string, metadata: Metadata = {}) {
+    this.transport(LogLevel.Warn, message, metadata)
+  }
+
+  error(error: Error | string, metadata: Metadata = {}) {
+    this.transport(LogLevel.Error, error, metadata)
+  }
+
+  addTransport(transport: Transport) {
+    this.transports.push(transport)
+    return () => {
+      this.transports.splice(this.transports.indexOf(transport), 1)
+    }
+  }
+
+  disable() {
+    this.enabled = false
+  }
+
+  enable() {
+    this.enabled = true
+  }
+
+  protected transport(
+    level: LogLevel,
+    message: string | Error,
+    metadata: Metadata = {},
+  ) {
+    if (!this.enabled) return
+    if (!enabledLogLevels[this.level].includes(level)) return
+
+    const timestamp = Date.now()
+    const meta = metadata || {}
+
+    for (const transport of this.transports) {
+      transport(level, message, meta, timestamp)
+    }
+
+    add({
+      id: nanoid(),
+      timestamp,
+      level,
+      message,
+      metadata: meta,
+    })
+  }
+}
+
+/**
+ * Logger instance. See `@/logger/README` for docs.
+ *
+ * Basic usage:
+ *
+ *   `logger.debug(message[, metadata, debugContext])`
+ *   `logger.info(message[, metadata])`
+ *   `logger.warn(message[, metadata])`
+ *   `logger.error(error[, metadata])`
+ *   `logger.disable()`
+ *   `logger.enable()`
+ */
+export const logger = new Logger()
+
+/**
+ * Report to console in dev, Sentry in prod, nothing in test.
+ */
+if (env.IS_DEV && !env.IS_TEST) {
+  logger.addTransport(consoleTransport)
+
+  /**
+   * Uncomment this to test Sentry in dev
+   */
+  // logger.addTransport(sentryTransport);
+} else if (env.IS_PROD) {
+  // logger.addTransport(sentryTransport)
+}
diff --git a/src/logger/logDump.ts b/src/logger/logDump.ts
new file mode 100644
index 000000000..ec64bf4bd
--- /dev/null
+++ b/src/logger/logDump.ts
@@ -0,0 +1,12 @@
+import type {ConsoleTransportEntry} from '#/logger'
+
+let entries: ConsoleTransportEntry[] = []
+
+export function add(entry: ConsoleTransportEntry) {
+  entries.unshift(entry)
+  entries = entries.slice(0, 50)
+}
+
+export function getEntries() {
+  return entries
+}
diff --git a/src/logger/sentry/index.ts b/src/logger/sentry/index.ts
new file mode 100644
index 000000000..a2ed8452d
--- /dev/null
+++ b/src/logger/sentry/index.ts
@@ -0,0 +1 @@
+export {Native as Sentry} from 'sentry-expo'
diff --git a/src/logger/sentry/index.web.ts b/src/logger/sentry/index.web.ts
new file mode 100644
index 000000000..072b997f4
--- /dev/null
+++ b/src/logger/sentry/index.web.ts
@@ -0,0 +1 @@
+export {Browser as Sentry} from 'sentry-expo'
diff --git a/src/state/index.ts b/src/state/index.ts
index 42687a229..2c81c0ddf 100644
--- a/src/state/index.ts
+++ b/src/state/index.ts
@@ -25,7 +25,7 @@ export async function setupState(serviceUri = DEFAULT_SERVICE) {
     rootStore.log.debug('Initial hydrate', {hasSession: !!data.session})
     rootStore.hydrate(data)
   } catch (e: any) {
-    rootStore.log.error('Failed to load state from storage', e)
+    rootStore.log.error('Failed to load state from storage', {error: e})
   }
   rootStore.attemptSessionResumption()
 
diff --git a/src/state/models/content/feed-source.ts b/src/state/models/content/feed-source.ts
index 8dac9b56f..d1b8fc9dc 100644
--- a/src/state/models/content/feed-source.ts
+++ b/src/state/models/content/feed-source.ts
@@ -134,7 +134,7 @@ export class FeedSourceModel {
     try {
       await this.rootStore.preferences.addSavedFeed(this.uri)
     } catch (error) {
-      this.rootStore.log.error('Failed to save feed', error)
+      this.rootStore.log.error('Failed to save feed', {error})
     } finally {
       track('CustomFeed:Save')
     }
@@ -147,7 +147,7 @@ export class FeedSourceModel {
     try {
       await this.rootStore.preferences.removeSavedFeed(this.uri)
     } catch (error) {
-      this.rootStore.log.error('Failed to unsave feed', error)
+      this.rootStore.log.error('Failed to unsave feed', {error})
     } finally {
       track('CustomFeed:Unsave')
     }
@@ -157,7 +157,7 @@ export class FeedSourceModel {
     try {
       await this.rootStore.preferences.addPinnedFeed(this.uri)
     } catch (error) {
-      this.rootStore.log.error('Failed to pin feed', error)
+      this.rootStore.log.error('Failed to pin feed', {error})
     } finally {
       track('CustomFeed:Pin', {
         name: this.displayName,
@@ -194,7 +194,7 @@ export class FeedSourceModel {
     } catch (e: any) {
       this.likeUri = undefined
       this.likeCount = (this.likeCount || 1) - 1
-      this.rootStore.log.error('Failed to like feed', e)
+      this.rootStore.log.error('Failed to like feed', {error: e})
     } finally {
       track('CustomFeed:Like')
     }
@@ -215,7 +215,7 @@ export class FeedSourceModel {
     } catch (e: any) {
       this.likeUri = uri
       this.likeCount = (this.likeCount || 0) + 1
-      this.rootStore.log.error('Failed to unlike feed', e)
+      this.rootStore.log.error('Failed to unlike feed', {error: e})
     } finally {
       track('CustomFeed:Unlike')
     }
diff --git a/src/state/models/content/list.ts b/src/state/models/content/list.ts
index 8fb9f4b5e..985d8d82d 100644
--- a/src/state/models/content/list.ts
+++ b/src/state/models/content/list.ts
@@ -339,7 +339,7 @@ export class ListModel {
     try {
       await this.rootStore.preferences.addPinnedFeed(this.uri)
     } catch (error) {
-      this.rootStore.log.error('Failed to pin feed', error)
+      this.rootStore.log.error('Failed to pin feed', {error})
     } finally {
       track('CustomFeed:Pin', {
         name: this.data?.name || '',
@@ -455,10 +455,12 @@ export class ListModel {
     this.error = cleanError(err)
     this.loadMoreError = cleanError(loadMoreErr)
     if (err) {
-      this.rootStore.log.error('Failed to fetch user items', err)
+      this.rootStore.log.error('Failed to fetch user items', {error: err})
     }
     if (loadMoreErr) {
-      this.rootStore.log.error('Failed to fetch user items', loadMoreErr)
+      this.rootStore.log.error('Failed to fetch user items', {
+        error: loadMoreErr,
+      })
     }
   }
 
diff --git a/src/state/models/content/post-thread.ts b/src/state/models/content/post-thread.ts
index a862c27d3..cf6377da7 100644
--- a/src/state/models/content/post-thread.ts
+++ b/src/state/models/content/post-thread.ts
@@ -163,7 +163,7 @@ export class PostThreadModel {
     this.hasLoaded = true
     this.error = cleanError(err)
     if (err) {
-      this.rootStore.log.error('Failed to fetch post thread', err)
+      this.rootStore.log.error('Failed to fetch post thread', {error: err})
     }
     this.notFound = err instanceof GetPostThread.NotFoundError
   }
diff --git a/src/state/models/content/profile.ts b/src/state/models/content/profile.ts
index 5333e7116..0050970e6 100644
--- a/src/state/models/content/profile.ts
+++ b/src/state/models/content/profile.ts
@@ -235,7 +235,7 @@ export class ProfileModel {
     this.hasLoaded = true
     this.error = cleanError(err)
     if (err) {
-      this.rootStore.log.error('Failed to fetch profile', err)
+      this.rootStore.log.error('Failed to fetch profile', {error: err})
     }
   }
 
diff --git a/src/state/models/discovery/feeds.ts b/src/state/models/discovery/feeds.ts
index 1a00f802c..3902f3ac1 100644
--- a/src/state/models/discovery/feeds.ts
+++ b/src/state/models/discovery/feeds.ts
@@ -120,7 +120,7 @@ export class FeedsDiscoveryModel {
     this.hasLoaded = true
     this.error = cleanError(err)
     if (err) {
-      this.rootStore.log.error('Failed to fetch popular feeds', err)
+      this.rootStore.log.error('Failed to fetch popular feeds', {error: err})
     }
   }
 
diff --git a/src/state/models/discovery/suggested-actors.ts b/src/state/models/discovery/suggested-actors.ts
index d270267ee..8776fcd85 100644
--- a/src/state/models/discovery/suggested-actors.ts
+++ b/src/state/models/discovery/suggested-actors.ts
@@ -144,7 +144,7 @@ export class SuggestedActorsModel {
     this.hasLoaded = true
     this.error = cleanError(err)
     if (err) {
-      this.rootStore.log.error('Failed to fetch suggested actors', err)
+      this.rootStore.log.error('Failed to fetch suggested actors', {error: err})
     }
   }
 }
diff --git a/src/state/models/feeds/notifications.ts b/src/state/models/feeds/notifications.ts
index 34f5d4add..a834b543a 100644
--- a/src/state/models/feeds/notifications.ts
+++ b/src/state/models/feeds/notifications.ts
@@ -220,7 +220,7 @@ export class NotificationsFeedItemModel {
     }
     this.rootStore.log.warn(
       'app.bsky.notifications.list served an unsupported record type',
-      v,
+      {record: v},
     )
   }
 
@@ -401,7 +401,9 @@ export class NotificationsFeedModel {
       this._setQueued(this._filterNotifications(queueModels))
       this._countUnread()
     } catch (e) {
-      this.rootStore.log.error('NotificationsModel:syncQueue failed', {e})
+      this.rootStore.log.error('NotificationsModel:syncQueue failed', {
+        error: e,
+      })
     } finally {
       this.lock.release()
     }
@@ -481,7 +483,9 @@ export class NotificationsFeedModel {
         this.lastSync ? this.lastSync.toISOString() : undefined,
       )
     } catch (e: any) {
-      this.rootStore.log.warn('Failed to update notifications read state', e)
+      this.rootStore.log.warn('Failed to update notifications read state', {
+        error: e,
+      })
     }
   }
 
@@ -501,13 +505,12 @@ export class NotificationsFeedModel {
     this.error = cleanError(error)
     this.loadMoreError = cleanError(loadMoreError)
     if (error) {
-      this.rootStore.log.error('Failed to fetch notifications', error)
+      this.rootStore.log.error('Failed to fetch notifications', {error})
     }
     if (loadMoreError) {
-      this.rootStore.log.error(
-        'Failed to load more notifications',
-        loadMoreError,
-      )
+      this.rootStore.log.error('Failed to load more notifications', {
+        error: loadMoreError,
+      })
     }
   }
 
diff --git a/src/state/models/feeds/post.ts b/src/state/models/feeds/post.ts
index d46cced75..be3417104 100644
--- a/src/state/models/feeds/post.ts
+++ b/src/state/models/feeds/post.ts
@@ -42,17 +42,16 @@ export class PostsFeedItemModel {
       } else {
         this.postRecord = undefined
         this.richText = undefined
-        rootStore.log.warn(
-          'Received an invalid app.bsky.feed.post record',
-          valid.error,
-        )
+        rootStore.log.warn('Received an invalid app.bsky.feed.post record', {
+          error: valid.error,
+        })
       }
     } else {
       this.postRecord = undefined
       this.richText = undefined
       rootStore.log.warn(
         'app.bsky.feed.getTimeline or app.bsky.feed.getAuthorFeed served an unexpected record type',
-        this.post.record,
+        {record: this.post.record},
       )
     }
     this.reply = v.reply
@@ -133,7 +132,7 @@ export class PostsFeedItemModel {
         track('Post:Like')
       }
     } catch (error) {
-      this.rootStore.log.error('Failed to toggle like', error)
+      this.rootStore.log.error('Failed to toggle like', {error})
     }
   }
 
@@ -168,7 +167,7 @@ export class PostsFeedItemModel {
         track('Post:Repost')
       }
     } catch (error) {
-      this.rootStore.log.error('Failed to toggle repost', error)
+      this.rootStore.log.error('Failed to toggle repost', {error})
     }
   }
 
@@ -182,7 +181,7 @@ export class PostsFeedItemModel {
         track('Post:ThreadMute')
       }
     } catch (error) {
-      this.rootStore.log.error('Failed to toggle thread mute', error)
+      this.rootStore.log.error('Failed to toggle thread mute', {error})
     }
   }
 
@@ -191,7 +190,7 @@ export class PostsFeedItemModel {
       await this.rootStore.agent.deletePost(this.post.uri)
       this.rootStore.emitPostDeleted(this.post.uri)
     } catch (error) {
-      this.rootStore.log.error('Failed to delete post', error)
+      this.rootStore.log.error('Failed to delete post', {error})
     } finally {
       track('Post:Delete')
     }
diff --git a/src/state/models/feeds/posts.ts b/src/state/models/feeds/posts.ts
index 3c580aca9..5c10ae4c7 100644
--- a/src/state/models/feeds/posts.ts
+++ b/src/state/models/feeds/posts.ts
@@ -324,13 +324,12 @@ export class PostsFeedModel {
     this.knownError = detectKnownError(this.feedType, error)
     this.loadMoreError = cleanError(loadMoreError)
     if (error) {
-      this.rootStore.log.error('Posts feed request failed', error)
+      this.rootStore.log.error('Posts feed request failed', {error})
     }
     if (loadMoreError) {
-      this.rootStore.log.error(
-        'Posts feed load-more request failed',
-        loadMoreError,
-      )
+      this.rootStore.log.error('Posts feed load-more request failed', {
+        error: loadMoreError,
+      })
     }
   }
 
diff --git a/src/state/models/invited-users.ts b/src/state/models/invited-users.ts
index cd3667062..995c4bfb5 100644
--- a/src/state/models/invited-users.ts
+++ b/src/state/models/invited-users.ts
@@ -63,10 +63,9 @@ export class InvitedUsers {
         })
         this.rootStore.me.follows.hydrateMany(this.profiles)
       } catch (e) {
-        this.rootStore.log.error(
-          'Failed to fetch profiles for invited users',
-          e,
-        )
+        this.rootStore.log.error('Failed to fetch profiles for invited users', {
+          error: e,
+        })
       }
     }
   }
diff --git a/src/state/models/lists/actor-feeds.ts b/src/state/models/lists/actor-feeds.ts
index d2bd7680b..65da765f1 100644
--- a/src/state/models/lists/actor-feeds.ts
+++ b/src/state/models/lists/actor-feeds.ts
@@ -98,7 +98,7 @@ export class ActorFeedsModel {
     this.hasLoaded = true
     this.error = cleanError(err)
     if (err) {
-      this.rootStore.log.error('Failed to fetch user followers', err)
+      this.rootStore.log.error('Failed to fetch user followers', {error: err})
     }
   }
 
diff --git a/src/state/models/lists/blocked-accounts.ts b/src/state/models/lists/blocked-accounts.ts
index 20eef8aff..b4495b543 100644
--- a/src/state/models/lists/blocked-accounts.ts
+++ b/src/state/models/lists/blocked-accounts.ts
@@ -86,7 +86,7 @@ export class BlockedAccountsModel {
     this.hasLoaded = true
     this.error = cleanError(err)
     if (err) {
-      this.rootStore.log.error('Failed to fetch user followers', err)
+      this.rootStore.log.error('Failed to fetch user followers', {error: err})
     }
   }
 
diff --git a/src/state/models/lists/likes.ts b/src/state/models/lists/likes.ts
index dd3cf18a3..61e480e19 100644
--- a/src/state/models/lists/likes.ts
+++ b/src/state/models/lists/likes.ts
@@ -97,7 +97,7 @@ export class LikesModel {
     this.hasLoaded = true
     this.error = cleanError(err)
     if (err) {
-      this.rootStore.log.error('Failed to fetch likes', err)
+      this.rootStore.log.error('Failed to fetch likes', {error: err})
     }
   }
 
diff --git a/src/state/models/lists/lists-list.ts b/src/state/models/lists/lists-list.ts
index 42638757a..7415d06d7 100644
--- a/src/state/models/lists/lists-list.ts
+++ b/src/state/models/lists/lists-list.ts
@@ -204,10 +204,12 @@ export class ListsListModel {
     this.error = cleanError(err)
     this.loadMoreError = cleanError(loadMoreErr)
     if (err) {
-      this.rootStore.log.error('Failed to fetch user lists', err)
+      this.rootStore.log.error('Failed to fetch user lists', {error: err})
     }
     if (loadMoreErr) {
-      this.rootStore.log.error('Failed to fetch user lists', loadMoreErr)
+      this.rootStore.log.error('Failed to fetch user lists', {
+        error: loadMoreErr,
+      })
     }
   }
 
diff --git a/src/state/models/lists/muted-accounts.ts b/src/state/models/lists/muted-accounts.ts
index 9c3e1157b..bc9e53e5c 100644
--- a/src/state/models/lists/muted-accounts.ts
+++ b/src/state/models/lists/muted-accounts.ts
@@ -86,7 +86,7 @@ export class MutedAccountsModel {
     this.hasLoaded = true
     this.error = cleanError(err)
     if (err) {
-      this.rootStore.log.error('Failed to fetch user followers', err)
+      this.rootStore.log.error('Failed to fetch user followers', {error: err})
     }
   }
 
diff --git a/src/state/models/lists/reposted-by.ts b/src/state/models/lists/reposted-by.ts
index 5d4fc107d..fe639fd0e 100644
--- a/src/state/models/lists/reposted-by.ts
+++ b/src/state/models/lists/reposted-by.ts
@@ -100,7 +100,7 @@ export class RepostedByModel {
     this.hasLoaded = true
     this.error = cleanError(err)
     if (err) {
-      this.rootStore.log.error('Failed to fetch reposted by view', err)
+      this.rootStore.log.error('Failed to fetch reposted by view', {error: err})
     }
   }
 
diff --git a/src/state/models/lists/user-followers.ts b/src/state/models/lists/user-followers.ts
index 1f817c33c..d76ecce1a 100644
--- a/src/state/models/lists/user-followers.ts
+++ b/src/state/models/lists/user-followers.ts
@@ -99,7 +99,7 @@ export class UserFollowersModel {
     this.hasLoaded = true
     this.error = cleanError(err)
     if (err) {
-      this.rootStore.log.error('Failed to fetch user followers', err)
+      this.rootStore.log.error('Failed to fetch user followers', {error: err})
     }
   }
 
diff --git a/src/state/models/me.ts b/src/state/models/me.ts
index e7baf5bf2..14b2ef843 100644
--- a/src/state/models/me.ts
+++ b/src/state/models/me.ts
@@ -110,13 +110,17 @@ export class MeModel {
       await this.fetchProfile()
       this.mainFeed.clear()
       /* dont await */ this.mainFeed.setup().catch(e => {
-        this.rootStore.log.error('Failed to setup main feed model', e)
+        this.rootStore.log.error('Failed to setup main feed model', {error: e})
       })
       /* dont await */ this.notifications.setup().catch(e => {
-        this.rootStore.log.error('Failed to setup notifications model', e)
+        this.rootStore.log.error('Failed to setup notifications model', {
+          error: e,
+        })
       })
       /* dont await */ this.notifications.setup().catch(e => {
-        this.rootStore.log.error('Failed to setup notifications model', e)
+        this.rootStore.log.error('Failed to setup notifications model', {
+          error: e,
+        })
       })
       this.myFeeds.clear()
       /* dont await */ this.myFeeds.saved.refresh()
@@ -184,7 +188,9 @@ export class MeModel {
           })
         })
       } catch (e) {
-        this.rootStore.log.error('Failed to fetch user invite codes', e)
+        this.rootStore.log.error('Failed to fetch user invite codes', {
+          error: e,
+        })
       }
       await this.rootStore.invitedUsers.fetch(this.invites)
     }
@@ -199,7 +205,9 @@ export class MeModel {
           this.appPasswords = res.data.passwords
         })
       } catch (e) {
-        this.rootStore.log.error('Failed to fetch user app passwords', e)
+        this.rootStore.log.error('Failed to fetch user app passwords', {
+          error: e,
+        })
       }
     }
   }
@@ -220,7 +228,7 @@ export class MeModel {
         })
         return res.data
       } catch (e) {
-        this.rootStore.log.error('Failed to create app password', e)
+        this.rootStore.log.error('Failed to create app password', {error: e})
       }
     }
   }
@@ -235,7 +243,7 @@ export class MeModel {
           this.appPasswords = this.appPasswords.filter(p => p.name !== name)
         })
       } catch (e) {
-        this.rootStore.log.error('Failed to delete app password', e)
+        this.rootStore.log.error('Failed to delete app password', {error: e})
       }
     }
   }
diff --git a/src/state/models/media/image.ts b/src/state/models/media/image.ts
index c26f9b87c..4ca0b47c6 100644
--- a/src/state/models/media/image.ts
+++ b/src/state/models/media/image.ts
@@ -188,7 +188,7 @@ export class ImageModel implements Omit<RNImage, 'size'> {
         this.cropped = cropped
       })
     } catch (err) {
-      this.rootStore.log.error('Failed to crop photo', err)
+      this.rootStore.log.error('Failed to crop photo', {error: err})
     }
   }
 
diff --git a/src/state/models/root-store.ts b/src/state/models/root-store.ts
index 363a81c0f..621c87c11 100644
--- a/src/state/models/root-store.ts
+++ b/src/state/models/root-store.ts
@@ -8,7 +8,6 @@ import {createContext, useContext} from 'react'
 import {DeviceEventEmitter, EmitterSubscription} from 'react-native'
 import {z} from 'zod'
 import {isObj, hasProp} from 'lib/type-guards'
-import {LogModel} from './log'
 import {SessionModel} from './session'
 import {ShellUiModel} from './ui/shell'
 import {HandleResolutionsCache} from './cache/handle-resolutions'
@@ -23,6 +22,7 @@ import {ImageSizesCache} from './cache/image-sizes'
 import {MutedThreads} from './muted-threads'
 import {Reminders} from './ui/reminders'
 import {reset as resetNavigation} from '../../Navigation'
+import {logger} from '#/logger'
 
 // TEMPORARY (APP-700)
 // remove after backend testing finishes
@@ -41,7 +41,7 @@ export type AppInfo = z.infer<typeof appInfo>
 export class RootStoreModel {
   agent: BskyAgent
   appInfo?: AppInfo
-  log = new LogModel()
+  log = logger
   session = new SessionModel(this)
   shell = new ShellUiModel(this)
   preferences = new PreferencesModel(this)
@@ -130,7 +130,7 @@ export class RootStoreModel {
       })
       this.updateSessionState()
     } catch (e: any) {
-      this.log.warn('Failed to initialize session', e)
+      this.log.warn('Failed to initialize session', {error: e})
     }
   }
 
@@ -184,7 +184,7 @@ export class RootStoreModel {
       await this.me.updateIfNeeded()
       await this.preferences.sync()
     } catch (e: any) {
-      this.log.error('Failed to fetch latest state', e)
+      this.log.error('Failed to fetch latest state', {error: e})
     }
   }
 
diff --git a/src/state/models/ui/create-account.ts b/src/state/models/ui/create-account.ts
index 9f11a9b31..3bd39ba76 100644
--- a/src/state/models/ui/create-account.ts
+++ b/src/state/models/ui/create-account.ts
@@ -78,7 +78,7 @@ export class CreateAccountModel {
     } catch (err: any) {
       this.rootStore.log.warn(
         `Failed to fetch service description for ${this.serviceUrl}`,
-        err,
+        {error: err},
       )
       this.setError(
         'Unable to contact your service. Please check your Internet connection.',
@@ -127,7 +127,7 @@ export class CreateAccountModel {
         errMsg =
           'Invite code not accepted. Check that you input it correctly and try again.'
       }
-      this.rootStore.log.error('Failed to create account', e)
+      this.rootStore.log.error('Failed to create account', {error: e})
       this.setIsProcessing(false)
       this.setError(cleanError(errMsg))
       throw e
diff --git a/src/state/models/ui/profile.ts b/src/state/models/ui/profile.ts
index 8525426bf..47a99a8fc 100644
--- a/src/state/models/ui/profile.ts
+++ b/src/state/models/ui/profile.ts
@@ -223,10 +223,14 @@ export class ProfileUiModel {
     await Promise.all([
       this.profile
         .setup()
-        .catch(err => this.rootStore.log.error('Failed to fetch profile', err)),
+        .catch(err =>
+          this.rootStore.log.error('Failed to fetch profile', {error: err}),
+        ),
       this.feed
         .setup()
-        .catch(err => this.rootStore.log.error('Failed to fetch feed', err)),
+        .catch(err =>
+          this.rootStore.log.error('Failed to fetch feed', {error: err}),
+        ),
     ])
     runInAction(() => {
       this.isAuthenticatedUser =
@@ -237,7 +241,9 @@ export class ProfileUiModel {
     this.lists.source = this.profile.did
     this.lists
       .loadMore()
-      .catch(err => this.rootStore.log.error('Failed to fetch lists', err))
+      .catch(err =>
+        this.rootStore.log.error('Failed to fetch lists', {error: err}),
+      )
   }
 
   async refresh() {
diff --git a/src/state/models/ui/saved-feeds.ts b/src/state/models/ui/saved-feeds.ts
index 667bc03a3..72055abeb 100644
--- a/src/state/models/ui/saved-feeds.ts
+++ b/src/state/models/ui/saved-feeds.ts
@@ -126,7 +126,7 @@ export class SavedFeedsModel {
     this.hasLoaded = true
     this.error = cleanError(err)
     if (err) {
-      this.rootStore.log.error('Failed to fetch user feeds', err)
+      this.rootStore.log.error('Failed to fetch user feeds', {err})
     }
   }
 
diff --git a/src/view/com/auth/login/Login.tsx b/src/view/com/auth/login/Login.tsx
index 8ec4cc812..348580fae 100644
--- a/src/view/com/auth/login/Login.tsx
+++ b/src/view/com/auth/login/Login.tsx
@@ -83,7 +83,7 @@ export const Login = ({onPressBack}: {onPressBack: () => void}) => {
         }
         store.log.warn(
           `Failed to fetch service description for ${serviceUrl}`,
-          err,
+          {error: err},
         )
         setError(
           'Unable to contact your service. Please check your Internet connection.',
@@ -349,7 +349,7 @@ const LoginForm = ({
       })
     } catch (e: any) {
       const errMsg = e.toString()
-      store.log.warn('Failed to login', e)
+      store.log.warn('Failed to login', {error: e})
       setIsProcessing(false)
       if (errMsg.includes('Authentication Required')) {
         setError('Invalid username or password')
@@ -578,7 +578,7 @@ const ForgotPasswordForm = ({
       onEmailSent()
     } catch (e: any) {
       const errMsg = e.toString()
-      store.log.warn('Failed to request password reset', e)
+      store.log.warn('Failed to request password reset', {error: e})
       setIsProcessing(false)
       if (isNetworkError(e)) {
         setError(
@@ -734,7 +734,7 @@ const SetNewPasswordForm = ({
       onPasswordSet()
     } catch (e: any) {
       const errMsg = e.toString()
-      store.log.warn('Failed to set new password', e)
+      store.log.warn('Failed to set new password', {error: e})
       setIsProcessing(false)
       if (isNetworkError(e)) {
         setError(
diff --git a/src/view/com/composer/photos/OpenCameraBtn.tsx b/src/view/com/composer/photos/OpenCameraBtn.tsx
index a6917b36d..bd4d4b65a 100644
--- a/src/view/com/composer/photos/OpenCameraBtn.tsx
+++ b/src/view/com/composer/photos/OpenCameraBtn.tsx
@@ -39,7 +39,7 @@ export function OpenCameraBtn({gallery}: Props) {
       gallery.add(img)
     } catch (err: any) {
       // ignore
-      store.log.warn('Error using camera', err)
+      store.log.warn('Error using camera', {error: err})
     }
   }, [gallery, track, store, requestCameraAccessIfNeeded])
 
diff --git a/src/view/com/composer/useExternalLinkFetch.ts b/src/view/com/composer/useExternalLinkFetch.ts
index 6592ed572..aa5b4b431 100644
--- a/src/view/com/composer/useExternalLinkFetch.ts
+++ b/src/view/com/composer/useExternalLinkFetch.ts
@@ -46,7 +46,9 @@ export function useExternalLinkFetch({
             setExtLink(undefined)
           },
           err => {
-            store.log.error('Failed to fetch post for quote embedding', {err})
+            store.log.error('Failed to fetch post for quote embedding', {
+              error: err,
+            })
             setExtLink(undefined)
           },
         )
@@ -64,7 +66,7 @@ export function useExternalLinkFetch({
             })
           },
           err => {
-            store.log.error('Failed to fetch feed for embedding', {err})
+            store.log.error('Failed to fetch feed for embedding', {error: err})
             setExtLink(undefined)
           },
         )
@@ -82,7 +84,7 @@ export function useExternalLinkFetch({
             })
           },
           err => {
-            store.log.error('Failed to fetch list for embedding', {err})
+            store.log.error('Failed to fetch list for embedding', {error: err})
             setExtLink(undefined)
           },
         )
diff --git a/src/view/com/feeds/FeedSourceCard.tsx b/src/view/com/feeds/FeedSourceCard.tsx
index 6b5a572b4..060a4b638 100644
--- a/src/view/com/feeds/FeedSourceCard.tsx
+++ b/src/view/com/feeds/FeedSourceCard.tsx
@@ -45,7 +45,7 @@ export const FeedSourceCard = observer(function FeedSourceCardImpl({
             Toast.show('Removed from my feeds')
           } catch (e) {
             Toast.show('There was an issue contacting your server')
-            store.log.error('Failed to unsave feed', {e})
+            store.log.error('Failed to unsave feed', {error: e})
           }
         },
       })
@@ -55,7 +55,7 @@ export const FeedSourceCard = observer(function FeedSourceCardImpl({
         Toast.show('Added to my feeds')
       } catch (e) {
         Toast.show('There was an issue contacting your server')
-        store.log.error('Failed to save feed', {e})
+        store.log.error('Failed to save feed', {error: e})
       }
     }
   }, [store, item])
diff --git a/src/view/com/lists/ListItems.tsx b/src/view/com/lists/ListItems.tsx
index 855c07d14..76cd5e7c3 100644
--- a/src/view/com/lists/ListItems.tsx
+++ b/src/view/com/lists/ListItems.tsx
@@ -94,7 +94,7 @@ export const ListItems = observer(function ListItemsImpl({
     try {
       await list.refresh()
     } catch (err) {
-      list.rootStore.log.error('Failed to refresh lists', err)
+      list.rootStore.log.error('Failed to refresh lists', {error: err})
     }
     setIsRefreshing(false)
   }, [list, track, setIsRefreshing])
@@ -104,7 +104,7 @@ export const ListItems = observer(function ListItemsImpl({
     try {
       await list.loadMore()
     } catch (err) {
-      list.rootStore.log.error('Failed to load more lists', err)
+      list.rootStore.log.error('Failed to load more lists', {error: err})
     }
   }, [list, track])
 
diff --git a/src/view/com/lists/ListsList.tsx b/src/view/com/lists/ListsList.tsx
index efc874ef3..c0acaa96f 100644
--- a/src/view/com/lists/ListsList.tsx
+++ b/src/view/com/lists/ListsList.tsx
@@ -78,7 +78,7 @@ export const ListsList = observer(function ListsListImpl({
     try {
       await listsList.refresh()
     } catch (err) {
-      listsList.rootStore.log.error('Failed to refresh lists', err)
+      listsList.rootStore.log.error('Failed to refresh lists', {error: err})
     }
     setIsRefreshing(false)
   }, [listsList, track, setIsRefreshing])
@@ -88,7 +88,7 @@ export const ListsList = observer(function ListsListImpl({
     try {
       await listsList.loadMore()
     } catch (err) {
-      listsList.rootStore.log.error('Failed to load more lists', err)
+      listsList.rootStore.log.error('Failed to load more lists', {error: err})
     }
   }, [listsList, track])
 
diff --git a/src/view/com/modals/AddAppPasswords.tsx b/src/view/com/modals/AddAppPasswords.tsx
index 2a8672131..71199e34b 100644
--- a/src/view/com/modals/AddAppPasswords.tsx
+++ b/src/view/com/modals/AddAppPasswords.tsx
@@ -95,7 +95,7 @@ export function Component({}: {}) {
       }
     } catch (e) {
       Toast.show('Failed to create app password.')
-      store.log.error('Failed to create app password', {e})
+      store.log.error('Failed to create app password', {error: e})
     }
   }
 
diff --git a/src/view/com/modals/ChangeHandle.tsx b/src/view/com/modals/ChangeHandle.tsx
index a1226680e..2fb1a503a 100644
--- a/src/view/com/modals/ChangeHandle.tsx
+++ b/src/view/com/modals/ChangeHandle.tsx
@@ -69,7 +69,7 @@ export function Component({onChanged}: {onChanged: () => void}) {
           `Failed to fetch service description for ${String(
             store.agent.service,
           )}`,
-          err,
+          {error: err},
         )
         setError(
           'Unable to contact your service. Please check your Internet connection.',
@@ -113,7 +113,7 @@ export function Component({onChanged}: {onChanged: () => void}) {
       onChanged()
     } catch (err: any) {
       setError(cleanError(err))
-      store.log.error('Failed to update handle', {handle, err})
+      store.log.error('Failed to update handle', {handle, error: err})
     } finally {
       setProcessing(false)
     }
@@ -343,7 +343,7 @@ function CustomHandleForm({
       }
     } catch (err: any) {
       setError(cleanError(err))
-      store.log.error('Failed to verify domain', {handle, err})
+      store.log.error('Failed to verify domain', {handle, error: err})
     } finally {
       setIsVerifying(false)
     }
diff --git a/src/view/com/modals/ContentFilteringSettings.tsx b/src/view/com/modals/ContentFilteringSettings.tsx
index aa0674d7a..b78846bdc 100644
--- a/src/view/com/modals/ContentFilteringSettings.tsx
+++ b/src/view/com/modals/ContentFilteringSettings.tsx
@@ -103,7 +103,7 @@ const AdultContentEnabledPref = observer(
         Toast.show(
           'There was an issue syncing your preferences with the server',
         )
-        store.log.error('Failed to update preferences with server', {e})
+        store.log.error('Failed to update preferences with server', {error: e})
       }
     }
 
@@ -168,7 +168,7 @@ const ContentLabelPref = observer(function ContentLabelPrefImpl({
         Toast.show(
           'There was an issue syncing your preferences with the server',
         )
-        store.log.error('Failed to update preferences with server', {e})
+        store.log.error('Failed to update preferences with server', {error: e})
       }
     },
     [store, group],
diff --git a/src/view/com/modals/UserAddRemoveLists.tsx b/src/view/com/modals/UserAddRemoveLists.tsx
index ff048ca29..32da6403f 100644
--- a/src/view/com/modals/UserAddRemoveLists.tsx
+++ b/src/view/com/modals/UserAddRemoveLists.tsx
@@ -62,7 +62,7 @@ export const Component = observer(function UserAddRemoveListsImpl({
         setMembershipsLoaded(true)
       },
       err => {
-        store.log.error('Failed to fetch memberships', {err})
+        store.log.error('Failed to fetch memberships', {error: err})
       },
     )
   }, [memberships, listsList, store, setSelected, setMembershipsLoaded])
@@ -76,7 +76,7 @@ export const Component = observer(function UserAddRemoveListsImpl({
     try {
       changes = await memberships.updateTo(selected)
     } catch (err) {
-      store.log.error('Failed to update memberships', {err})
+      store.log.error('Failed to update memberships', {error: err})
       return
     }
     Toast.show('Lists updated')
diff --git a/src/view/com/notifications/Feed.tsx b/src/view/com/notifications/Feed.tsx
index 4ca22282d..ef16f598c 100644
--- a/src/view/com/notifications/Feed.tsx
+++ b/src/view/com/notifications/Feed.tsx
@@ -61,7 +61,9 @@ export const Feed = observer(function Feed({
       setIsPTRing(true)
       await view.refresh()
     } catch (err) {
-      view.rootStore.log.error('Failed to refresh notifications feed', err)
+      view.rootStore.log.error('Failed to refresh notifications feed', {
+        error: err,
+      })
     } finally {
       setIsPTRing(false)
     }
@@ -71,7 +73,9 @@ export const Feed = observer(function Feed({
     try {
       await view.loadMore()
     } catch (err) {
-      view.rootStore.log.error('Failed to load more notifications', err)
+      view.rootStore.log.error('Failed to load more notifications', {
+        error: err,
+      })
     }
   }, [view])
 
diff --git a/src/view/com/post-thread/PostLikedBy.tsx b/src/view/com/post-thread/PostLikedBy.tsx
index 574fe1e8e..d16e8d306 100644
--- a/src/view/com/post-thread/PostLikedBy.tsx
+++ b/src/view/com/post-thread/PostLikedBy.tsx
@@ -18,7 +18,9 @@ export const PostLikedBy = observer(function PostLikedByImpl({
   const view = React.useMemo(() => new LikesModel(store, {uri}), [store, uri])
 
   useEffect(() => {
-    view.loadMore().catch(err => store.log.error('Failed to fetch likes', err))
+    view
+      .loadMore()
+      .catch(err => store.log.error('Failed to fetch likes', {error: err}))
   }, [view, store.log])
 
   const onRefresh = () => {
@@ -27,7 +29,9 @@ export const PostLikedBy = observer(function PostLikedByImpl({
   const onEndReached = () => {
     view
       .loadMore()
-      .catch(err => view?.rootStore.log.error('Failed to load more likes', err))
+      .catch(err =>
+        view?.rootStore.log.error('Failed to load more likes', {error: err}),
+      )
   }
 
   if (!view.hasLoaded) {
diff --git a/src/view/com/post-thread/PostRepostedBy.tsx b/src/view/com/post-thread/PostRepostedBy.tsx
index e4b592779..0e681777e 100644
--- a/src/view/com/post-thread/PostRepostedBy.tsx
+++ b/src/view/com/post-thread/PostRepostedBy.tsx
@@ -23,7 +23,7 @@ export const PostRepostedBy = observer(function PostRepostedByImpl({
   useEffect(() => {
     view
       .loadMore()
-      .catch(err => store.log.error('Failed to fetch reposts', err))
+      .catch(err => store.log.error('Failed to fetch reposts', {error: err}))
   }, [view, store.log])
 
   const onRefresh = () => {
@@ -33,7 +33,7 @@ export const PostRepostedBy = observer(function PostRepostedByImpl({
     view
       .loadMore()
       .catch(err =>
-        view?.rootStore.log.error('Failed to load more reposts', err),
+        view?.rootStore.log.error('Failed to load more reposts', {error: err}),
       )
   }
 
diff --git a/src/view/com/post-thread/PostThread.tsx b/src/view/com/post-thread/PostThread.tsx
index 378ef5028..b0728a8a6 100644
--- a/src/view/com/post-thread/PostThread.tsx
+++ b/src/view/com/post-thread/PostThread.tsx
@@ -119,7 +119,7 @@ export const PostThread = observer(function PostThread({
     try {
       view?.refresh()
     } catch (err) {
-      view.rootStore.log.error('Failed to refresh posts thread', err)
+      view.rootStore.log.error('Failed to refresh posts thread', {error: err})
     }
     setIsRefreshing(false)
   }, [view, setIsRefreshing])
diff --git a/src/view/com/post-thread/PostThreadItem.tsx b/src/view/com/post-thread/PostThreadItem.tsx
index 8976a7e2c..430269165 100644
--- a/src/view/com/post-thread/PostThreadItem.tsx
+++ b/src/view/com/post-thread/PostThreadItem.tsx
@@ -111,13 +111,13 @@ export const PostThreadItem = observer(function PostThreadItem({
   const onPressToggleRepost = React.useCallback(() => {
     return item
       .toggleRepost()
-      .catch(e => store.log.error('Failed to toggle repost', e))
+      .catch(e => store.log.error('Failed to toggle repost', {error: e}))
   }, [item, store])
 
   const onPressToggleLike = React.useCallback(() => {
     return item
       .toggleLike()
-      .catch(e => store.log.error('Failed to toggle like', e))
+      .catch(e => store.log.error('Failed to toggle like', {error: e}))
   }, [item, store])
 
   const onCopyPostText = React.useCallback(() => {
@@ -138,7 +138,7 @@ export const PostThreadItem = observer(function PostThreadItem({
         Toast.show('You will now receive notifications for this thread')
       }
     } catch (e) {
-      store.log.error('Failed to toggle thread mute', e)
+      store.log.error('Failed to toggle thread mute', {error: e})
     }
   }, [item, store])
 
@@ -149,7 +149,7 @@ export const PostThreadItem = observer(function PostThreadItem({
         Toast.show('Post deleted')
       },
       e => {
-        store.log.error('Failed to delete post', e)
+        store.log.error('Failed to delete post', {error: e})
         Toast.show('Failed to delete post, please try again')
       },
     )
diff --git a/src/view/com/post/Post.tsx b/src/view/com/post/Post.tsx
index e3c948e5d..8f862f321 100644
--- a/src/view/com/post/Post.tsx
+++ b/src/view/com/post/Post.tsx
@@ -142,13 +142,13 @@ const PostLoaded = observer(function PostLoadedImpl({
   const onPressToggleRepost = React.useCallback(() => {
     return item
       .toggleRepost()
-      .catch(e => store.log.error('Failed to toggle repost', e))
+      .catch(e => store.log.error('Failed to toggle repost', {error: e}))
   }, [item, store])
 
   const onPressToggleLike = React.useCallback(() => {
     return item
       .toggleLike()
-      .catch(e => store.log.error('Failed to toggle like', e))
+      .catch(e => store.log.error('Failed to toggle like', {error: e}))
   }, [item, store])
 
   const onCopyPostText = React.useCallback(() => {
@@ -169,7 +169,7 @@ const PostLoaded = observer(function PostLoadedImpl({
         Toast.show('You will now receive notifications for this thread')
       }
     } catch (e) {
-      store.log.error('Failed to toggle thread mute', e)
+      store.log.error('Failed to toggle thread mute', {error: e})
     }
   }, [item, store])
 
@@ -180,7 +180,7 @@ const PostLoaded = observer(function PostLoadedImpl({
         Toast.show('Post deleted')
       },
       e => {
-        store.log.error('Failed to delete post', e)
+        store.log.error('Failed to delete post', {error: e})
         Toast.show('Failed to delete post, please try again')
       },
     )
diff --git a/src/view/com/posts/Feed.tsx b/src/view/com/posts/Feed.tsx
index 0578036d9..7d54fd842 100644
--- a/src/view/com/posts/Feed.tsx
+++ b/src/view/com/posts/Feed.tsx
@@ -92,7 +92,7 @@ export const Feed = observer(function Feed({
     try {
       await feed.refresh()
     } catch (err) {
-      feed.rootStore.log.error('Failed to refresh posts feed', err)
+      feed.rootStore.log.error('Failed to refresh posts feed', {error: err})
     }
     setIsRefreshing(false)
   }, [feed, track, setIsRefreshing])
@@ -104,7 +104,7 @@ export const Feed = observer(function Feed({
     try {
       await feed.loadMore()
     } catch (err) {
-      feed.rootStore.log.error('Failed to load more posts', err)
+      feed.rootStore.log.error('Failed to load more posts', {error: err})
     }
   }, [feed, track])
 
diff --git a/src/view/com/posts/FeedErrorMessage.tsx b/src/view/com/posts/FeedErrorMessage.tsx
index 51c735e31..52fa9246d 100644
--- a/src/view/com/posts/FeedErrorMessage.tsx
+++ b/src/view/com/posts/FeedErrorMessage.tsx
@@ -73,7 +73,7 @@ function FeedgenErrorMessage({
           Toast.show(
             'There was an an issue removing this feed. Please check your internet connection and try again.',
           )
-          store.log.error('Failed to remove feed', {err})
+          store.log.error('Failed to remove feed', {error: err})
         }
       },
       onPressCancel() {
diff --git a/src/view/com/posts/FeedItem.tsx b/src/view/com/posts/FeedItem.tsx
index 441621638..4d49eba6c 100644
--- a/src/view/com/posts/FeedItem.tsx
+++ b/src/view/com/posts/FeedItem.tsx
@@ -94,14 +94,14 @@ export const FeedItem = observer(function FeedItemImpl({
     track('FeedItem:PostRepost')
     return item
       .toggleRepost()
-      .catch(e => store.log.error('Failed to toggle repost', e))
+      .catch(e => store.log.error('Failed to toggle repost', {error: e}))
   }, [track, item, store])
 
   const onPressToggleLike = React.useCallback(() => {
     track('FeedItem:PostLike')
     return item
       .toggleLike()
-      .catch(e => store.log.error('Failed to toggle like', e))
+      .catch(e => store.log.error('Failed to toggle like', {error: e}))
   }, [track, item, store])
 
   const onCopyPostText = React.useCallback(() => {
@@ -123,7 +123,7 @@ export const FeedItem = observer(function FeedItemImpl({
         Toast.show('You will now receive notifications for this thread')
       }
     } catch (e) {
-      store.log.error('Failed to toggle thread mute', e)
+      store.log.error('Failed to toggle thread mute', {error: e})
     }
   }, [track, item, store])
 
@@ -135,7 +135,7 @@ export const FeedItem = observer(function FeedItemImpl({
         Toast.show('Post deleted')
       },
       e => {
-        store.log.error('Failed to delete post', e)
+        store.log.error('Failed to delete post', {error: e})
         Toast.show('Failed to delete post, please try again')
       },
     )
diff --git a/src/view/com/profile/ProfileFollowers.tsx b/src/view/com/profile/ProfileFollowers.tsx
index beb9609b6..7e41b1314 100644
--- a/src/view/com/profile/ProfileFollowers.tsx
+++ b/src/view/com/profile/ProfileFollowers.tsx
@@ -26,18 +26,20 @@ export const ProfileFollowers = observer(function ProfileFollowers({
   useEffect(() => {
     view
       .loadMore()
-      .catch(err => store.log.error('Failed to fetch user followers', err))
+      .catch(err =>
+        store.log.error('Failed to fetch user followers', {error: err}),
+      )
   }, [view, store.log])
 
   const onRefresh = () => {
     view.refresh()
   }
   const onEndReached = () => {
-    view
-      .loadMore()
-      .catch(err =>
-        view?.rootStore.log.error('Failed to load more followers', err),
-      )
+    view.loadMore().catch(err =>
+      view?.rootStore.log.error('Failed to load more followers', {
+        error: err,
+      }),
+    )
   }
 
   if (!view.hasLoaded) {
diff --git a/src/view/com/profile/ProfileHeader.tsx b/src/view/com/profile/ProfileHeader.tsx
index 082fbc0bc..d4fd88ca8 100644
--- a/src/view/com/profile/ProfileHeader.tsx
+++ b/src/view/com/profile/ProfileHeader.tsx
@@ -150,7 +150,7 @@ const ProfileHeaderLoaded = observer(function ProfileHeaderLoadedImpl({
             : 'ProfileHeader:UnfollowButtonClicked',
         )
       },
-      err => store.log.error('Failed to toggle follow', err),
+      err => store.log.error('Failed to toggle follow', {error: err}),
     )
   }, [track, view, store.log, setShowSuggestedFollows])
 
@@ -193,7 +193,7 @@ const ProfileHeaderLoaded = observer(function ProfileHeaderLoadedImpl({
       await view.muteAccount()
       Toast.show('Account muted')
     } catch (e: any) {
-      store.log.error('Failed to mute account', e)
+      store.log.error('Failed to mute account', {error: e})
       Toast.show(`There was an issue! ${e.toString()}`)
     }
   }, [track, view, store])
@@ -204,7 +204,7 @@ const ProfileHeaderLoaded = observer(function ProfileHeaderLoadedImpl({
       await view.unmuteAccount()
       Toast.show('Account unmuted')
     } catch (e: any) {
-      store.log.error('Failed to unmute account', e)
+      store.log.error('Failed to unmute account', {error: e})
       Toast.show(`There was an issue! ${e.toString()}`)
     }
   }, [track, view, store])
@@ -222,7 +222,7 @@ const ProfileHeaderLoaded = observer(function ProfileHeaderLoadedImpl({
           onRefreshAll()
           Toast.show('Account blocked')
         } catch (e: any) {
-          store.log.error('Failed to block account', e)
+          store.log.error('Failed to block account', {error: e})
           Toast.show(`There was an issue! ${e.toString()}`)
         }
       },
@@ -242,7 +242,7 @@ const ProfileHeaderLoaded = observer(function ProfileHeaderLoadedImpl({
           onRefreshAll()
           Toast.show('Account unblocked')
         } catch (e: any) {
-          store.log.error('Failed to unblock account', e)
+          store.log.error('Failed to unblock account', {error: e})
           Toast.show(`There was an issue! ${e.toString()}`)
         }
       },
diff --git a/src/view/screens/Log.tsx b/src/view/screens/Log.tsx
index 4a747e5bf..6ae61888d 100644
--- a/src/view/screens/Log.tsx
+++ b/src/view/screens/Log.tsx
@@ -10,6 +10,7 @@ import {s} from 'lib/styles'
 import {ViewHeader} from '../com/util/ViewHeader'
 import {Text} from '../com/util/text/Text'
 import {usePalette} from 'lib/hooks/usePalette'
+import {getEntries} from '#/logger/logDump'
 import {ago} from 'lib/strings/time'
 
 export const LogScreen = observer(function Log({}: NativeStackScreenProps<
@@ -38,9 +39,8 @@ export const LogScreen = observer(function Log({}: NativeStackScreenProps<
     <View style={[s.flex1]}>
       <ViewHeader title="Log" />
       <ScrollView style={s.flex1}>
-        {store.log.entries
+        {getEntries()
           .slice(0)
-          .reverse()
           .map(entry => {
             return (
               <View key={`entry-${entry.id}`}>
@@ -49,15 +49,15 @@ export const LogScreen = observer(function Log({}: NativeStackScreenProps<
                   onPress={toggler(entry.id)}
                   accessibilityLabel="View debug entry"
                   accessibilityHint="Opens additional details for a debug entry">
-                  {entry.type === 'debug' ? (
+                  {entry.level === 'debug' ? (
                     <FontAwesomeIcon icon="info" />
                   ) : (
                     <FontAwesomeIcon icon="exclamation" style={s.red3} />
                   )}
                   <Text type="sm" style={[styles.summary, pal.text]}>
-                    {entry.summary}
+                    {String(entry.message)}
                   </Text>
-                  {entry.details ? (
+                  {entry.metadata && Object.keys(entry.metadata).length ? (
                     <FontAwesomeIcon
                       icon={
                         expanded.includes(entry.id) ? 'angle-up' : 'angle-down'
@@ -66,14 +66,14 @@ export const LogScreen = observer(function Log({}: NativeStackScreenProps<
                     />
                   ) : undefined}
                   <Text type="sm" style={[styles.ts, pal.textLight]}>
-                    {entry.ts ? ago(entry.ts) : ''}
+                    {ago(entry.timestamp)}
                   </Text>
                 </TouchableOpacity>
                 {expanded.includes(entry.id) ? (
                   <View style={[pal.view, s.pl10, s.pr10, s.pb10]}>
                     <View style={[pal.btn, styles.details]}>
                       <Text type="mono" style={pal.text}>
-                        {entry.details}
+                        {JSON.stringify(entry.metadata, null, 2)}
                       </Text>
                     </View>
                   </View>
diff --git a/src/view/screens/ModerationBlockedAccounts.tsx b/src/view/screens/ModerationBlockedAccounts.tsx
index 7bbb6beee..a32c5e36e 100644
--- a/src/view/screens/ModerationBlockedAccounts.tsx
+++ b/src/view/screens/ModerationBlockedAccounts.tsx
@@ -52,7 +52,7 @@ export const ModerationBlockedAccounts = withAuthRequired(
       blockedAccounts
         .loadMore()
         .catch(err =>
-          store.log.error('Failed to load more blocked accounts', err),
+          store.log.error('Failed to load more blocked accounts', {error: err}),
         )
     }, [blockedAccounts, store])
 
diff --git a/src/view/screens/ModerationMutedAccounts.tsx b/src/view/screens/ModerationMutedAccounts.tsx
index 31c46e640..61911717a 100644
--- a/src/view/screens/ModerationMutedAccounts.tsx
+++ b/src/view/screens/ModerationMutedAccounts.tsx
@@ -49,7 +49,7 @@ export const ModerationMutedAccounts = withAuthRequired(
       mutedAccounts
         .loadMore()
         .catch(err =>
-          store.log.error('Failed to load more muted accounts', err),
+          store.log.error('Failed to load more muted accounts', {error: err}),
         )
     }, [mutedAccounts, store])
 
diff --git a/src/view/screens/PostThread.tsx b/src/view/screens/PostThread.tsx
index d4447f139..5f15adcc5 100644
--- a/src/view/screens/PostThread.tsx
+++ b/src/view/screens/PostThread.tsx
@@ -38,7 +38,7 @@ export const PostThreadScreen = withAuthRequired(
         InteractionManager.runAfterInteractions(() => {
           if (!view.hasLoaded && !view.isLoading) {
             view.setup().catch(err => {
-              store.log.error('Failed to fetch thread', err)
+              store.log.error('Failed to fetch thread', {error: err})
             })
           }
         })
diff --git a/src/view/screens/Profile.tsx b/src/view/screens/Profile.tsx
index 6c5a84e83..d353c411f 100644
--- a/src/view/screens/Profile.tsx
+++ b/src/view/screens/Profile.tsx
@@ -108,15 +108,15 @@ export const ProfileScreen = withAuthRequired(
       uiState
         .refresh()
         .catch((err: any) =>
-          store.log.error('Failed to refresh user profile', err),
+          store.log.error('Failed to refresh user profile', {error: err}),
         )
     }, [uiState, store])
     const onEndReached = React.useCallback(() => {
-      uiState
-        .loadMore()
-        .catch((err: any) =>
-          store.log.error('Failed to load more entries in user profile', err),
-        )
+      uiState.loadMore().catch((err: any) =>
+        store.log.error('Failed to load more entries in user profile', {
+          error: err,
+        }),
+      )
     }, [uiState, store])
     const onPressTryAgain = React.useCallback(() => {
       uiState.setup()
diff --git a/src/view/screens/ProfileFeed.tsx b/src/view/screens/ProfileFeed.tsx
index 3607ef82d..253031ff4 100644
--- a/src/view/screens/ProfileFeed.tsx
+++ b/src/view/screens/ProfileFeed.tsx
@@ -165,7 +165,7 @@ export const ProfileFeedScreenInner = observer(
         Toast.show(
           'There was an an issue updating your feeds, please check your internet connection and try again.',
         )
-        store.log.error('Failed up update feeds', {err})
+        store.log.error('Failed up update feeds', {error: err})
       }
     }, [store, feedInfo])
 
@@ -181,7 +181,7 @@ export const ProfileFeedScreenInner = observer(
         Toast.show(
           'There was an an issue contacting the server, please check your internet connection and try again.',
         )
-        store.log.error('Failed up toggle like', {err})
+        store.log.error('Failed up toggle like', {error: err})
       }
     }, [store, feedInfo])
 
@@ -190,7 +190,7 @@ export const ProfileFeedScreenInner = observer(
       if (feedInfo) {
         feedInfo.togglePin().catch(e => {
           Toast.show('There was an issue contacting the server')
-          store.log.error('Failed to toggle pinned feed', {e})
+          store.log.error('Failed to toggle pinned feed', {error: e})
         })
       }
     }, [store, feedInfo])
diff --git a/src/view/screens/ProfileList.tsx b/src/view/screens/ProfileList.tsx
index 28c69a90e..7580dcf55 100644
--- a/src/view/screens/ProfileList.tsx
+++ b/src/view/screens/ProfileList.tsx
@@ -272,7 +272,7 @@ const Header = observer(function HeaderImpl({
     Haptics.default()
     list.togglePin().catch(e => {
       Toast.show('There was an issue contacting the server')
-      store.log.error('Failed to toggle pinned list', {e})
+      store.log.error('Failed to toggle pinned list', {error: e})
     })
   }, [store, list])
 
diff --git a/src/view/screens/SavedFeeds.tsx b/src/view/screens/SavedFeeds.tsx
index 0f6278288..900bb06aa 100644
--- a/src/view/screens/SavedFeeds.tsx
+++ b/src/view/screens/SavedFeeds.tsx
@@ -166,14 +166,14 @@ const ListItem = observer(function ListItemImpl({
     Haptics.default()
     item.togglePin().catch(e => {
       Toast.show('There was an issue contacting the server')
-      store.log.error('Failed to toggle pinned feed', {e})
+      store.log.error('Failed to toggle pinned feed', {error: e})
     })
   }, [item, store])
   const onPressUp = useCallback(
     () =>
       savedFeeds.movePinnedFeed(item, 'up').catch(e => {
         Toast.show('There was an issue contacting the server')
-        store.log.error('Failed to set pinned feed order', {e})
+        store.log.error('Failed to set pinned feed order', {error: e})
       }),
     [store, savedFeeds, item],
   )
@@ -181,7 +181,7 @@ const ListItem = observer(function ListItemImpl({
     () =>
       savedFeeds.movePinnedFeed(item, 'down').catch(e => {
         Toast.show('There was an issue contacting the server')
-        store.log.error('Failed to set pinned feed order', {e})
+        store.log.error('Failed to set pinned feed order', {error: e})
       }),
     [store, savedFeeds, item],
   )
diff --git a/src/view/screens/Settings.tsx b/src/view/screens/Settings.tsx
index 2112ec7d1..c2c6d1efa 100644
--- a/src/view/screens/Settings.tsx
+++ b/src/view/screens/Settings.tsx
@@ -112,7 +112,7 @@ export const SettingsScreen = withAuthRequired(
             err => {
               store.log.error(
                 'Failed to reload from server after handle update',
-                {err},
+                {error: err},
               )
               setIsSwitching(false)
             },
diff --git a/yarn.lock b/yarn.lock
index 593c068b7..cd4f71a7a 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -1517,6 +1517,13 @@
   dependencies:
     regenerator-runtime "^0.14.0"
 
+"@babel/runtime@^7.21.0":
+  version "7.23.2"
+  resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.23.2.tgz#062b0ac103261d68a966c4c7baf2ae3e62ec3885"
+  integrity sha512-mM8eg4yl5D6i3lu2QKPuPH4FArvJ8KhTofbE7jwMUv9KX5mBvwPAqnV3MlyBNqdp9RyRKP6Yck8TrfYrPvX3bg==
+  dependencies:
+    regenerator-runtime "^0.14.0"
+
 "@babel/template@^7.0.0", "@babel/template@^7.22.5", "@babel/template@^7.3.3":
   version "7.22.5"
   resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.22.5.tgz#0c8c4d944509875849bd0344ff0050756eefc6ec"
@@ -8014,6 +8021,13 @@ data-urls@^3.0.2:
     whatwg-mimetype "^3.0.0"
     whatwg-url "^11.0.0"
 
+date-fns@^2.30.0:
+  version "2.30.0"
+  resolved "https://registry.yarnpkg.com/date-fns/-/date-fns-2.30.0.tgz#f367e644839ff57894ec6ac480de40cae4b0f4d0"
+  integrity sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw==
+  dependencies:
+    "@babel/runtime" "^7.21.0"
+
 dayjs@^1.8.15:
   version "1.11.9"
   resolved "https://registry.yarnpkg.com/dayjs/-/dayjs-1.11.9.tgz#9ca491933fadd0a60a2c19f6c237c03517d71d1a"
@@ -13673,6 +13687,11 @@ nanoid@^3.1.23, nanoid@^3.3.1, nanoid@^3.3.6:
   resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.6.tgz#443380c856d6e9f9824267d960b4236ad583ea4c"
   integrity sha512-BGcqMMJuToF7i1rt+2PWSNVnWIkGCU78jBG3RxO/bZlnZPK2Cmi2QaffxGO/2RvWi9sL+FAiRiXMgsyxQ1DIDA==
 
+nanoid@^5.0.2:
+  version "5.0.2"
+  resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-5.0.2.tgz#97588ebc70166d0feaf73ccd2799bb4ceaebf692"
+  integrity sha512-2ustYUX1R2rL/Br5B/FMhi8d5/QzvkJ912rBYxskcpu0myTHzSZfTr1LAS2Sm7jxRUObRrSBFoyzwAhL49aVSg==
+
 napi-build-utils@^1.0.1:
   version "1.0.2"
   resolved "https://registry.yarnpkg.com/napi-build-utils/-/napi-build-utils-1.0.2.tgz#b1fddc0b2c46e380a0b7a76f984dd47c41a13806"