diff options
Diffstat (limited to 'shared/logger/src')
| -rw-r--r-- | shared/logger/src/base.ts | 67 | ||||
| -rw-r--r-- | shared/logger/src/composite.ts | 92 | ||||
| -rw-r--r-- | shared/logger/src/console.ts | 29 | ||||
| -rw-r--r-- | shared/logger/src/errorkit/errorkit-logger.ts | 93 | ||||
| -rw-r--r-- | shared/logger/src/errorkit/errorkit.ts | 108 | ||||
| -rw-r--r-- | shared/logger/src/index.ts | 31 | ||||
| -rw-r--r-- | shared/logger/src/local-storage-filter.ts | 122 |
7 files changed, 542 insertions, 0 deletions
diff --git a/shared/logger/src/base.ts b/shared/logger/src/base.ts new file mode 100644 index 0000000..1f6df0b --- /dev/null +++ b/shared/logger/src/base.ts @@ -0,0 +1,67 @@ +import type { Level, Logger } from './types'; + +export abstract class BaseLogger<Args extends unknown[] = unknown[]> + implements Logger<Args> +{ + constructor(protected readonly name: string) {} + + /** + * Log a debug level message. + * Appropriate for verbose logging that explains steps/details of the inner state of + * a code unit. + * + * Example uses include in a size-constrain datastructure, logging when the size + * exceeds the threshold and elements are removed, or in a virtual scrolling + * component logging when a scroll event causes a new page of elements to be loaded. + * + * @param args Arguments to log (same as console.debug) + * @return empty string (for use in brackets {} in svelte components) + */ + debug(...args: Args): string { + return this.log('debug', ...args); + } + + /** + * Log an info level message. + * Appropriate for informational messages that may be relevant to consumers of a code + * unit. + * + * Example uses include a router logging when transitions occur or a button logging + * clicks. + * + * @param args Arguments to log (same as console.info) + * @return empty string (for use in brackets {} in svelte components) + */ + info(...args: Args): string { + return this.log('info', ...args); + } + + /** + * Log a warn level message. + * Appropriate for situations where state has been (or likely will be) corrupted or + * invariants have been broken. + * + * Example uses include a data structure warning when it is used before being fully + * initialized. + * + * @param args Arguments to log (same as console.warn) + * @return empty string (for use in brackets {} in svelte components) + */ + warn(...args: Args): string { + return this.log('warn', ...args); + } + + /** + * Log an error message. + * Appropriate for thrown errors or situations where the apps breaks or has to + * engage in fallback behavior to avoid a more catastrophic failure. + * + * @param args Arguments to log (same as console.error) + * @return empty string (for use in brackets {} in svelte components) + */ + error(...args: Args): string { + return this.log('error', ...args); + } + + protected abstract log(method: Level, ...args: Args): string; +} diff --git a/shared/logger/src/composite.ts b/shared/logger/src/composite.ts new file mode 100644 index 0000000..a3c4d69 --- /dev/null +++ b/shared/logger/src/composite.ts @@ -0,0 +1,92 @@ +import type { LoggerFactory, Logger } from './types'; + +export class CompositeLoggerFactory implements LoggerFactory { + private readonly factories: LoggerFactory[]; + + constructor(factories: LoggerFactory[]) { + this.factories = factories; + } + + loggerFor(name: string): Logger { + return new CompositeLogger( + this.factories.map((factory) => factory.loggerFor(name)), + ); + } +} + +export class CompositeLogger implements Logger { + private readonly loggers: Logger[]; + + constructor(loggers: Logger[]) { + this.loggers = loggers; + } + + /** + * Log a debug level message. + * Appropriate for verbose logging that explains steps/details of the inner state of + * a code unit. + * + * Example uses include in a size-constrain datastructure, logging when the size + * exceeds the threshold and elements are removed, or in a virtual scrolling + * component logging when a scroll event causes a new page of elements to be loaded. + * + * @param args Arguments to log (same as console.debug) + * @return empty string (for use in brackets {} in svelte components) + */ + debug(...args: unknown[]): string { + return this.callAll('debug', args); + } + + /** + * Log an info level message. + * Appropriate for informational messages that may be relevant to consumers of a code + * unit. + * + * Example uses include a router logging when transitions occur or a button logging + * clicks. + * + * @param args Arguments to log (same as console.info) + * @return empty string (for use in brackets {} in svelte components) + */ + info(...args: unknown[]): string { + return this.callAll('info', args); + } + + /** + * Log a warn level message. + * Appropriate for situations where state has been (or likely will be) corrupted or + * invariants have been broken. + * + * Example uses include a data structure warning when it is used before being fully + * initialized. + * + * @param args Arguments to log (same as console.warn) + * @return empty string (for use in brackets {} in svelte components) + */ + warn(...args: unknown[]): string { + return this.callAll('warn', args); + } + + /** + * Log an error message. + * Appropriate for thrown errors or situations where the apps breaks or has to + * engage in fallback behavior to avoid a more catastrophic failure. + * + * @param args Arguments to log (same as console.error) + * @return empty string (for use in brackets {} in svelte components) + */ + error(...args: unknown[]): string { + return this.callAll('error', args); + } + + private callAll( + method: 'debug' | 'info' | 'warn' | 'error', + args: unknown[], + ): string { + for (const logger of this.loggers) { + logger[method](...args); + } + + return ''; + } +} diff --git a/shared/logger/src/console.ts b/shared/logger/src/console.ts new file mode 100644 index 0000000..408002b --- /dev/null +++ b/shared/logger/src/console.ts @@ -0,0 +1,29 @@ +import { BaseLogger } from './base'; +import type { Level, LoggerFactory, Logger } from './types'; +import { shouldLog } from './local-storage-filter'; + +export class ConsoleLoggerFactory implements LoggerFactory { + loggerFor(name: string): Logger { + return new ConsoleLogger(name); + } +} + +export class ConsoleLogger extends BaseLogger { + protected log(method: Level, ...args: unknown[]): string { + if (!shouldLog(this.name, method)) { + return ''; + } + + const log = console[method]; + const prefix = `[${this.name}]`; + const [firstArg, ...rest] = args; + + if (typeof firstArg === 'string') { + log(`${prefix} ${firstArg}`, ...rest); + } else { + log(prefix, ...args); + } + + return ''; + } +} diff --git a/shared/logger/src/errorkit/errorkit-logger.ts b/shared/logger/src/errorkit/errorkit-logger.ts new file mode 100644 index 0000000..1290c41 --- /dev/null +++ b/shared/logger/src/errorkit/errorkit-logger.ts @@ -0,0 +1,93 @@ +import type { ErrorHub, ValueOf } from './types'; +import type { LoggerFactory, Logger } from '../types'; + +/** + * Determines the level of logs to send to sentry. + * + */ +export const ERROR_REPORT_LEVEL = { + error: 'error', + error_warn: 'error_warn', +} as const; + +type ReportLevel = ValueOf<typeof ERROR_REPORT_LEVEL>; + +export class ErrorKitLoggerFactory implements LoggerFactory { + private readonly errorKit: ErrorHub; + private readonly reportLevel: ReportLevel; + constructor(errorKit: ErrorHub, reportLevel?: ReportLevel) { + this.errorKit = errorKit; + this.reportLevel = reportLevel ?? ERROR_REPORT_LEVEL.error; + } + loggerFor(name: string): Logger { + return new ErrorKitLogger(name, this.errorKit, this.reportLevel); + } +} + +interface HasToString { + toString(): string; +} + +export class ErrorKitLogger implements Logger { + private readonly name: string; + private readonly errorKit: ErrorHub; + private readonly reportLevel: ReportLevel; + constructor(name: string, errorKit: ErrorHub, reportLevel: ReportLevel) { + this.name = name; + this.errorKit = errorKit; + this.reportLevel = reportLevel; + } + + private stringifyConsoleArgs(...args: unknown[]): string { + return args.reduce((acc: string, val: unknown) => { + let tempVal: HasToString; + switch (true) { + case val instanceof Error: { + tempVal = (val as unknown as InstanceType<typeof Error>) + .message; + break; + } + case typeof val === 'object': { + try { + tempVal = JSON.stringify(val); + } catch (e) { + tempVal = `failed to stringify ${val}`; + } + break; + } + case typeof val === 'undefined' || val === null: { + tempVal = `${val}`; + break; + } + default: { + tempVal = val as HasToString; + } + } + + return `${acc} ${tempVal.toString()}`; + }, `[${this.name}]`) as string; + } + + debug(..._args: unknown[]): string { + return ''; + } + info(..._args: unknown[]): string { + return ''; + } + warn(...args: unknown[]): string { + if (this.reportLevel === ERROR_REPORT_LEVEL.error_warn) { + this.errorKit.captureMessage(this.stringifyConsoleArgs(...args)); + } + return ''; + } + error(...args: unknown[]): string { + const errors = args.filter((item) => item instanceof Error) as Error[]; + const message = this.stringifyConsoleArgs(...args); + + const error = errors.length === 0 ? new Error(message) : errors[0]; + error.message = message; + + this.errorKit.captureException(error); + return ''; + } +} diff --git a/shared/logger/src/errorkit/errorkit.ts b/shared/logger/src/errorkit/errorkit.ts new file mode 100644 index 0000000..dd40e26 --- /dev/null +++ b/shared/logger/src/errorkit/errorkit.ts @@ -0,0 +1,108 @@ +import { Severity } from '@sentry/types'; +import type { Logger, LoggerFactory } from '../types'; +import type { + captureException, + captureMessage, + addBreadcrumb, + ErrorHub, + ErrorKitConfig, +} from './types'; + +type PartialSentryModule = { + captureException: typeof captureException; + captureMessage: typeof captureMessage; + addBreadcrumb: typeof addBreadcrumb; +}; + +export type ErrorKitInstance = InstanceType<typeof ErrorKit>; + +export const setupErrorKit = ( + config: ErrorKitConfig, + loggerFactory: LoggerFactory, +): ErrorKitInstance | undefined => { + if (typeof window === 'undefined') return; + const log = loggerFactory.loggerFor('errorkit'); + const isMultiDev = window.location.href.includes('multidev'); + const BUILD_ENV = process.env.NODE_ENV; + const isErrorKitEnabled = BUILD_ENV === 'production' && !isMultiDev; + + const initializeErrorKit = + async (): Promise<PartialSentryModule | null> => { + let sentry: PartialSentryModule | null = null; + + if (isErrorKitEnabled) { + try { + const { createSentryConfig } = await import( + '@amp-metrics/sentrykit' + ); + const Sentry = await import('@sentry/browser'); + Sentry.init(createSentryConfig(config)); + + sentry = { + addBreadcrumb: Sentry.addBreadcrumb, + captureException: Sentry.captureException, + captureMessage: Sentry.captureMessage, + }; + } catch (e) { + log.error('something went wrong setting up errorKit', e); + } + } + + return sentry; + }; + + return new ErrorKit(initializeErrorKit(), log, isErrorKitEnabled); +}; + +class ErrorKit implements ErrorHub { + private readonly sentry: Promise<PartialSentryModule | null>; + private readonly logger: Logger; + private readonly isErrorKitEnabled: boolean; + constructor( + sentry: Promise<PartialSentryModule | null>, + log: Logger, + isErrorKitEnabled: boolean, + ) { + this.sentry = sentry; + this.logger = log; + this.isErrorKitEnabled = isErrorKitEnabled; + + if (!isErrorKitEnabled) { + log.debug('errorkit is disabled'); + } + } + + async captureMessage(message: string) { + if (!this.isErrorKitEnabled) return; + const sentry = await this.sentry; + + if (sentry) { + sentry.addBreadcrumb({ + category: 'log.warn', + level: Severity.Warning, + }); + sentry.captureMessage(message, Severity.Warning); + } else { + this.logger.warn(`${message} was not sent to errorKit`); + } + } + + async captureException(exception: Error) { + if (!this.isErrorKitEnabled) return; + const sentry = await this.sentry; + + if (sentry) { + sentry.addBreadcrumb({ + type: 'error', + category: 'error', + level: Severity.Error, + }); + sentry.captureException(exception); + } else { + this.logger.warn( + `The following exception was not sent to errorKit:`, + exception, + ); + } + } +} diff --git a/shared/logger/src/index.ts b/shared/logger/src/index.ts new file mode 100644 index 0000000..dc786e3 --- /dev/null +++ b/shared/logger/src/index.ts @@ -0,0 +1,31 @@ +import { getContext } from 'svelte'; +import type { Logger, LoggerFactory } from './types'; + +export * from './composite'; +export * from './console'; +export * from './deferred'; +export * from './recording'; +export * from './sampled'; +export * from './types'; +export * from './void'; + +const CONTEXT_NAME = 'loggerFactory'; + +export function setContext( + context: Map<string, unknown>, + factory: LoggerFactory, +): void { + context.set(CONTEXT_NAME, factory); +} + +export function loggerFor(subject: string): Logger { + const factory = getContext(CONTEXT_NAME) as LoggerFactory | undefined; + + if (!factory) { + throw new Error( + 'loggerFor called before setContext or outside of svelte component init', + ); + } + + return factory.loggerFor(subject); +} diff --git a/shared/logger/src/local-storage-filter.ts b/shared/logger/src/local-storage-filter.ts new file mode 100644 index 0000000..18a42fa --- /dev/null +++ b/shared/logger/src/local-storage-filter.ts @@ -0,0 +1,122 @@ +export type Level = 'debug' | 'info' | 'warn' | 'error'; +// Numbers correspond to the levels above, with 0 meaning "no level" +type LevelNum = 4 | 3 | 2 | 1 | 0; + +interface Rules { + named?: Record<string, LevelNum>; + defaultLevel?: LevelNum; +} + +const LEVEL_TO_NUM: Record<Level | 'off' | '*' | '', LevelNum> = { + '*': 4, + debug: 4, + info: 3, + warn: 2, + error: 1, + off: 0, + '': 0, +}; + +/** + * Parses log filtering instructions from localStorage.onyxLog. + * The instructions are a series of comma separated directives that restrict + * logging. Restrictions indicate the highest log level that a named logger + * will emit. The name of the logger is the string passed to + * LoggerFactory.loggerFor. + * + * By default (ex. empty rule string), no logs will be emitted. + * + * The format of the directives is NAME=LEVEL. LEVEL can be one of: + * + * - * - all levels are logged (debug, info, warn, error) + * - debug - same as above + * - info - everything but debug is logged + * - warn - everything but info and debug is logged + * - error - only errors are logged + * - off (or empty string, ex. "MyClass=") - nothing will be logged + * + * Some examples: + * + * - '*=*' will emit all log levels from all loggers + * - '*=info,Foo=off' will emit everything but debug except or logs from + * the named logger Foo (which will be entirely suppressed) + * - 'Bar=error,Baz=warn' will emit errors from Bar and Baz and warnings from + * Baz + * + * NOTE: Keep this in sync with README.md! + */ +function parseRules(): Rules { + const onyxLog: string = (() => { + try { + // The typeof check is for SSR + return ( + (typeof window !== 'undefined' + ? window.localStorage.onyxLog + : '') || '' + ); + } catch { + // window.localStorage will throw when referenced (at all) when + // Chrome has it disabled + // See: rdar://93367396 (Guard localStorage and sessionStorage use) + return ''; + } + })(); + + const PRODUCTION_DEFAULT = {}; // no logs unless specified + const DEV_DEFAULT = { + defaultLevel: LEVEL_TO_NUM['*'], // All logs unless specified + }; + const isDevelopment = (() => { + // This is a little tricky. The ENV var is not real. It's replaced by + // rollup-plugin-replace. Thus, we can't do the usual of testing for + // the existence of `process` and then doing `process?.env` etc. + // Instead, we just try the whole thing and try/catch. This way, + // rollup-plugin-replace sees that entire string verbatim and can + // replace it with the proper environment. + try { + // @ts-ignore + return process.env.NODE_ENV !== 'production'; + } catch { + return false; + } + })(); + const defaultRules = isDevelopment ? DEV_DEFAULT : PRODUCTION_DEFAULT; + + // If the localStorage is specified, start from a clean slate. Otherwise, + // use the environment default + const rules: Rules = onyxLog.length > 0 ? {} : defaultRules; + + for (const directive of onyxLog.split(',').filter((v) => v)) { + // Invalid directive, must be of the form 'name=level' + const parts = directive.split('='); + if (parts.length !== 2) { + continue; + } + + const [name, maxLevelName] = parts; + const maxLevel = + LEVEL_TO_NUM[maxLevelName as keyof typeof LEVEL_TO_NUM]; + + // Invalid level + if (typeof maxLevel === 'undefined') { + continue; + } + + if (name === '*') { + rules.defaultLevel = maxLevel; + } else { + rules.named = rules.named ?? {}; + rules.named[name] = maxLevel; + } + } + + return rules; +} + +export function shouldLog(name: string, level: Level): boolean { + const rules = parseRules(); + + // Rules for the named logger take precedence over the default + const maxLevel = (rules.named || {})[name] ?? rules.defaultLevel ?? 0; + return LEVEL_TO_NUM[level] <= maxLevel; +} |
