@@ -0,0 +1,2 @@
|
||||
export * from "./logger.js";
|
||||
export * from "./types.js";
|
||||
+203
@@ -0,0 +1,203 @@
|
||||
import type {
|
||||
LogData,
|
||||
LogEvent,
|
||||
Logger,
|
||||
LoggerEnvironment,
|
||||
LoggerOptions,
|
||||
LogLevel,
|
||||
LogSink,
|
||||
} from "./types.js";
|
||||
|
||||
const LEVELS: readonly Exclude<LogLevel, "silent">[] = ["trace", "debug", "info", "warn", "error"];
|
||||
const levelRank = (level: LogLevel): number =>
|
||||
level === "silent" ? Infinity : LEVELS.indexOf(level);
|
||||
const defaultLevel = (environment: LoggerEnvironment): LogLevel =>
|
||||
environment === "development" ? "trace" : environment === "staging" ? "warn" : "error";
|
||||
|
||||
/** Parses `debug` or `debug:WIDGET,API`, used by browser and container overrides. */
|
||||
export function parseLoggingOverride(
|
||||
value: string | null | undefined,
|
||||
): { level: LogLevel; namespaces: readonly string[] } | undefined {
|
||||
if (!value) return undefined;
|
||||
const [rawLevel, rawNamespaces = ""] = value.trim().split(":", 2);
|
||||
if (!(["trace", "debug", "info", "warn", "error", "silent"] as string[]).includes(rawLevel))
|
||||
return undefined;
|
||||
return {
|
||||
level: rawLevel as LogLevel,
|
||||
namespaces: rawNamespaces
|
||||
.split(",")
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean),
|
||||
};
|
||||
}
|
||||
|
||||
function resolveStorage(provided?: Pick<Storage, "getItem">): Pick<Storage, "getItem"> | undefined {
|
||||
if (provided) return provided;
|
||||
try {
|
||||
return globalThis.sessionStorage;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveEnv(
|
||||
provided?: Record<string, string | undefined>,
|
||||
): Record<string, string | undefined> {
|
||||
if (provided) return provided;
|
||||
return (
|
||||
(globalThis as { process?: { env?: Record<string, string | undefined> } }).process?.env ??
|
||||
{}
|
||||
);
|
||||
}
|
||||
|
||||
function resolveRuntime(provided?: "browser" | "node"): "browser" | "node" {
|
||||
if (provided) return provided;
|
||||
return typeof (globalThis as { window?: unknown }).window === "undefined" ? "node" : "browser";
|
||||
}
|
||||
|
||||
function namespaceMatches(namespace: string | undefined, filters: readonly string[]): boolean {
|
||||
return (
|
||||
filters.length === 0 ||
|
||||
Boolean(
|
||||
namespace &&
|
||||
filters.some((filter) => namespace === filter || namespace.startsWith(`${filter}:`)),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function evaluate(arguments_: readonly unknown[]): readonly unknown[] {
|
||||
return arguments_.map((argument, index) =>
|
||||
index > 0 && typeof argument === "function" ? (argument as () => LogData)() : argument,
|
||||
);
|
||||
}
|
||||
|
||||
/** Creates an intentional, Console-shaped logger. Functions passed after the message are evaluated lazily. */
|
||||
export function createLogger(options: LoggerOptions): Logger {
|
||||
const namespace =
|
||||
typeof options.namespace === "string"
|
||||
? options.namespace
|
||||
: options.namespace?.filter(Boolean).join(":");
|
||||
const env = resolveEnv(options.env);
|
||||
const runtimeOverride =
|
||||
parseLoggingOverride(resolveStorage(options.sessionStorage)?.getItem("showLoggingFor")) ??
|
||||
parseLoggingOverride(
|
||||
env.MIFI_LOG_LEVEL
|
||||
? `${env.MIFI_LOG_LEVEL}${env.MIFI_LOG_NAMESPACES ? `:${env.MIFI_LOG_NAMESPACES}` : ""}`
|
||||
: undefined,
|
||||
);
|
||||
const threshold = options.level ?? runtimeOverride?.level ?? defaultLevel(options.environment);
|
||||
const runtime = resolveRuntime(options.runtime);
|
||||
const sinks: readonly LogSink[] = options.sinks ?? [
|
||||
...(runtime === "node" || options.environment !== "production" || !options.sentry
|
||||
? [createConsoleSink()]
|
||||
: []),
|
||||
...(options.environment === "production" && options.sentry ? [options.sentry] : []),
|
||||
];
|
||||
const enabled = (level: Exclude<LogLevel, "silent">) =>
|
||||
levelRank(level) >= levelRank(threshold) &&
|
||||
(!runtimeOverride?.namespaces.length ||
|
||||
namespaceMatches(namespace, runtimeOverride.namespaces));
|
||||
const emit = (level: Exclude<LogLevel, "silent">, arguments_: readonly unknown[]) => {
|
||||
if (!enabled(level)) return;
|
||||
const event: LogEvent = {
|
||||
level,
|
||||
namespace,
|
||||
arguments: evaluate(arguments_),
|
||||
timestamp: new Date(),
|
||||
};
|
||||
sinks.forEach((sink) => sink.emit(event));
|
||||
};
|
||||
const consoleMethod = (
|
||||
method: keyof Console | "profile" | "profileEnd",
|
||||
level: Exclude<LogLevel, "silent">,
|
||||
arguments_: readonly unknown[],
|
||||
) => {
|
||||
if (!enabled(level)) return;
|
||||
const console_ = globalThis.console as Console & Record<string, unknown>;
|
||||
const fn = console_[method];
|
||||
if (typeof fn === "function")
|
||||
(fn as (...items: unknown[]) => void).apply(
|
||||
console_,
|
||||
Array.from(prefix(namespace, evaluate(arguments_))),
|
||||
);
|
||||
};
|
||||
const api: Logger = {
|
||||
namespace,
|
||||
child: (child) =>
|
||||
createLogger({ ...options, namespace: [namespace, child].filter(Boolean) as string[] }),
|
||||
trace: (...items) => emit("trace", items),
|
||||
debug: (...items) => emit("debug", items),
|
||||
log: (...items) => emit("info", items),
|
||||
info: (...items) => emit("info", items),
|
||||
warn: (...items) => emit("warn", items),
|
||||
error: (...items) => emit("error", items),
|
||||
assert: (condition, ...items) => {
|
||||
if (!condition) emit("error", ["Assertion failed", ...items]);
|
||||
},
|
||||
group: (...items) => consoleMethod("group", "info", items),
|
||||
groupCollapsed: (...items) => consoleMethod("groupCollapsed", "info", items),
|
||||
groupEnd: () => consoleMethod("groupEnd", "info", []),
|
||||
dir: (item, options_) => consoleMethod("dir", "info", [item, options_]),
|
||||
dirxml: (...items) => consoleMethod("dirxml", "info", items),
|
||||
table: (data, properties) => consoleMethod("table", "info", [data, properties]),
|
||||
clear: () => consoleMethod("clear", "info", []),
|
||||
count: (label) => consoleMethod("count", "info", [label]),
|
||||
countReset: (label) => consoleMethod("countReset", "info", [label]),
|
||||
time: (label) => consoleMethod("time", "info", [label]),
|
||||
timeLog: (label, ...items) => consoleMethod("timeLog", "info", [label, ...items]),
|
||||
timeEnd: (label) => consoleMethod("timeEnd", "info", [label]),
|
||||
timeStamp: (label) => consoleMethod("timeStamp", "info", [label]),
|
||||
profile: (label) => consoleMethod("profile", "info", [label]),
|
||||
profileEnd: (label) => consoleMethod("profileEnd", "info", [label]),
|
||||
};
|
||||
return api;
|
||||
}
|
||||
|
||||
function prefix(namespace: string | undefined, arguments_: readonly unknown[]): readonly unknown[] {
|
||||
return namespace ? [`[${namespace.split(":").join("][")}]`, ...arguments_] : arguments_;
|
||||
}
|
||||
|
||||
const ANSI_RESET = "\u001B[0m";
|
||||
const ANSI_BY_LEVEL: Record<Exclude<LogLevel, "silent">, string> = {
|
||||
trace: "\u001B[90m",
|
||||
debug: "\u001B[36m",
|
||||
info: "\u001B[32m",
|
||||
warn: "\u001B[33m",
|
||||
error: "\u001B[31m",
|
||||
};
|
||||
const BROWSER_STYLE_BY_LEVEL: Record<Exclude<LogLevel, "silent">, string> = {
|
||||
trace: "color: #6b7280; font-weight: 600",
|
||||
debug: "color: #0891b2; font-weight: 600",
|
||||
info: "color: #15803d; font-weight: 600",
|
||||
warn: "color: #a16207; font-weight: 700",
|
||||
error: "color: #dc2626; font-weight: 700",
|
||||
};
|
||||
|
||||
function isBrowser(): boolean {
|
||||
return typeof (globalThis as { window?: unknown }).window !== "undefined";
|
||||
}
|
||||
|
||||
function supportsAnsi(): boolean {
|
||||
return Boolean(
|
||||
(globalThis as { process?: { stdout?: { isTTY?: boolean } } }).process?.stdout?.isTTY,
|
||||
);
|
||||
}
|
||||
|
||||
/** Default readable console destination. Node's `console.error` writes to stderr. */
|
||||
export function createConsoleSink(): LogSink {
|
||||
return {
|
||||
emit: (event) => {
|
||||
const method = event.level === "trace" ? "debug" : event.level;
|
||||
const console_ = globalThis.console as Console & Record<string, unknown>;
|
||||
const fn = console_[method];
|
||||
if (typeof fn !== "function") return;
|
||||
const label = event.namespace ? `[${event.namespace.split(":").join("][")}]` : "[LOG]";
|
||||
const arguments_ = isBrowser()
|
||||
? [`%c${label}`, BROWSER_STYLE_BY_LEVEL[event.level], ...event.arguments]
|
||||
: supportsAnsi()
|
||||
? [`${ANSI_BY_LEVEL[event.level]}${label}${ANSI_RESET}`, ...event.arguments]
|
||||
: [label, ...event.arguments];
|
||||
(fn as (...items: unknown[]) => void).apply(console_, arguments_);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { LogEvent, LogSink } from "./types.js";
|
||||
|
||||
export interface SentryScopeLike {
|
||||
setLevel(level: string): void;
|
||||
setTag(key: string, value: string): void;
|
||||
setExtras(extras: Record<string, unknown>): void;
|
||||
}
|
||||
export interface SentryLike {
|
||||
withScope(callback: (scope: SentryScopeLike) => void): void;
|
||||
captureException(error: Error): void;
|
||||
captureMessage(message: string, level: string): void;
|
||||
}
|
||||
|
||||
/** Creates an optional Sentry adapter without adding a Sentry SDK dependency to this package. */
|
||||
export function createSentrySink(sentry: SentryLike): LogSink {
|
||||
return {
|
||||
emit(event: LogEvent) {
|
||||
if (event.level !== "error") return;
|
||||
const error = event.arguments.find((item): item is Error => item instanceof Error);
|
||||
const extras = Object.fromEntries(
|
||||
event.arguments
|
||||
.filter((item) => item && typeof item === "object" && !(item instanceof Error))
|
||||
.map((item, index) => [`context_${index}`, item]),
|
||||
);
|
||||
const message =
|
||||
event.arguments.filter((item) => typeof item === "string").join(" ") ||
|
||||
"Logger error";
|
||||
sentry.withScope((scope) => {
|
||||
scope.setLevel("error");
|
||||
if (event.namespace) scope.setTag("logger.namespace", event.namespace);
|
||||
scope.setExtras(extras);
|
||||
if (error) sentry.captureException(error);
|
||||
else sentry.captureMessage(message, "error");
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
/** A severity threshold, ordered from most to least verbose. */
|
||||
export type LogLevel = "trace" | "debug" | "info" | "warn" | "error" | "silent";
|
||||
export type LoggerEnvironment = "development" | "staging" | "production";
|
||||
export type LogData = unknown | (() => unknown);
|
||||
|
||||
/** A structured event after its level policy has allowed it to be emitted. */
|
||||
export interface LogEvent {
|
||||
level: Exclude<LogLevel, "silent">;
|
||||
namespace?: string;
|
||||
arguments: readonly unknown[];
|
||||
timestamp: Date;
|
||||
}
|
||||
|
||||
/** A destination for enabled log events. */
|
||||
export interface LogSink {
|
||||
emit(event: LogEvent): void;
|
||||
}
|
||||
|
||||
export interface LoggerOptions {
|
||||
environment: LoggerEnvironment;
|
||||
namespace?: string | readonly string[];
|
||||
/** Runtime used for the default destination policy. It is inferred when omitted. */
|
||||
runtime?: "browser" | "node";
|
||||
/** Hard override. It takes precedence over runtime environment defaults. */
|
||||
level?: LogLevel;
|
||||
/** Browser-only session override source; defaults to global sessionStorage when available. */
|
||||
sessionStorage?: Pick<Storage, "getItem">;
|
||||
/** Node/container environment source; defaults to process.env when available. */
|
||||
env?: Record<string, string | undefined>;
|
||||
/** Production error destination. Browser errors go only here; Node errors also go to stderr. */
|
||||
sentry?: LogSink | false;
|
||||
/** Complete destination override. When provided, `sentry` and default console behavior are not used. */
|
||||
sinks?: readonly LogSink[];
|
||||
}
|
||||
|
||||
export interface Logger {
|
||||
readonly namespace?: string;
|
||||
child(namespace: string): Logger;
|
||||
trace(...arguments_: readonly unknown[]): void;
|
||||
debug(...arguments_: readonly unknown[]): void;
|
||||
log(...arguments_: readonly unknown[]): void;
|
||||
info(...arguments_: readonly unknown[]): void;
|
||||
warn(...arguments_: readonly unknown[]): void;
|
||||
error(...arguments_: readonly unknown[]): void;
|
||||
assert(condition: unknown, ...arguments_: readonly unknown[]): void;
|
||||
group(...arguments_: readonly unknown[]): void;
|
||||
groupCollapsed(...arguments_: readonly unknown[]): void;
|
||||
groupEnd(): void;
|
||||
dir(item?: unknown, options?: unknown): void;
|
||||
dirxml(...arguments_: readonly unknown[]): void;
|
||||
table(tabularData?: unknown, properties?: readonly string[]): void;
|
||||
clear(): void;
|
||||
count(label?: string): void;
|
||||
countReset(label?: string): void;
|
||||
time(label?: string): void;
|
||||
timeLog(label?: string, ...arguments_: readonly unknown[]): void;
|
||||
timeEnd(label?: string): void;
|
||||
timeStamp(label?: string): void;
|
||||
profile(label?: string): void;
|
||||
profileEnd(label?: string): void;
|
||||
}
|
||||
Reference in New Issue
Block a user