diff --git a/.env.example b/.env.example index b4213aea..d4db46ab 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 3e280eb0..efb0942d 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 00000000..7b255e7e --- /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/logger/README.md b/src/logger/README.md new file mode 100644 index 00000000..1dfd5da2 --- /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`. 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__/logger.test.ts b/src/logger/__tests__/logger.test.ts new file mode 100644 index 00000000..46a5be61 --- /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 00000000..658f4b18 --- /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 00000000..28b72091 --- /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[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 00000000..777ff777 --- /dev/null +++ b/src/logger/logDump.ts @@ -0,0 +1,12 @@ +import {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 00000000..a2ed8452 --- /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 00000000..072b997f --- /dev/null +++ b/src/logger/sentry/index.web.ts @@ -0,0 +1 @@ +export {Browser as Sentry} from 'sentry-expo' diff --git a/src/state/models/root-store.ts b/src/state/models/root-store.ts index 363a81c0..c9b460ba 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 export class RootStoreModel { agent: BskyAgent appInfo?: AppInfo - log = new LogModel() + log = logger session = new SessionModel(this) shell = new ShellUiModel(this) preferences = new PreferencesModel(this) diff --git a/src/view/screens/Log.tsx b/src/view/screens/Log.tsx index 4a747e5b..9389bd5a 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,7 +39,7 @@ export const LogScreen = observer(function Log({}: NativeStackScreenProps< - {store.log.entries + {getEntries() .slice(0) .reverse() .map(entry => { @@ -49,15 +50,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' ? ( ) : ( )} - {entry.summary} + {String(entry.message)} - {entry.details ? ( + {entry.metadata && Object.keys(entry.metadata).length ? ( ) : undefined} - {entry.ts ? ago(entry.ts) : ''} + {ago(entry.timestamp)} {expanded.includes(entry.id) ? ( - {entry.details} + {JSON.stringify(entry.metadata, null, 2)} diff --git a/yarn.lock b/yarn.lock index 593c068b..cd4f71a7 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"