my fork of the bluesky client

Add new logger

+888 -18
+2
.env.example
··· 1 1 SENTRY_AUTH_TOKEN= 2 + EXPO_PUBLIC_LOG_LEVEL=debug 3 + EXPO_PUBLIC_LOG_DEBUG=
+12 -10
package.json
··· 13 13 "start": "expo start --dev-client", 14 14 "start:prod": "expo start --dev-client --no-dev --minify", 15 15 "clean-cache": "rm -rf node_modules/.cache/babel-loader/*", 16 - "test": "jest --forceExit --testTimeout=20000 --bail", 17 - "test-watch": "jest --watchAll", 18 - "test-ci": "jest --ci --forceExit --reporters=default --reporters=jest-junit", 19 - "test-coverage": "jest --coverage", 16 + "test": "NODE_ENV=test jest --forceExit --testTimeout=20000 --bail", 17 + "test-watch": "NODE_ENV=test jest --watchAll", 18 + "test-ci": "NODE_ENV=test jest --ci --forceExit --reporters=default --reporters=jest-junit", 19 + "test-coverage": "NODE_ENV=test jest --coverage", 20 20 "lint": "eslint ./src --ext .js,.jsx,.ts,.tsx", 21 21 "typecheck": "tsc --project ./tsconfig.check.json", 22 22 "e2e:mock-server": "./jest/dev-infra/with-test-redis-and-db.sh ts-node __e2e__/mock-server.ts", 23 23 "e2e:metro": "RN_SRC_EXT=e2e.ts,e2e.tsx expo run:ios", 24 24 "e2e:build": "detox build -c ios.sim.debug", 25 25 "e2e:run": "detox test --configuration ios.sim.debug --take-screenshots all", 26 - "perf:test": "maestro test", 27 - "perf:test:run": "maestro test __e2e__/maestro/scroll.yaml", 28 - "perf:test:measure": "flashlight test --bundleId xyz.blueskyweb.app --testCommand 'yarn perf:test' --duration 150000 --resultsFilePath .perf/results.json", 29 - "perf:test:results": "flashlight report .perf/results.json", 30 - "perf:measure": "flashlight measure", 26 + "perf:test": "NODE_ENV=test maestro test", 27 + "perf:test:run": "NODE_ENV=test maestro test __e2e__/maestro/scroll.yaml", 28 + "perf:test:measure": "NODE_ENV=test flashlight test --bundleId xyz.blueskyweb.app --testCommand 'yarn perf:test' --duration 150000 --resultsFilePath .perf/results.json", 29 + "perf:test:results": "NODE_ENV=test flashlight report .perf/results.json", 30 + "perf:measure": "NODE_ENV=test flashlight measure", 31 31 "build:apk": "eas build -p android --profile dev-android-apk" 32 32 }, 33 33 "dependencies": { ··· 80 80 "babel-plugin-transform-remove-console": "^6.9.4", 81 81 "base64-js": "^1.5.1", 82 82 "bcp-47-match": "^2.0.3", 83 + "date-fns": "^2.30.0", 83 84 "email-validator": "^2.0.4", 84 85 "emoji-mart": "^5.5.2", 85 86 "eventemitter3": "^5.0.1", ··· 118 119 "mobx": "^6.6.1", 119 120 "mobx-react-lite": "^3.4.0", 120 121 "mobx-utils": "^6.0.6", 122 + "nanoid": "^5.0.2", 121 123 "normalize-url": "^8.0.0", 122 124 "patch-package": "^6.5.1", 123 125 "postinstall-postinstall": "^2.1.0", ··· 240 242 "\\.[jt]sx?$": "babel-jest" 241 243 }, 242 244 "transformIgnorePatterns": [ 243 - "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)" 245 + "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)" 244 246 ], 245 247 "modulePathIgnorePatterns": [ 246 248 "__tests__/.*/__mocks__",
+9
src/env.ts
··· 1 + export const IS_TEST = process.env.NODE_ENV === 'test' 2 + export const IS_DEV = __DEV__ 3 + export const IS_PROD = !IS_DEV 4 + export const LOG_DEBUG = process.env.EXPO_PUBLIC_LOG_DEBUG || '' 5 + export const LOG_LEVEL = (process.env.EXPO_PUBLIC_LOG_LEVEL || 'info') as 6 + | 'debug' 7 + | 'info' 8 + | 'warn' 9 + | 'error'
+99
src/logger/README.md
··· 1 + # Logger 2 + 3 + Simple logger for Bluesky. Supports log levels, debug contexts, and separate 4 + transports for production, dev, and test mode. 5 + 6 + ## At a Glance 7 + 8 + ```typescript 9 + import { logger } from '#/logger' 10 + 11 + logger.debug(message[, metadata, debugContext]) 12 + logger.info(message[, metadata]) 13 + logger.log(message[, metadata]) 14 + logger.warn(message[, metadata]) 15 + logger.error(error[, metadata]) 16 + ``` 17 + 18 + #### Modes 19 + 20 + The "modes" referred to here are inferred from the values exported from `#/env`. 21 + Basically, the booleans `IS_DEV`, `IS_TEST`, and `IS_PROD`. 22 + 23 + #### Log Levels 24 + 25 + Log levels are used to filter which logs are either printed to the console 26 + and/or sent to Sentry and other reporting services. To configure, set the 27 + `EXPO_PUBLIC_LOG_LEVEL` environment variable in `.env` to one of `debug`, 28 + `info`, `log`, `warn`, or `error`. 29 + 30 + This variable should be `info` in production, and `debug` in dev. If it gets too 31 + noisy in dev, simply set it to a higher level, such as `warn`. 32 + 33 + ## Usage 34 + 35 + ```typescript 36 + import { logger } from '#/logger'; 37 + ``` 38 + 39 + ### `logger.error` 40 + 41 + The `error` level is for... well, errors. These are sent to Sentry in production mode. 42 + 43 + `error`, along with all log levels, supports an additional parameter, `metadata: Record<string, unknown>`. Use this to provide values to the [Sentry 44 + breadcrumb](https://docs.sentry.io/platforms/react-native/enriching-events/breadcrumbs/#manual-breadcrumbs). 45 + 46 + ```typescript 47 + try { 48 + // some async code 49 + } catch (e) { 50 + logger.error(e, { ...metadata }); 51 + } 52 + ``` 53 + 54 + ### `logger.warn` 55 + 56 + Warnings will be sent to Sentry as a separate Issue with level `warning`, as 57 + well as as breadcrumbs, with a severity level of `warning` 58 + 59 + ### `logger.log` 60 + 61 + Logs with level `log` will be sent to Sentry as a separate Issue with level `log`, as 62 + well as as breadcrumbs, with a severity level of `default`. 63 + 64 + ### `logger.info` 65 + 66 + The `info` level should be used for information that would be helpful in a 67 + tracing context, like Sentry. In production mode, `info` logs are sent 68 + to Sentry as breadcrumbs, which decorate log levels above `info` such as `log`, 69 + `warn`, and `error`. 70 + 71 + ### `logger.debug` 72 + 73 + Debug level is really only intended for local development. Use this instead of 74 + `console.log`. 75 + 76 + ```typescript 77 + logger.debug(message, { ...metadata }); 78 + ``` 79 + 80 + Inspired by [debug](https://www.npmjs.com/package/debug), when writing debug 81 + logs, you can optionally pass a _context_, which can be then filtered when in 82 + debug mode. 83 + 84 + This value should be related to the feature, component, or screen 85 + the code is running within, and **it should be defined in `#/logger/debugContext`**. 86 + This way we know if a relevant context already exists, and we can trace all 87 + active contexts in use in our app. This const enum is conveniently available on 88 + the `logger` at `logger.DebugContext`. 89 + 90 + For example, a debug log like this: 91 + 92 + ```typescript 93 + logger.debug(message, {}, logger.DebugContext.composer); 94 + ``` 95 + 96 + Would be logged to the console in dev mode if `EXPO_PUBLIC_LOG_LEVEL=debug`, _or_ if you 97 + pass a separate environment variable `LOG_DEBUG=composer`. This variable supports 98 + multiple contexts using commas like `LOG_DEBUG=composer,profile`, and _automatically 99 + sets the log level to `debug`, regardless of `EXPO_PUBLIC_LOG_LEVEL`._
+424
src/logger/__tests__/logger.test.ts
··· 1 + import {nanoid} from 'nanoid/non-secure' 2 + import {jest, describe, expect, test, beforeAll} from '@jest/globals' 3 + import {Native as Sentry} from 'sentry-expo' 4 + 5 + import {Logger, LogLevel, sentryTransport} from '#/logger' 6 + 7 + jest.mock('#/env', () => ({ 8 + IS_TEST: true, 9 + IS_DEV: false, 10 + IS_PROD: false, 11 + /* 12 + * Forces debug mode for tests using the default logger. Most tests create 13 + * their own logger instance. 14 + */ 15 + LOG_LEVEL: 'debug', 16 + LOG_DEBUG: '', 17 + })) 18 + 19 + jest.mock('sentry-expo', () => ({ 20 + Native: { 21 + addBreadcrumb: jest.fn(), 22 + captureException: jest.fn(), 23 + captureMessage: jest.fn(), 24 + }, 25 + })) 26 + 27 + beforeAll(() => { 28 + jest.useFakeTimers() 29 + }) 30 + 31 + describe('general functionality', () => { 32 + test('default params', () => { 33 + const logger = new Logger() 34 + expect(logger.enabled).toBeFalsy() 35 + expect(logger.level).toEqual(LogLevel.Debug) // mocked above 36 + }) 37 + 38 + test('can override default params', () => { 39 + const logger = new Logger({ 40 + enabled: true, 41 + level: LogLevel.Info, 42 + }) 43 + expect(logger.enabled).toBeTruthy() 44 + expect(logger.level).toEqual(LogLevel.Info) 45 + }) 46 + 47 + test('disabled logger does not report', () => { 48 + const logger = new Logger({ 49 + enabled: false, 50 + level: LogLevel.Debug, 51 + }) 52 + 53 + const mockTransport = jest.fn() 54 + 55 + logger.addTransport(mockTransport) 56 + logger.debug('message') 57 + 58 + expect(mockTransport).not.toHaveBeenCalled() 59 + }) 60 + 61 + test('disablement', () => { 62 + const logger = new Logger({ 63 + enabled: true, 64 + level: LogLevel.Debug, 65 + }) 66 + 67 + logger.disable() 68 + 69 + const mockTransport = jest.fn() 70 + 71 + logger.addTransport(mockTransport) 72 + logger.debug('message') 73 + 74 + expect(mockTransport).not.toHaveBeenCalled() 75 + }) 76 + 77 + test('passing debug contexts automatically enables debug mode', () => { 78 + const logger = new Logger({debug: 'specific'}) 79 + expect(logger.level).toEqual(LogLevel.Debug) 80 + }) 81 + 82 + test('supports extra metadata', () => { 83 + const timestamp = Date.now() 84 + const logger = new Logger({enabled: true}) 85 + 86 + const mockTransport = jest.fn() 87 + 88 + logger.addTransport(mockTransport) 89 + 90 + const extra = {foo: true} 91 + logger.warn('message', extra) 92 + 93 + expect(mockTransport).toHaveBeenCalledWith( 94 + LogLevel.Warn, 95 + 'message', 96 + extra, 97 + timestamp, 98 + ) 99 + }) 100 + 101 + test('supports nullish/falsy metadata', () => { 102 + const timestamp = Date.now() 103 + const logger = new Logger({enabled: true}) 104 + 105 + const mockTransport = jest.fn() 106 + 107 + const remove = logger.addTransport(mockTransport) 108 + 109 + // @ts-expect-error testing the JS case 110 + logger.warn('a', null) 111 + expect(mockTransport).toHaveBeenCalledWith( 112 + LogLevel.Warn, 113 + 'a', 114 + {}, 115 + timestamp, 116 + ) 117 + 118 + // @ts-expect-error testing the JS case 119 + logger.warn('b', false) 120 + expect(mockTransport).toHaveBeenCalledWith( 121 + LogLevel.Warn, 122 + 'b', 123 + {}, 124 + timestamp, 125 + ) 126 + 127 + // @ts-expect-error testing the JS case 128 + logger.warn('c', 0) 129 + expect(mockTransport).toHaveBeenCalledWith( 130 + LogLevel.Warn, 131 + 'c', 132 + {}, 133 + timestamp, 134 + ) 135 + 136 + remove() 137 + 138 + logger.addTransport((level, message, metadata) => { 139 + expect(typeof metadata).toEqual('object') 140 + }) 141 + 142 + // @ts-expect-error testing the JS case 143 + logger.warn('message', null) 144 + }) 145 + 146 + test('sentryTransport', () => { 147 + const message = 'message' 148 + const timestamp = Date.now() 149 + const sentryTimestamp = timestamp / 1000 150 + 151 + sentryTransport(LogLevel.Debug, message, {}, timestamp) 152 + expect(Sentry.addBreadcrumb).toHaveBeenCalledWith({ 153 + message, 154 + data: {}, 155 + type: 'default', 156 + level: LogLevel.Debug, 157 + timestamp: sentryTimestamp, 158 + }) 159 + 160 + sentryTransport( 161 + LogLevel.Info, 162 + message, 163 + {type: 'info', prop: true}, 164 + timestamp, 165 + ) 166 + expect(Sentry.addBreadcrumb).toHaveBeenCalledWith({ 167 + message, 168 + data: {prop: true}, 169 + type: 'info', 170 + level: LogLevel.Info, 171 + timestamp: sentryTimestamp, 172 + }) 173 + 174 + sentryTransport(LogLevel.Log, message, {}, timestamp) 175 + expect(Sentry.addBreadcrumb).toHaveBeenCalledWith({ 176 + message, 177 + data: {}, 178 + type: 'default', 179 + level: 'debug', // Sentry bug, log becomes debug 180 + timestamp: sentryTimestamp, 181 + }) 182 + expect(Sentry.captureMessage).toHaveBeenCalledWith(message, { 183 + level: 'log', 184 + tags: undefined, 185 + extra: {}, 186 + }) 187 + 188 + sentryTransport(LogLevel.Warn, message, {}, timestamp) 189 + expect(Sentry.addBreadcrumb).toHaveBeenCalledWith({ 190 + message, 191 + data: {}, 192 + type: 'default', 193 + level: 'warning', 194 + timestamp: sentryTimestamp, 195 + }) 196 + expect(Sentry.captureMessage).toHaveBeenCalledWith(message, { 197 + level: 'warning', 198 + tags: undefined, 199 + extra: {}, 200 + }) 201 + 202 + const e = new Error('error') 203 + const tags = { 204 + prop: 'prop', 205 + } 206 + 207 + sentryTransport( 208 + LogLevel.Error, 209 + e, 210 + { 211 + tags, 212 + prop: true, 213 + }, 214 + timestamp, 215 + ) 216 + 217 + expect(Sentry.captureException).toHaveBeenCalledWith(e, { 218 + tags, 219 + extra: { 220 + prop: true, 221 + }, 222 + }) 223 + }) 224 + 225 + test('add/remove transport', () => { 226 + const timestamp = Date.now() 227 + const logger = new Logger({enabled: true}) 228 + const mockTransport = jest.fn() 229 + 230 + const remove = logger.addTransport(mockTransport) 231 + 232 + logger.warn('warn') 233 + 234 + remove() 235 + 236 + logger.warn('warn') 237 + 238 + // only called once bc it was removed 239 + expect(mockTransport).toHaveBeenNthCalledWith( 240 + 1, 241 + LogLevel.Warn, 242 + 'warn', 243 + {}, 244 + timestamp, 245 + ) 246 + }) 247 + }) 248 + 249 + describe('debug contexts', () => { 250 + const mockTransport = jest.fn() 251 + 252 + test('specific', () => { 253 + const timestamp = Date.now() 254 + const message = nanoid() 255 + const logger = new Logger({ 256 + enabled: true, 257 + debug: 'specific', 258 + }) 259 + 260 + logger.addTransport(mockTransport) 261 + logger.debug(message, {}, 'specific') 262 + 263 + expect(mockTransport).toHaveBeenCalledWith( 264 + LogLevel.Debug, 265 + message, 266 + {}, 267 + timestamp, 268 + ) 269 + }) 270 + 271 + test('namespaced', () => { 272 + const timestamp = Date.now() 273 + const message = nanoid() 274 + const logger = new Logger({ 275 + enabled: true, 276 + debug: 'namespace*', 277 + }) 278 + 279 + logger.addTransport(mockTransport) 280 + logger.debug(message, {}, 'namespace') 281 + 282 + expect(mockTransport).toHaveBeenCalledWith( 283 + LogLevel.Debug, 284 + message, 285 + {}, 286 + timestamp, 287 + ) 288 + }) 289 + 290 + test('ignores inactive', () => { 291 + const timestamp = Date.now() 292 + const message = nanoid() 293 + const logger = new Logger({ 294 + enabled: true, 295 + debug: 'namespace:foo:*', 296 + }) 297 + 298 + logger.addTransport(mockTransport) 299 + logger.debug(message, {}, 'namespace:bar:baz') 300 + 301 + expect(mockTransport).not.toHaveBeenCalledWith( 302 + LogLevel.Debug, 303 + message, 304 + {}, 305 + timestamp, 306 + ) 307 + }) 308 + }) 309 + 310 + describe('supports levels', () => { 311 + test('debug', () => { 312 + const timestamp = Date.now() 313 + const logger = new Logger({ 314 + enabled: true, 315 + level: LogLevel.Debug, 316 + }) 317 + const message = nanoid() 318 + const mockTransport = jest.fn() 319 + 320 + logger.addTransport(mockTransport) 321 + 322 + logger.debug(message) 323 + expect(mockTransport).toHaveBeenCalledWith( 324 + LogLevel.Debug, 325 + message, 326 + {}, 327 + timestamp, 328 + ) 329 + 330 + logger.info(message) 331 + expect(mockTransport).toHaveBeenCalledWith( 332 + LogLevel.Info, 333 + message, 334 + {}, 335 + timestamp, 336 + ) 337 + 338 + logger.warn(message) 339 + expect(mockTransport).toHaveBeenCalledWith( 340 + LogLevel.Warn, 341 + message, 342 + {}, 343 + timestamp, 344 + ) 345 + 346 + const e = new Error(message) 347 + logger.error(e) 348 + expect(mockTransport).toHaveBeenCalledWith(LogLevel.Error, e, {}, timestamp) 349 + }) 350 + 351 + test('info', () => { 352 + const timestamp = Date.now() 353 + const logger = new Logger({ 354 + enabled: true, 355 + level: LogLevel.Info, 356 + }) 357 + const message = nanoid() 358 + const mockTransport = jest.fn() 359 + 360 + logger.addTransport(mockTransport) 361 + 362 + logger.debug(message) 363 + expect(mockTransport).not.toHaveBeenCalled() 364 + 365 + logger.info(message) 366 + expect(mockTransport).toHaveBeenCalledWith( 367 + LogLevel.Info, 368 + message, 369 + {}, 370 + timestamp, 371 + ) 372 + }) 373 + 374 + test('warn', () => { 375 + const timestamp = Date.now() 376 + const logger = new Logger({ 377 + enabled: true, 378 + level: LogLevel.Warn, 379 + }) 380 + const message = nanoid() 381 + const mockTransport = jest.fn() 382 + 383 + logger.addTransport(mockTransport) 384 + 385 + logger.debug(message) 386 + expect(mockTransport).not.toHaveBeenCalled() 387 + 388 + logger.info(message) 389 + expect(mockTransport).not.toHaveBeenCalled() 390 + 391 + logger.warn(message) 392 + expect(mockTransport).toHaveBeenCalledWith( 393 + LogLevel.Warn, 394 + message, 395 + {}, 396 + timestamp, 397 + ) 398 + }) 399 + 400 + test('error', () => { 401 + const timestamp = Date.now() 402 + const logger = new Logger({ 403 + enabled: true, 404 + level: LogLevel.Error, 405 + }) 406 + const message = nanoid() 407 + const mockTransport = jest.fn() 408 + 409 + logger.addTransport(mockTransport) 410 + 411 + logger.debug(message) 412 + expect(mockTransport).not.toHaveBeenCalled() 413 + 414 + logger.info(message) 415 + expect(mockTransport).not.toHaveBeenCalled() 416 + 417 + logger.warn(message) 418 + expect(mockTransport).not.toHaveBeenCalled() 419 + 420 + const e = new Error('original message') 421 + logger.error(e) 422 + expect(mockTransport).toHaveBeenCalledWith(LogLevel.Error, e, {}, timestamp) 423 + }) 424 + })
+10
src/logger/debugContext.ts
··· 1 + /** 2 + * *Do not import this directly.* Instead, use the shortcut reference `logger.DebugContext`. 3 + * 4 + * Add debug contexts here. Although convention typically calls for enums ito 5 + * be capitalized, for parity with the `LOG_DEBUG` env var, please use all 6 + * lowercase. 7 + */ 8 + export const DebugContext = { 9 + // e.g. composer: 'composer' 10 + } as const
+290
src/logger/index.ts
··· 1 + import format from 'date-fns/format' 2 + import {nanoid} from 'nanoid/non-secure' 3 + 4 + import {Sentry} from '#/logger/sentry' 5 + import * as env from '#/env' 6 + import {DebugContext} from '#/logger/debugContext' 7 + import {add} from '#/logger/logDump' 8 + 9 + export enum LogLevel { 10 + Debug = 'debug', 11 + Info = 'info', 12 + Log = 'log', 13 + Warn = 'warn', 14 + Error = 'error', 15 + } 16 + 17 + type Transport = ( 18 + level: LogLevel, 19 + message: string | Error, 20 + metadata: Metadata, 21 + timestamp: number, 22 + ) => void 23 + 24 + /** 25 + * A union of some of Sentry's breadcrumb properties as well as Sentry's 26 + * `captureException` parameter, `CaptureContext`. 27 + */ 28 + type Metadata = { 29 + /** 30 + * Applied as Sentry breadcrumb types. Defaults to `default`. 31 + * 32 + * @see https://develop.sentry.dev/sdk/event-payloads/breadcrumbs/#breadcrumb-types 33 + */ 34 + type?: 35 + | 'default' 36 + | 'debug' 37 + | 'error' 38 + | 'navigation' 39 + | 'http' 40 + | 'info' 41 + | 'query' 42 + | 'transaction' 43 + | 'ui' 44 + | 'user' 45 + 46 + /** 47 + * Passed through to `Sentry.captureException` 48 + * 49 + * @see https://github.com/getsentry/sentry-javascript/blob/903addf9a1a1534a6cb2ba3143654b918a86f6dd/packages/types/src/misc.ts#L65 50 + */ 51 + tags?: { 52 + [key: string]: 53 + | number 54 + | string 55 + | boolean 56 + | bigint 57 + | symbol 58 + | null 59 + | undefined 60 + } 61 + 62 + /** 63 + * Any additional data, passed through to Sentry as `extra` param on 64 + * exceptions, or the `data` param on breadcrumbs. 65 + */ 66 + [key: string]: unknown 67 + } & Parameters<typeof Sentry.captureException>[1] 68 + 69 + export type ConsoleTransportEntry = { 70 + id: string 71 + timestamp: number 72 + level: LogLevel 73 + message: string | Error 74 + metadata: Metadata 75 + } 76 + 77 + const enabledLogLevels: { 78 + [key in LogLevel]: LogLevel[] 79 + } = { 80 + [LogLevel.Debug]: [ 81 + LogLevel.Debug, 82 + LogLevel.Info, 83 + LogLevel.Log, 84 + LogLevel.Warn, 85 + LogLevel.Error, 86 + ], 87 + [LogLevel.Info]: [LogLevel.Info, LogLevel.Log, LogLevel.Warn, LogLevel.Error], 88 + [LogLevel.Log]: [LogLevel.Log, LogLevel.Warn, LogLevel.Error], 89 + [LogLevel.Warn]: [LogLevel.Warn, LogLevel.Error], 90 + [LogLevel.Error]: [LogLevel.Error], 91 + } 92 + 93 + /** 94 + * Used in dev mode to nicely log to the console 95 + */ 96 + export const consoleTransport: Transport = ( 97 + level, 98 + message, 99 + metadata, 100 + timestamp, 101 + ) => { 102 + const extra = Object.keys(metadata).length 103 + ? ' ' + JSON.stringify(metadata, null, ' ') 104 + : '' 105 + const log = { 106 + [LogLevel.Debug]: console.debug, 107 + [LogLevel.Info]: console.info, 108 + [LogLevel.Log]: console.log, 109 + [LogLevel.Warn]: console.warn, 110 + [LogLevel.Error]: console.error, 111 + }[level] 112 + 113 + log(`${format(timestamp, 'HH:mm:ss')} ${message.toString()}${extra}`) 114 + } 115 + 116 + export const sentryTransport: Transport = ( 117 + level, 118 + message, 119 + {type, tags, ...metadata}, 120 + timestamp, 121 + ) => { 122 + /** 123 + * If a string, report a breadcrumb 124 + */ 125 + if (typeof message === 'string') { 126 + const severity = ( 127 + { 128 + [LogLevel.Debug]: 'debug', 129 + [LogLevel.Info]: 'info', 130 + [LogLevel.Log]: 'log', // Sentry value here is undefined 131 + [LogLevel.Warn]: 'warning', 132 + [LogLevel.Error]: 'error', 133 + } as const 134 + )[level] 135 + 136 + Sentry.addBreadcrumb({ 137 + message, 138 + data: metadata, 139 + type: type || 'default', 140 + level: severity, 141 + timestamp: timestamp / 1000, // Sentry expects seconds 142 + }) 143 + 144 + /** 145 + * Send all higher levels with `captureMessage`, with appropriate severity 146 + * level 147 + */ 148 + if (level === 'error' || level === 'warn' || level === 'log') { 149 + const messageLevel = ({ 150 + [LogLevel.Log]: 'log', 151 + [LogLevel.Warn]: 'warning', 152 + [LogLevel.Error]: 'error', 153 + }[level] || 'log') as Sentry.Breadcrumb['level'] 154 + 155 + Sentry.captureMessage(message, { 156 + level: messageLevel, 157 + tags, 158 + extra: metadata, 159 + }) 160 + } 161 + } else { 162 + /** 163 + * It's otherwise an Error and should be reported with captureException 164 + */ 165 + Sentry.captureException(message, { 166 + tags, 167 + extra: metadata, 168 + }) 169 + } 170 + } 171 + 172 + /** 173 + * Main class. Defaults are provided in the constructor so that subclasses are 174 + * technically possible, if we need to go that route in the future. 175 + */ 176 + export class Logger { 177 + LogLevel = LogLevel 178 + DebugContext = DebugContext 179 + 180 + enabled: boolean 181 + level: LogLevel 182 + transports: Transport[] = [] 183 + 184 + protected debugContextRegexes: RegExp[] = [] 185 + 186 + constructor({ 187 + enabled = !env.IS_TEST, 188 + level = env.LOG_LEVEL as LogLevel, 189 + debug = env.LOG_DEBUG || '', 190 + }: { 191 + enabled?: boolean 192 + level?: LogLevel 193 + debug?: string 194 + } = {}) { 195 + this.enabled = enabled !== false 196 + this.level = debug ? LogLevel.Debug : level ?? LogLevel.Info // default to info 197 + this.debugContextRegexes = (debug || '').split(',').map(context => { 198 + return new RegExp(context.replace(/[^\w:*]/, '').replace(/\*/g, '.*')) 199 + }) 200 + } 201 + 202 + debug(message: string, metadata: Metadata = {}, context?: string) { 203 + if (context && !this.debugContextRegexes.find(reg => reg.test(context))) 204 + return 205 + this.transport(LogLevel.Debug, message, metadata) 206 + } 207 + 208 + info(message: string, metadata: Metadata = {}) { 209 + this.transport(LogLevel.Info, message, metadata) 210 + } 211 + 212 + log(message: string, metadata: Metadata = {}) { 213 + this.transport(LogLevel.Log, message, metadata) 214 + } 215 + 216 + warn(message: string, metadata: Metadata = {}) { 217 + this.transport(LogLevel.Warn, message, metadata) 218 + } 219 + 220 + error(error: Error | string, metadata: Metadata = {}) { 221 + this.transport(LogLevel.Error, error, metadata) 222 + } 223 + 224 + addTransport(transport: Transport) { 225 + this.transports.push(transport) 226 + return () => { 227 + this.transports.splice(this.transports.indexOf(transport), 1) 228 + } 229 + } 230 + 231 + disable() { 232 + this.enabled = false 233 + } 234 + 235 + enable() { 236 + this.enabled = true 237 + } 238 + 239 + protected transport( 240 + level: LogLevel, 241 + message: string | Error, 242 + metadata: Metadata = {}, 243 + ) { 244 + if (!this.enabled) return 245 + if (!enabledLogLevels[this.level].includes(level)) return 246 + 247 + const timestamp = Date.now() 248 + const meta = metadata || {} 249 + 250 + for (const transport of this.transports) { 251 + transport(level, message, meta, timestamp) 252 + } 253 + 254 + add({ 255 + id: nanoid(), 256 + timestamp, 257 + level, 258 + message, 259 + metadata: meta, 260 + }) 261 + } 262 + } 263 + 264 + /** 265 + * Logger instance. See `@/logger/README` for docs. 266 + * 267 + * Basic usage: 268 + * 269 + * `logger.debug(message[, metadata, debugContext])` 270 + * `logger.info(message[, metadata])` 271 + * `logger.warn(message[, metadata])` 272 + * `logger.error(error[, metadata])` 273 + * `logger.disable()` 274 + * `logger.enable()` 275 + */ 276 + export const logger = new Logger() 277 + 278 + /** 279 + * Report to console in dev, Sentry in prod, nothing in test. 280 + */ 281 + if (env.IS_DEV && !env.IS_TEST) { 282 + logger.addTransport(consoleTransport) 283 + 284 + /** 285 + * Uncomment this to test Sentry in dev 286 + */ 287 + // logger.addTransport(sentryTransport); 288 + } else if (env.IS_PROD) { 289 + logger.addTransport(sentryTransport) 290 + }
+12
src/logger/logDump.ts
··· 1 + import {ConsoleTransportEntry} from '#/logger' 2 + 3 + let entries: ConsoleTransportEntry[] = [] 4 + 5 + export function add(entry: ConsoleTransportEntry) { 6 + entries.unshift(entry) 7 + entries = entries.slice(0, 50) 8 + } 9 + 10 + export function getEntries() { 11 + return entries 12 + }
+1
src/logger/sentry/index.ts
··· 1 + export {Native as Sentry} from 'sentry-expo'
+1
src/logger/sentry/index.web.ts
··· 1 + export {Browser as Sentry} from 'sentry-expo'
+2 -2
src/state/models/root-store.ts
··· 8 8 import {DeviceEventEmitter, EmitterSubscription} from 'react-native' 9 9 import {z} from 'zod' 10 10 import {isObj, hasProp} from 'lib/type-guards' 11 - import {LogModel} from './log' 12 11 import {SessionModel} from './session' 13 12 import {ShellUiModel} from './ui/shell' 14 13 import {HandleResolutionsCache} from './cache/handle-resolutions' ··· 23 22 import {MutedThreads} from './muted-threads' 24 23 import {Reminders} from './ui/reminders' 25 24 import {reset as resetNavigation} from '../../Navigation' 25 + import {logger} from '#/logger' 26 26 27 27 // TEMPORARY (APP-700) 28 28 // remove after backend testing finishes ··· 41 41 export class RootStoreModel { 42 42 agent: BskyAgent 43 43 appInfo?: AppInfo 44 - log = new LogModel() 44 + log = logger 45 45 session = new SessionModel(this) 46 46 shell = new ShellUiModel(this) 47 47 preferences = new PreferencesModel(this)
+7 -6
src/view/screens/Log.tsx
··· 10 10 import {ViewHeader} from '../com/util/ViewHeader' 11 11 import {Text} from '../com/util/text/Text' 12 12 import {usePalette} from 'lib/hooks/usePalette' 13 + import {getEntries} from '#/logger/logDump' 13 14 import {ago} from 'lib/strings/time' 14 15 15 16 export const LogScreen = observer(function Log({}: NativeStackScreenProps< ··· 38 39 <View style={[s.flex1]}> 39 40 <ViewHeader title="Log" /> 40 41 <ScrollView style={s.flex1}> 41 - {store.log.entries 42 + {getEntries() 42 43 .slice(0) 43 44 .reverse() 44 45 .map(entry => { ··· 49 50 onPress={toggler(entry.id)} 50 51 accessibilityLabel="View debug entry" 51 52 accessibilityHint="Opens additional details for a debug entry"> 52 - {entry.type === 'debug' ? ( 53 + {entry.level === 'debug' ? ( 53 54 <FontAwesomeIcon icon="info" /> 54 55 ) : ( 55 56 <FontAwesomeIcon icon="exclamation" style={s.red3} /> 56 57 )} 57 58 <Text type="sm" style={[styles.summary, pal.text]}> 58 - {entry.summary} 59 + {String(entry.message)} 59 60 </Text> 60 - {entry.details ? ( 61 + {entry.metadata && Object.keys(entry.metadata).length ? ( 61 62 <FontAwesomeIcon 62 63 icon={ 63 64 expanded.includes(entry.id) ? 'angle-up' : 'angle-down' ··· 66 67 /> 67 68 ) : undefined} 68 69 <Text type="sm" style={[styles.ts, pal.textLight]}> 69 - {entry.ts ? ago(entry.ts) : ''} 70 + {ago(entry.timestamp)} 70 71 </Text> 71 72 </TouchableOpacity> 72 73 {expanded.includes(entry.id) ? ( 73 74 <View style={[pal.view, s.pl10, s.pr10, s.pb10]}> 74 75 <View style={[pal.btn, styles.details]}> 75 76 <Text type="mono" style={pal.text}> 76 - {entry.details} 77 + {JSON.stringify(entry.metadata, null, 2)} 77 78 </Text> 78 79 </View> 79 80 </View>
+19
yarn.lock
··· 1517 1517 dependencies: 1518 1518 regenerator-runtime "^0.14.0" 1519 1519 1520 + "@babel/runtime@^7.21.0": 1521 + version "7.23.2" 1522 + resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.23.2.tgz#062b0ac103261d68a966c4c7baf2ae3e62ec3885" 1523 + integrity sha512-mM8eg4yl5D6i3lu2QKPuPH4FArvJ8KhTofbE7jwMUv9KX5mBvwPAqnV3MlyBNqdp9RyRKP6Yck8TrfYrPvX3bg== 1524 + dependencies: 1525 + regenerator-runtime "^0.14.0" 1526 + 1520 1527 "@babel/template@^7.0.0", "@babel/template@^7.22.5", "@babel/template@^7.3.3": 1521 1528 version "7.22.5" 1522 1529 resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.22.5.tgz#0c8c4d944509875849bd0344ff0050756eefc6ec" ··· 8014 8021 whatwg-mimetype "^3.0.0" 8015 8022 whatwg-url "^11.0.0" 8016 8023 8024 + date-fns@^2.30.0: 8025 + version "2.30.0" 8026 + resolved "https://registry.yarnpkg.com/date-fns/-/date-fns-2.30.0.tgz#f367e644839ff57894ec6ac480de40cae4b0f4d0" 8027 + integrity sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw== 8028 + dependencies: 8029 + "@babel/runtime" "^7.21.0" 8030 + 8017 8031 dayjs@^1.8.15: 8018 8032 version "1.11.9" 8019 8033 resolved "https://registry.yarnpkg.com/dayjs/-/dayjs-1.11.9.tgz#9ca491933fadd0a60a2c19f6c237c03517d71d1a" ··· 13672 13686 version "3.3.6" 13673 13687 resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.6.tgz#443380c856d6e9f9824267d960b4236ad583ea4c" 13674 13688 integrity sha512-BGcqMMJuToF7i1rt+2PWSNVnWIkGCU78jBG3RxO/bZlnZPK2Cmi2QaffxGO/2RvWi9sL+FAiRiXMgsyxQ1DIDA== 13689 + 13690 + nanoid@^5.0.2: 13691 + version "5.0.2" 13692 + resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-5.0.2.tgz#97588ebc70166d0feaf73ccd2799bb4ceaebf692" 13693 + integrity sha512-2ustYUX1R2rL/Br5B/FMhi8d5/QzvkJ912rBYxskcpu0myTHzSZfTr1LAS2Sm7jxRUObRrSBFoyzwAhL49aVSg== 13675 13694 13676 13695 napi-build-utils@^1.0.1: 13677 13696 version "1.0.2"