summaryrefslogtreecommitdiff
path: root/shared/logger/src
diff options
context:
space:
mode:
authorrxliuli <rxliuli@gmail.com>2025-11-04 05:03:50 +0800
committerrxliuli <rxliuli@gmail.com>2025-11-04 05:03:50 +0800
commitbce557cc2dc767628bed6aac87301a1be7c5431b (patch)
treeb51a051228d01fe3306cd7626d4a96768aadb944 /shared/logger/src
init commit
Diffstat (limited to 'shared/logger/src')
-rw-r--r--shared/logger/src/base.ts67
-rw-r--r--shared/logger/src/composite.ts92
-rw-r--r--shared/logger/src/console.ts29
-rw-r--r--shared/logger/src/errorkit/errorkit-logger.ts93
-rw-r--r--shared/logger/src/errorkit/errorkit.ts108
-rw-r--r--shared/logger/src/index.ts31
-rw-r--r--shared/logger/src/local-storage-filter.ts122
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;
+}