diff --git a/.craft.yml b/.craft.yml index a51b1a97a..cd6d29968 100644 --- a/.craft.yml +++ b/.craft.yml @@ -103,6 +103,17 @@ workspaces: assert_match version.to_s, shell_output("#{bin}/sentry --version").chomp end end + # No preReleaseCommand: Craft bumps package.json through the npm target. + packages/logger: + changelog: + policy: auto + releaseBranchPrefix: "release/{{workspaceName}}" + targets: + - name: github + tagPrefix: "{{workspaceName}}@" + includeNames: '/^{{packageSimpleName}}-\d.*\.tgz$/' + - name: npm + includeNames: '/^{{packageSimpleName}}-\d.*\.tgz$/' packages/mcp-server: changelogPolicy: none preReleaseCommand: >- diff --git a/.github/workflows/merge-jobs.yml b/.github/workflows/merge-jobs.yml index c021908dc..ea2febc8f 100644 --- a/.github/workflows/merge-jobs.yml +++ b/.github/workflows/merge-jobs.yml @@ -2,7 +2,7 @@ name: Post-merge tasks on: push: - branches: ["main", "release/*", "release/mcp/**"] + branches: ["main", "release/*", "release/mcp/**", "release/logger/**"] workflow_dispatch: jobs: @@ -26,7 +26,7 @@ jobs: run: pnpm install - name: Build - run: pnpm --filter '@sentry/mcp-server...' --if-present run build + run: pnpm --filter '@sentry/mcp-server...' --filter @sentry/logger --if-present run build - name: Run linter run: pnpm lint @@ -38,12 +38,17 @@ jobs: working-directory: packages/mcp-server run: pnpm pack --pack-destination dist + - name: Package logger + working-directory: packages/logger + run: pnpm pack --pack-destination dist + - name: Archive artifacts uses: actions/upload-artifact@v4 with: name: ${{ github.sha }} path: | ${{ github.workspace }}/packages/mcp-server/dist/*.tgz + ${{ github.workspace }}/packages/logger/dist/*.tgz - name: Publish Test Report uses: mikepenz/action-junit-report@cf701569b05ccdd861a76b8607a66d76f6fd4857 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 54f975e10..47a665636 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -10,6 +10,7 @@ on: default: mcp options: - cli + - logger - mcp bump: description: Version bump type @@ -64,6 +65,10 @@ jobs: workspace=packages/cli package_path=packages/cli ;; + logger) + workspace=packages/logger + package_path=packages/logger + ;; mcp) workspace=packages/mcp-server package_path=packages/mcp-server diff --git a/AGENTS.md b/AGENTS.md index 73ded3708..f50faf526 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -22,6 +22,7 @@ toolkit/ │ └── local/ # Sentry Local browser viewer ├── packages/ │ ├── cli/ # Sentry CLI (see packages/cli/AGENTS.md) +│ ├── logger/ # Structured logger (@sentry/logger on npm) │ ├── toolkit-core/ # Shared CLI/MCP primitives │ ├── mcp-core/ # Core MCP implementation (private) │ ├── mcp-server/ # stdio transport (@sentry/mcp-server on npm) diff --git a/docs/README.md b/docs/README.md index c74d1a52c..5b464571e 100644 --- a/docs/README.md +++ b/docs/README.md @@ -29,7 +29,9 @@ directories below; the MCP index explains which guides apply to that product. For package-specific rules, see the root [AGENTS.md](../AGENTS.md) and the [CLI AGENTS.md](../packages/cli/AGENTS.md). `packages/toolkit-core/` contains -shared primitives used by both products. +shared primitives used by both products. `packages/logger/` is the structured +logger the CLI uses, published to npm as `@sentry/logger`; see its +[README](../packages/logger/README.md). ## Ownership diff --git a/packages/cli/package.json b/packages/cli/package.json index 84d5dfad8..0da5ada0b 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -104,6 +104,7 @@ "@mastra/client-js": "^1.38.0", "@sentry/api": "catalog:", "@sentry/core": "catalog:", + "@sentry/logger": "workspace:*", "@sentry/mcp-server": "workspace:*", "@sentry/node": "catalog:", "@sentry/search-syntax": "catalog:", diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 73f8accc1..379b8211f 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -432,10 +432,11 @@ export async function runCli(cliArgs: string[]): Promise { async function promptImportConsent(): Promise< "accepted" | "declined" | "cancelled" > { - const { logger: logModule } = await import("./lib/logger.js"); - const confirmed = await logModule - .withTag("import") - .prompt("Import from .sentryclirc?", { type: "confirm", initial: true }); + const { prompt } = await import("./lib/prompt.js"); + const confirmed = await prompt("Import from .sentryclirc?", { + type: "confirm", + initial: true, + }); if (confirmed === true) { return "accepted"; } diff --git a/packages/cli/src/commands/auth/login.ts b/packages/cli/src/commands/auth/login.ts index b62913008..6f0957686 100644 --- a/packages/cli/src/commands/auth/login.ts +++ b/packages/cli/src/commands/auth/login.ts @@ -49,6 +49,7 @@ import { toLoginUser, } from "../../lib/interactive-login.js"; import { logger } from "../../lib/logger.js"; +import { prompt } from "../../lib/prompt.js"; import { buildHostRefusalMessage, isLoginHostTrusted, @@ -394,7 +395,7 @@ async function handleExistingAuth(force: boolean): Promise { // Interactive TTY: prompt user to confirm re-authentication const userInfo = getUserInfo(); const identity = userInfo ? formatUserIdentity(userInfo) : "current user"; - const confirmed = await log.prompt( + const confirmed = await prompt( `Already authenticated as ${identity}. Add another account? ` + "(The current account stays saved; see 'sentry auth list'. Use --force to replace it.)", { type: "confirm", initial: false }, diff --git a/packages/cli/src/commands/cli/defaults.ts b/packages/cli/src/commands/cli/defaults.ts index f9e4afc70..238cbb362 100644 --- a/packages/cli/src/commands/cli/defaults.ts +++ b/packages/cli/src/commands/cli/defaults.ts @@ -41,6 +41,7 @@ import { ValidationError } from "../../lib/errors.js"; import { formatDefaultsResult } from "../../lib/formatters/human.js"; import { CommandOutput } from "../../lib/formatters/output.js"; import { logger } from "../../lib/logger.js"; +import { prompt } from "../../lib/prompt.js"; import { FORCE_FLAG, guardNonInteractive, @@ -380,7 +381,7 @@ export const defaultsCommand = buildCommand({ // Clear all defaults (with confirmation) guardNonInteractive(flags); if (!isConfirmationBypassed(flags)) { - const confirmed = await log.prompt( + const confirmed = await prompt( `This will clear all defaults (${Object.keys(DEFAULTS_REGISTRY).join(", ")}). Continue?`, { type: "confirm" }, ); diff --git a/packages/cli/src/commands/cli/feedback.ts b/packages/cli/src/commands/cli/feedback.ts index b5cff851d..22af1d6f6 100644 --- a/packages/cli/src/commands/cli/feedback.ts +++ b/packages/cli/src/commands/cli/feedback.ts @@ -17,7 +17,7 @@ import { parseText } from "../../lib/completion-target.js"; import { ConfigError, ValidationError } from "../../lib/errors.js"; import { formatFeedbackResult } from "../../lib/formatters/human.js"; import { CommandOutput } from "../../lib/formatters/output.js"; -import { logger } from "../../lib/logger.js"; +import { prompt } from "../../lib/prompt.js"; /** Structured result of the feedback submission */ export type FeedbackResult = { @@ -69,7 +69,7 @@ export const feedbackCommand = buildCommand({ if (!isatty(0)) { throw new ValidationError("Please provide a feedback message."); } - const response = await logger.prompt("Enter your feedback message:", { + const response = await prompt("Enter your feedback message:", { type: "text", placeholder: "e.g. I love this tool!", }); diff --git a/packages/cli/src/commands/cli/import.ts b/packages/cli/src/commands/cli/import.ts index 2d22537f3..a84411412 100644 --- a/packages/cli/src/commands/cli/import.ts +++ b/packages/cli/src/commands/cli/import.ts @@ -20,6 +20,7 @@ import { success, warning } from "../../lib/formatters/colors.js"; import { renderMarkdown } from "../../lib/formatters/markdown.js"; import { CommandOutput } from "../../lib/formatters/output.js"; import { logger } from "../../lib/logger.js"; +import { prompt } from "../../lib/prompt.js"; import { DRY_RUN_FLAG } from "../../lib/mutate-command.js"; import { normalizeUserInputToOrigin } from "../../lib/sentry-urls.js"; import type { @@ -229,7 +230,7 @@ async function confirmImport(): Promise { if (!isatty(0)) { return false; } - const confirmed = await log.prompt("Import these settings to the new CLI?", { + const confirmed = await prompt("Import these settings to the new CLI?", { type: "confirm", initial: true, }); diff --git a/packages/cli/src/commands/issue/plan.ts b/packages/cli/src/commands/issue/plan.ts index 717a442c2..cfe4a57e8 100644 --- a/packages/cli/src/commands/issue/plan.ts +++ b/packages/cli/src/commands/issue/plan.ts @@ -21,6 +21,7 @@ import { FRESH_FLAG, } from "../../lib/list-command.js"; import { logger } from "../../lib/logger.js"; +import { prompt } from "../../lib/prompt.js"; import { type AutofixState, extractExaminedFiles, @@ -184,7 +185,7 @@ async function confirmRootCauseAndContinue( log.info("Root cause identified:"); log.info(`"${primaryCause.description}"`); - const proceed = await log.prompt("Generate a solution plan for it?", { + const proceed = await prompt("Generate a solution plan for it?", { type: "confirm", initial: true, }); diff --git a/packages/cli/src/commands/log/view.ts b/packages/cli/src/commands/log/view.ts index 7e92c691f..f254b22b4 100644 --- a/packages/cli/src/commands/log/view.ts +++ b/packages/cli/src/commands/log/view.ts @@ -39,6 +39,7 @@ import { FRESH_FLAG, } from "../../lib/list-command.js"; import { logger } from "../../lib/logger.js"; +import { prompt } from "../../lib/prompt.js"; import { type BoundArguments, boundArguments, @@ -306,7 +307,7 @@ async function handleWebOpen(orgSlug: string, logIds: string[]): Promise { ); return; } - const confirmed = await log.prompt(`Open ${logIds.length} browser tabs?`, { + const confirmed = await prompt(`Open ${logIds.length} browser tabs?`, { type: "confirm", initial: false, }); diff --git a/packages/cli/src/commands/trial/start.ts b/packages/cli/src/commands/trial/start.ts index f336dd8bd..0845e0b97 100644 --- a/packages/cli/src/commands/trial/start.ts +++ b/packages/cli/src/commands/trial/start.ts @@ -25,6 +25,7 @@ import { ContextError, ValidationError } from "../../lib/errors.js"; import { success } from "../../lib/formatters/colors.js"; import { CommandOutput } from "../../lib/formatters/output.js"; import { logger as log } from "../../lib/logger.js"; +import { prompt } from "../../lib/prompt.js"; import { type BoundArguments, boundArguments, @@ -230,7 +231,7 @@ async function promptOpenBrowser(url: string): Promise { // Prompt to open browser if interactive TTY (stdin for input, stderr for display) if (isatty(0) && isatty(2)) { - const confirmed = await logger.prompt("Open in browser?", { + const confirmed = await prompt("Open in browser?", { type: "confirm", initial: true, }); diff --git a/packages/cli/src/lib/logger.ts b/packages/cli/src/lib/logger.ts index 94e5007ce..0582db6af 100644 --- a/packages/cli/src/lib/logger.ts +++ b/packages/cli/src/lib/logger.ts @@ -1,31 +1,20 @@ /** * Structured logging for Sentry CLI. * - * A small in-house logger with log levels, tag scoping, and consola-style - * terminal output. It replaced `consola`, the largest third-party package that - * `sentry __complete` loaded on every TAB press. Each log call goes to every - * registered reporter: + * The logger comes from `@sentry/logger` (`packages/logger`): levels, tag + * scoping, and reporters. This module configures it for the CLI. Each log + * call goes to every registered reporter: * - * 1. **The stderr reporter** (built in) — renders consola's "fancy" layout - * (icons, colors, `WARN`/`ERROR` badges, a right-aligned `tag HH:MM:SS`) and - * falls back to plain `[type] [tag] message` lines in CI and tests. The time - * comes from {@link formatLogTime}, which stays in local time in the SEA - * binaries this CLI ships. - * 2. **Sentry.createConsolaReporter()** — auto-forwards all log messages to Sentry - * structured logs via `_INTERNAL_captureLog`. Log records keep consola's - * `{ date, args, type, level, tag }` shape so that reporter still reads them. - * Library mode drops these logs in `beforeSendLog` (see telemetry.ts). + * 1. **`fancyReporter`** — renders consola's "fancy" layout on stderr and + * falls back to plain `[type] [tag] message` lines in CI and tests. It reads + * the environment through {@link getEnv}, so library mode's isolated env + * controls colors and layout. + * 2. **`sentryReporter(Sentry.logger)`** — forwards all log messages to Sentry + * structured logs. Library mode drops these logs in `beforeSendLog` (see + * telemetry.ts). * - * ## Log levels - * - * | Level | Value | Shows | - * |-------|-------|-------| - * | error | 0 | error | - * | warn | 1 | + warn | - * | log | 2 | + log | - * | info | 3 | + info, success, start, fail (default) | - * | debug | 4 | + debug | - * | trace | 5 | + trace | + * Prompts live in `prompt.ts`. See `packages/logger/README.md` for the log + * levels. * * ## Usage * @@ -50,18 +39,20 @@ * - `NO_COLOR` — Disables color output * - `FORCE_COLOR` — Forces color output even in non-TTY * - * Every logger, including the scoped ones `withTag()` returns, shares one level - * and one reporter list, so a later `setLogLevel()` also reaches the scoped - * loggers that modules create at import time. - * * @module */ -import { sep } from "node:path"; -import { formatWithOptions } from "node:util"; +import { + createLogger, + fancyReporter, + parseLogLevel, + type Reporter, +} from "@sentry/logger"; import { getEnv } from "./env.js"; -import { formatLogTime } from "./timezone.js"; + +export { LOG_LEVEL_NAMES, parseLogLevel } from "@sentry/logger"; +export type { LogLevelName, LogRecord, Reporter } from "@sentry/logger"; /** * Environment variable name for controlling CLI log verbosity. @@ -71,51 +62,6 @@ import { formatLogTime } from "./timezone.js"; */ export const LOG_LEVEL_ENV_VAR = "SENTRY_LOG_LEVEL"; -/** - * Valid log level names accepted by `SENTRY_LOG_LEVEL` and `--log-level`. - * - * Array index IS the numeric level: - * - 0 = error - * - 1 = warn - * - 2 = log - * - 3 = info (default — also shows success, start, fail) - * - 4 = debug - * - 5 = trace - */ -export const LOG_LEVEL_NAMES = [ - "error", - "warn", - "log", - "info", - "debug", - "trace", -] as const; - -/** A valid log level name string */ -export type LogLevelName = (typeof LOG_LEVEL_NAMES)[number]; - -/** - * Default log level when nothing is configured. - * Level 3 shows info, success, start, log, fail — standard user-facing output. - */ -const DEFAULT_LOG_LEVEL = 3; - -/** - * Parse a log level name string into a numeric level. - * - * Returns the default level (info = 3) for unrecognized values so that - * a typo in `SENTRY_LOG_LEVEL` doesn't break the CLI. - * - * @param name - Level name from env var or CLI flag - * @returns numeric level - */ -export function parseLogLevel(name: string): number { - const idx = LOG_LEVEL_NAMES.indexOf( - name.toLowerCase().trim() as LogLevelName, - ); - return idx === -1 ? DEFAULT_LOG_LEVEL : idx; -} - /** * Read the log level from `SENTRY_LOG_LEVEL`, called lazily by {@link setLogLevel}. * @@ -132,160 +78,6 @@ export function getEnvLogLevel(): number | null { return null; } -/** Level of each log method: a call prints when it is at most the logger's level. */ -const LOG_TYPE_LEVELS = { - error: 0, - warn: 1, - log: 2, - info: 3, - success: 3, - fail: 3, - start: 3, - debug: 4, - trace: 5, -} as const; - -/** A log method name, such as `info` or `warn`. */ -type LogType = keyof typeof LOG_TYPE_LEVELS; - -/** - * One log call, in the shape consola reporters read, so the reporter from - * `Sentry.createConsolaReporter()` can forward it. - */ -export type LogObject = { - date: Date; - args: unknown[]; - type: LogType; - level: number; - tag: string; -}; - -/** Receives every log call at or below the current level. */ -export type LogReporter = { - log(logObj: LogObject): void; -}; - -/** Options for a yes/no prompt. */ -type ConfirmPromptOptions = { type: "confirm"; initial?: boolean }; - -/** Options for a free-text prompt. */ -type TextPromptOptions = { - type: "text"; - placeholder?: string; - initial?: string; - default?: string; -}; - -/** - * Set to `true` by esbuild in the `dist/complete.cjs` bundle (see - * `script/bundle.ts`), so the `@clack/prompts` import below stays out of the - * shell completion bundle. Undefined when running from source. - */ -declare const SENTRY_CLI_COMPLETION_BUILD: boolean | undefined; - -/** - * Ask the user a question on the terminal. - * - * Resolves to the answer, or to clack's cancel symbol when the user presses - * Ctrl+C, so callers check for `=== true` or `typeof answer === "string"`. - */ -function askUser( - message: string, - opts: ConfirmPromptOptions, -): Promise; -function askUser( - message: string, - opts: TextPromptOptions, -): Promise; -async function askUser( - message: string, - opts: ConfirmPromptOptions | TextPromptOptions, -): Promise { - if ( - typeof SENTRY_CLI_COMPLETION_BUILD !== "undefined" && - SENTRY_CLI_COMPLETION_BUILD - ) { - throw new Error("Prompts are not available during shell completion."); - } else { - // The `else` lets esbuild drop this import from the completion bundle. - // Elsewhere `@clack/prompts` loads only when a command asks a question. - const clack = await import("@clack/prompts"); - if (opts.type === "confirm") { - return await clack.confirm({ message, initialValue: opts.initial }); - } - return await clack.text({ - message, - placeholder: opts.placeholder, - initialValue: opts.initial, - defaultValue: opts.default, - }); - } -} - -/** Level and reporters shared by the root logger and every scoped logger. */ -const state: { level: number; reporters: LogReporter[] } = { - level: DEFAULT_LOG_LEVEL, - reporters: [{ log: writeLogToStderr }], -}; - -/** A logger, optionally scoped to a tag shown with each message. */ -class Logger { - /** Tag shown with each message; nested tags join with `:`. */ - readonly tag: string; - - // Arrow properties keep the methods bound, so they can be passed around. - readonly error = (...args: unknown[]): void => this.emit("error", args); - readonly warn = (...args: unknown[]): void => this.emit("warn", args); - readonly log = (...args: unknown[]): void => this.emit("log", args); - readonly info = (...args: unknown[]): void => this.emit("info", args); - readonly success = (...args: unknown[]): void => this.emit("success", args); - readonly fail = (...args: unknown[]): void => this.emit("fail", args); - readonly start = (...args: unknown[]): void => this.emit("start", args); - readonly debug = (...args: unknown[]): void => this.emit("debug", args); - readonly trace = (...args: unknown[]): void => this.emit("trace", args); - readonly prompt = askUser; - - constructor(tag = "") { - this.tag = tag; - } - - /** The shared level: calls above it are dropped. */ - get level(): number { - return state.level; - } - - set level(level: number) { - state.level = level; - } - - /** Return a logger whose messages carry `tag`, nested under this logger's tag. */ - withTag(tag: string): Logger { - return new Logger(this.tag ? `${this.tag}:${tag}` : tag); - } - - /** Send every later log call to `reporter` too. */ - addReporter(reporter: LogReporter): void { - state.reporters.push(reporter); - } - - private emit(type: LogType, args: unknown[]): void { - const level = LOG_TYPE_LEVELS[type]; - if (level > state.level) { - return; - } - const logObj: LogObject = { - date: new Date(), - args, - type, - level, - tag: this.tag, - }; - for (const reporter of state.reporters) { - reporter.log(logObj); - } - } -} - /** * Global CLI logger instance. * @@ -303,7 +95,9 @@ class Logger { * by bin.ts calling `setLogLevel(getEnvLogLevel())` — not at module load time — * so tests can override the env var after import. */ -export const logger = new Logger(); +export const logger = createLogger({ + reporters: [fancyReporter({ env: getEnv })], +}); /** * Write an already-formatted line to stderr verbatim, bypassing the logger. @@ -337,22 +131,22 @@ export function printJsonLine(line: string): void { let sentryReporterAttached = false; /** - * Attach the Sentry consola reporter for automatic log forwarding. + * Attach the Sentry reporter for automatic log forwarding. * - * Must be called after `Sentry.init()` completes so that `createConsolaReporter()` - * can find the active client. Safe to call multiple times — subsequent calls are no-ops. + * Must be called after `Sentry.init()` completes so that the reporter has an + * active client. Safe to call multiple times — subsequent calls are no-ops. * The caller creates the reporter so this module never imports `@sentry/node`: * the `__complete` fast path loads the logger through the database modules. * - * The reporter auto-forwards all log messages to Sentry structured logs, - * mapping log types to Sentry severity levels: + * The reporter forwards all log messages to Sentry structured logs, mapping + * log types to Sentry severity levels: * - error → error, fail → error, warn → warn * - info/log/success/start → info * - debug → debug, trace → trace * - * Tags are preserved as `consola.tag` attributes in Sentry. + * Tags are preserved as `logger.tag` attributes in Sentry. */ -export function attachSentryReporter(reporter: LogReporter): void { +export function attachSentryReporter(reporter: Reporter): void { if (sentryReporterAttached) { return; } @@ -371,216 +165,5 @@ export function attachSentryReporter(reporter: LogReporter): void { * @param level - numeric level (0-5) */ export function setLogLevel(level: number): void { - state.level = level; -} - -// stderr rendering - -/** SGR open and close codes for the styles the reporter uses. */ -const ANSI_CODES = { - black: [30, 39], - red: [31, 39], - green: [32, 39], - yellow: [33, 39], - magenta: [35, 39], - cyan: [36, 39], - gray: [90, 39], - underline: [4, 24], - bgRed: [41, 49], - bgYellow: [43, 49], -} as const; - -type AnsiStyle = keyof typeof ANSI_CODES; - -/** Icon color per log type. The error and warn levels render as badges instead. */ -const TYPE_COLORS: Record = { - error: "red", - warn: "yellow", - log: "gray", - info: "cyan", - success: "green", - fail: "red", - start: "magenta", - debug: "gray", - trace: "gray", -}; - -/** Badge background for the error (0) and warn (1) levels. */ -const BADGE_BACKGROUNDS: Record = { - 0: "bgRed", - 1: "bgYellow", -}; - -/** Unicode icon per log type, and its ASCII fallback. */ -const TYPE_ICONS: Record = { - error: ["✖", "×"], - warn: ["⚠", "‼"], - log: ["", ""], - info: ["ℹ", "i"], - success: ["✔", "√"], - fail: ["✖", "×"], - start: ["◐", "o"], - debug: ["⚙", "D"], - trace: ["→", "→"], -}; - -/** Matches SGR escape sequences, to measure the visible width of a line. */ -// oxlint-disable-next-line no-control-regex -- matching ANSI escapes requires ESC -const ANSI_PATTERN = /\x1b\[[0-9;]*m/g; - -/** Inline `code` spans, rendered in cyan without their backticks. */ -const CODE_SPAN_PATTERN = /`([^`]+)`/g; - -/** ` _word_ ` spans, rendered underlined. */ -const UNDERLINE_PATTERN = /\s+_([^_]+)_\s+/g; - -/** Whether an env var is set to something other than `""` or `"false"`. */ -function isEnabled(value: string | undefined): boolean { - return Boolean(value) && value !== "false"; -} - -/** - * Whether to render the fancy layout. CI logs and test runs get plain - * `[type] [tag] message` lines instead, as they did with consola. - */ -function useFancyLayout(): boolean { - const env = getEnv(); - return !(isEnabled(env.CI) || env.NODE_ENV === "test" || isEnabled(env.TEST)); -} - -/** Whether stderr takes ANSI colors: `NO_COLOR`, then `FORCE_COLOR`, then TTY detection. */ -function useColor(): boolean { - const env = getEnv(); - if (env.NO_COLOR) { - return false; - } - if (env.FORCE_COLOR) { - return env.FORCE_COLOR !== "0"; - } - return Boolean(process.stderr.isTTY) && env.TERM !== "dumb"; -} - -/** Whether the terminal can draw the Unicode icons. */ -function supportsUnicode(): boolean { - const env = getEnv(); - if (process.platform !== "win32") { - return env.TERM !== "linux"; - } - return ( - Boolean(env.WT_SESSION) || - env.TERM_PROGRAM === "vscode" || - env.TERM === "xterm-256color" || - env.TERM === "alacritty" || - env.TERMINAL_EMULATOR === "JetBrains-JediTerm" - ); -} - -/** - * Approximate visible width of a line: its length without ANSI escapes. Wide - * characters count as one column, which only shifts the right-aligned tag. - */ -function visibleWidth(text: string): number { - return text.replace(ANSI_PATTERN, "").length; -} - -/** Options for formatting log arguments, as consola passed them. */ -const FORMAT_OPTIONS = { colors: false, compact: true } as const; - -/** An `Error`, or anything else carrying a stack. */ -type ErrorLike = { message?: unknown; stack: string; cause?: unknown }; - -function isErrorLike(value: unknown): value is ErrorLike { - return ( - typeof value === "object" && - value !== null && - typeof (value as { stack?: unknown }).stack === "string" - ); -} - -/** - * Render an error as its message, its stack frames, and its causes, as consola - * did. Unlike `util.inspect`, this leaves out the error's other properties, - * which can hold API response details. - */ -function formatError(error: ErrorLike, depth: number): string { - const message = - typeof error.message === "string" - ? error.message - : formatWithOptions(FORMAT_OPTIONS, error); - const indent = " ".repeat(depth + 1); - const cwd = process.cwd() + sep; - const frames = error.stack - .split("\n") - .slice(message.split("\n").length) - .map( - (frame) => indent + frame.trim().replace("file://", "").replace(cwd, ""), - ) - .join("\n"); - const prefix = depth > 0 ? `${" ".repeat(depth)}[cause]: ` : ""; - let cause = ""; - if (isErrorLike(error.cause)) { - cause = `\n\n${formatError(error.cause, depth + 1)}`; - } else if (error.cause) { - cause = `\n\n${" ".repeat(depth + 1)}[cause]: ${formatWithOptions(FORMAT_OPTIONS, error.cause)}`; - } - return `${prefix}${message}\n${frames}${cause}`; -} - -/** Render `logObj` as terminal lines, without a trailing newline. */ -function formatLogObj(logObj: LogObject): string { - const message = formatWithOptions( - FORMAT_OPTIONS, - ...logObj.args.map((arg) => (isErrorLike(arg) ? formatError(arg, 0) : arg)), - ); - if (!useFancyLayout()) { - return [`[${logObj.type}]`, logObj.tag && `[${logObj.tag}]`, message] - .filter(Boolean) - .join(" "); - } - - const color = useColor(); - const paint = (style: AnsiStyle, text: string): string => { - if (!(color && text)) { - return text; - } - const [open, close] = ANSI_CODES[style]; - return `\x1b[${open}m${text}\x1b[${close}m`; - }; - const markup = (text: string): string => - text - .replace(CODE_SPAN_PATTERN, (_, code: string) => paint("cyan", code)) - .replace( - UNDERLINE_PATTERN, - (_, word: string) => ` ${paint("underline", word)} `, - ); - - const [firstLine = "", ...additional] = message.split("\n"); - const badge = BADGE_BACKGROUNDS[logObj.level]; - const type = badge - ? paint(badge, paint("black", ` ${logObj.type.toUpperCase()} `)) - : paint( - TYPE_COLORS[logObj.type], - TYPE_ICONS[logObj.type][supportsUnicode() ? 0 : 1], - ); - - const columns = process.stderr.columns || 0; - const tag = logObj.tag ? paint("gray", logObj.tag) : ""; - const date = columns ? paint("gray", formatLogTime(logObj.date)) : ""; - const left = [type, markup(firstLine)].filter(Boolean).join(" "); - const right = [tag, date].filter(Boolean).join(" "); - const space = columns - visibleWidth(left) - visibleWidth(right) - 2; - - let line = - space > 0 && columns >= 80 - ? left + " ".repeat(space) + right - : (right ? `${paint("gray", `[${right}]`)} ` : "") + left; - if (additional.length > 0) { - line += markup(`\n${additional.join("\n")}`); - } - return badge ? `\n${line}\n` : line; -} - -/** The built-in reporter: render the log call and write it to stderr in one write. */ -function writeLogToStderr(logObj: LogObject): void { - process.stderr.write(`${formatLogObj(logObj)}\n`); + logger.level = level; } diff --git a/packages/cli/src/lib/mutate-command.ts b/packages/cli/src/lib/mutate-command.ts index e99ee1c1a..584648756 100644 --- a/packages/cli/src/lib/mutate-command.ts +++ b/packages/cli/src/lib/mutate-command.ts @@ -22,7 +22,7 @@ import type { ParsedOrgProject } from "./arg-parsing.js"; import { buildCommand, type CommandDocumentation } from "./command.js"; import { CliError, ContextError } from "./errors.js"; import type { CommandReturn } from "./formatters/output.js"; -import { logger } from "./logger.js"; +import { prompt } from "./prompt.js"; // --------------------------------------------------------------------------- // Level A: shared flag / alias definitions @@ -182,7 +182,6 @@ export function guardNonInteractive(flags: { * * @param expected - The string the user must type to confirm (e.g., "acme/my-app") * @param promptMessage - The message displayed to the user - * @param opts - Optional logger override (defaults to the global logger) * @returns `true` if confirmed, `false` if cancelled or mismatched * * @example @@ -199,11 +198,8 @@ export function guardNonInteractive(flags: { export async function confirmByTyping( expected: string, promptMessage: string, - opts?: { logger?: { prompt: typeof logger.prompt } }, ): Promise { - const log = opts?.logger ?? logger; - - const response = await log.prompt(promptMessage, { + const response = await prompt(promptMessage, { type: "text", placeholder: expected, }); diff --git a/packages/cli/src/lib/prompt.ts b/packages/cli/src/lib/prompt.ts new file mode 100644 index 000000000..ab40475b7 --- /dev/null +++ b/packages/cli/src/lib/prompt.ts @@ -0,0 +1,64 @@ +/** + * Interactive terminal prompts, backed by `@clack/prompts`. + * + * `@clack/prompts` loads only when a command asks a question, and never in the + * shell completion bundle. + * + * @module + */ + +/** Options for a yes/no prompt. */ +type ConfirmPromptOptions = { type: "confirm"; initial?: boolean }; + +/** Options for a free-text prompt. */ +type TextPromptOptions = { + type: "text"; + placeholder?: string; + initial?: string; + default?: string; +}; + +/** + * Set to `true` by esbuild in the `dist/complete.cjs` bundle (see + * `script/bundle.ts`), so the `@clack/prompts` import below stays out of the + * shell completion bundle. Undefined when running from source. + */ +declare const SENTRY_CLI_COMPLETION_BUILD: boolean | undefined; + +/** + * Ask the user a question on the terminal. + * + * Resolves to the answer, or to clack's cancel symbol when the user presses + * Ctrl+C, so callers check for `=== true` or `typeof answer === "string"`. + */ +export function prompt( + message: string, + opts: ConfirmPromptOptions, +): Promise; +export function prompt( + message: string, + opts: TextPromptOptions, +): Promise; +export async function prompt( + message: string, + opts: ConfirmPromptOptions | TextPromptOptions, +): Promise { + if ( + typeof SENTRY_CLI_COMPLETION_BUILD !== "undefined" && + SENTRY_CLI_COMPLETION_BUILD + ) { + throw new Error("Prompts are not available during shell completion."); + } else { + // The `else` lets esbuild drop this import from the completion bundle. + const clack = await import("@clack/prompts"); + if (opts.type === "confirm") { + return await clack.confirm({ message, initialValue: opts.initial }); + } + return await clack.text({ + message, + placeholder: opts.placeholder, + initialValue: opts.initial, + defaultValue: opts.default, + }); + } +} diff --git a/packages/cli/src/lib/seer-trial.ts b/packages/cli/src/lib/seer-trial.ts index c18204ab1..14927fe17 100644 --- a/packages/cli/src/lib/seer-trial.ts +++ b/packages/cli/src/lib/seer-trial.ts @@ -15,6 +15,7 @@ import { isatty } from "node:tty"; import { getProductTrials, startProductTrial } from "./api-client.js"; import { SeerError, type SeerErrorReason } from "./errors.js"; import { logger } from "./logger.js"; +import { prompt } from "./prompt.js"; import { buildBillingUrl } from "./sentry-web-urls.js"; import { findAvailableTrial } from "./trials.js"; @@ -99,7 +100,7 @@ export async function promptAndStartTrial( } const daysText = trial.lengthDays ? `${trial.lengthDays}-day ` : ""; - const confirmed = await log.prompt( + const confirmed = await prompt( `A free ${daysText}Seer trial is available. Start trial?`, { type: "confirm", initial: true }, ); diff --git a/packages/cli/src/lib/telemetry.ts b/packages/cli/src/lib/telemetry.ts index 26adc8809..7bf1c42c0 100644 --- a/packages/cli/src/lib/telemetry.ts +++ b/packages/cli/src/lib/telemetry.ts @@ -40,6 +40,7 @@ import { } from "./error-reporting.js"; import { ApiError, isUserError } from "./errors.js"; import { attachSentryReporter, logger } from "./logger.js"; +import { sentryReporter } from "@sentry/logger"; import { getSentryBaseUrl, isSentrySaasUrl } from "./sentry-urls.js"; import { redactTelemetryEnvelope } from "./telemetry/credential-redaction.js"; import { makeCompressedTransport } from "./telemetry/zstd-transport.js"; @@ -786,7 +787,7 @@ export function initSentry( } // Wire up logger → Sentry log forwarding now that the client is active - attachSentryReporter(Sentry.createConsolaReporter()); + attachSentryReporter(sentryReporter(Sentry.logger)); // End healthy sessions and flush events when the event loop drains. // The SDK's processSessionIntegration starts a session during init and diff --git a/packages/cli/src/lib/timezone.ts b/packages/cli/src/lib/timezone.ts index 7cdf7299c..e13fef338 100644 --- a/packages/cli/src/lib/timezone.ts +++ b/packages/cli/src/lib/timezone.ts @@ -253,32 +253,3 @@ export function initTimezone(): string | null { process.env.TZ = zone; return zone; } - -/** - * Format a `Date` as a local-time clock string for the log prefix. - * - * Renders 24-hour `HH:MM:SS` in the machine's local timezone. Unlike the - * default `date.toLocaleTimeString()` this does **not** depend on ICU being - * able to resolve a zone: it derives the local wall-clock time from the UTC - * time and `getTimezoneOffset()`, which reflects the OS offset even in runtimes - * that cache a stale UTC formatter (observed under some SEA/Bun builds). After - * {@link initTimezone} has set `TZ`, `getTimezoneOffset()` returns the corrected - * offset, so this stays consistent with the rest of the CLI's output. - * - * The 24-hour format also removes the AM/PM ambiguity of the previous prefix, - * which is friendlier for logs shared across regions. - * - * @param date - The log record's timestamp - * @returns A `HH:MM:SS` string in local time (e.g. `"06:28:59"`) - */ -export function formatLogTime(date: Date): string { - // getTimezoneOffset() is minutes behind UTC (positive west of UTC). Shifting - // the epoch by it and reading UTC getters yields local wall-clock components - // without relying on the ICU formatter. - const localMs = date.getTime() - date.getTimezoneOffset() * 60_000; - const local = new Date(localMs); - const hh = String(local.getUTCHours()).padStart(2, "0"); - const mm = String(local.getUTCMinutes()).padStart(2, "0"); - const ss = String(local.getUTCSeconds()).padStart(2, "0"); - return `${hh}:${mm}:${ss}`; -} diff --git a/packages/cli/test/commands/auth/login.test.ts b/packages/cli/test/commands/auth/login.test.ts index e65019257..85e59b3cc 100644 --- a/packages/cli/test/commands/auth/login.test.ts +++ b/packages/cli/test/commands/auth/login.test.ts @@ -7,8 +7,8 @@ * calls or database access. * * The interactive TTY prompt tests use vi.mock() at the top of this file - * to stub node:tty (so isatty(0) returns true) and the logger module (so - * `.prompt()` is controllable). + * to stub node:tty (so isatty(0) returns true) and the prompt module (so + * `prompt()` is controllable). */ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; @@ -31,13 +31,12 @@ const { mockIsatty, ttyExports, noop, mockPrompt, fakeLog } = vi.hoisted(() => { // intentional no-op } - // Mock the logger module to intercept the .prompt() call made by the - // module-scoped `log = logger.withTag("auth.login")` in login.ts. + // Stub the logger module-scoped as `log = logger.withTag("auth.login")` in + // login.ts; the prompt module is mocked with `_mockPrompt` below. const _mockPrompt = vi.fn((): Promise => Promise.resolve(true), ); const _fakeLog: { - prompt: typeof _mockPrompt; info: ReturnType; warn: ReturnType; error: ReturnType; @@ -45,7 +44,6 @@ const { mockIsatty, ttyExports, noop, mockPrompt, fakeLog } = vi.hoisted(() => { success: ReturnType; withTag: () => typeof _fakeLog; } = { - prompt: _mockPrompt, info: vi.fn(_noop), warn: vi.fn(_noop), error: vi.fn(_noop), @@ -68,6 +66,8 @@ vi.mock("node:tty", () => ({ default: ttyExports, })); +vi.mock("../../../src/lib/prompt.js", () => ({ prompt: mockPrompt })); + vi.mock("../../../src/lib/logger.js", () => ({ logger: fakeLog, setLogLevel: vi.fn(noop), @@ -806,7 +806,7 @@ describe("loginCommand.func --token path", () => { * Tests for the interactive TTY re-authentication prompt. * * Uses the module-level `vi.mock()` on node:tty (so `isatty(0)` returns - * true) and the logger (so `.prompt()` is controllable). + * true) and the prompt module (so `prompt()` is controllable). */ describe("login re-authentication interactive prompt", () => { let isAuthenticatedSpy: ReturnType; diff --git a/packages/cli/test/commands/issue/plan.func.test.ts b/packages/cli/test/commands/issue/plan.func.test.ts index e3773e832..16cfa4dac 100644 --- a/packages/cli/test/commands/issue/plan.func.test.ts +++ b/packages/cli/test/commands/issue/plan.func.test.ts @@ -25,9 +25,11 @@ import * as issueUtils from "../../../src/commands/issue/utils.js"; import { triggerSolutionPlanning } from "../../../src/lib/api-client.js"; import { CliError } from "../../../src/lib/errors.js"; import { logger } from "../../../src/lib/logger.js"; +import { prompt } from "../../../src/lib/prompt.js"; import type { AutofixState, RootCause } from "../../../src/types/seer.js"; vi.mock("node:tty", () => ({ isatty: vi.fn() })); +vi.mock("../../../src/lib/prompt.js", () => ({ prompt: vi.fn() })); vi.mock("../../../src/lib/api-client.js", () => ({ triggerSolutionPlanning: vi.fn(async () => ({})), @@ -77,11 +79,10 @@ describe("issue plan waiting-for-user-response", () => { vi.clearAllMocks(); resolveSpy = vi.spyOn(issueUtils, "resolveOrgAndIssueId"); analyzeSpy = vi.spyOn(issueUtils, "ensureRootCauseAnalysis"); - promptSpy = vi.fn(); - // The command prompts through a tagged logger, so intercept withTag. + promptSpy = vi.mocked(prompt); + promptSpy.mockReset(); vi.spyOn(logger, "withTag").mockReturnValue({ info: vi.fn(), - prompt: promptSpy, } as unknown as ReturnType); resolveSpy.mockResolvedValue({ org: "test-org", issueId: "1" }); }); diff --git a/packages/cli/test/commands/log/view.test.ts b/packages/cli/test/commands/log/view.test.ts index ce6b6a360..f3b334b4f 100644 --- a/packages/cli/test/commands/log/view.test.ts +++ b/packages/cli/test/commands/log/view.test.ts @@ -26,18 +26,16 @@ const { mockIsatty, ttyExports, noop, mockPrompt, fakeLog } = vi.hoisted(() => { // intentional no-op } - // Mock the logger module to intercept the .prompt() call made by the - // module-scoped `log = logger.withTag("log-view")` in view.ts. + // Stub the logger module-scoped as `log = logger.withTag("log-view")` in + // view.ts; the prompt module is mocked with `_mockPrompt` below. const _mockPrompt = vi.fn(() => Promise.resolve(true)); const _fakeLog: { - prompt: typeof _mockPrompt; warn: ReturnType; info: ReturnType; error: ReturnType; debug: ReturnType; withTag: () => typeof _fakeLog; } = { - prompt: _mockPrompt, warn: vi.fn(_noop), info: vi.fn(_noop), error: vi.fn(_noop), @@ -59,6 +57,8 @@ vi.mock("node:tty", () => ({ default: ttyExports, })); +vi.mock("../../../src/lib/prompt.js", () => ({ prompt: mockPrompt })); + vi.mock("../../../src/lib/logger.js", () => ({ logger: fakeLog, setLogLevel: vi.fn(noop), diff --git a/packages/cli/test/commands/project/delete.test.ts b/packages/cli/test/commands/project/delete.test.ts index 73456c37f..574e791e1 100644 --- a/packages/cli/test/commands/project/delete.test.ts +++ b/packages/cli/test/commands/project/delete.test.ts @@ -4,8 +4,8 @@ * Tests for the project delete command in src/commands/project/delete.ts. * Uses spyOn to mock api-client and resolve-target for the func() body * without real HTTP calls or database access. The interactive type-out - * confirmation tests use vi.mock() on node:tty and the logger module - * to control the prompt. + * confirmation tests use vi.mock() on node:tty and the prompt module + * to control the answer. */ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; @@ -28,13 +28,12 @@ const { mockIsatty, ttyExports, noop, mockPrompt, fakeLog } = vi.hoisted(() => { // intentional no-op } - // Mock the logger to intercept the .prompt() call made by the module-scoped - // `log = logger.withTag("project.delete")` inside delete.ts. + // Stub the logger module-scoped as `log = logger.withTag("project.delete")` + // in delete.ts; the prompt module is mocked with `_mockPrompt` below. const _mockPrompt = vi.fn((): Promise => Promise.resolve("acme-corp/my-app"), ); const _fakeLog: { - prompt: typeof _mockPrompt; info: ReturnType; warn: ReturnType; error: ReturnType; @@ -42,7 +41,6 @@ const { mockIsatty, ttyExports, noop, mockPrompt, fakeLog } = vi.hoisted(() => { success: ReturnType; withTag: () => typeof _fakeLog; } = { - prompt: _mockPrompt, info: vi.fn(_noop), warn: vi.fn(_noop), error: vi.fn(_noop), @@ -65,6 +63,8 @@ vi.mock("node:tty", () => ({ default: ttyExports, })); +vi.mock("../../../src/lib/prompt.js", () => ({ prompt: mockPrompt })); + vi.mock("../../../src/lib/logger.js", () => ({ logger: fakeLog, setLogLevel: vi.fn(noop), @@ -411,8 +411,8 @@ describe("project delete", () => { * Tests for the interactive type-out confirmation prompt. * * These rely on `vi.mock()` at the top of this file to stub `node:tty` - * (so `isatty(0)` returns true) and the logger (so `.prompt()` returns a - * controllable value). + * (so `isatty(0)` returns true) and the prompt module (so `prompt()` + * returns a controllable value). */ describe("project delete — interactive confirmation", () => { let getProjectSpy: ReturnType; diff --git a/packages/cli/test/lib/logger.test.ts b/packages/cli/test/lib/logger.test.ts index 98305916e..25f35bc3a 100644 --- a/packages/cli/test/lib/logger.test.ts +++ b/packages/cli/test/lib/logger.test.ts @@ -1,82 +1,25 @@ /** - * Unit tests for the logger module. + * Unit tests for the CLI's logger configuration. * - * Tests parseLogLevel, getEnvLogLevel, setLogLevel (with withTag propagation), - * attachSentryReporter, and the logger instance configuration. + * Tests getEnvLogLevel, setLogLevel (with withTag propagation), + * attachSentryReporter, and the library-mode env. The logger itself is tested + * in packages/logger. */ -import { createConsolaReporter } from "@sentry/node"; +import { sentryReporter } from "@sentry/logger"; +import { logger as sentryLogger } from "@sentry/node"; import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { withEnv } from "../../src/lib/env.js"; import { attachSentryReporter, getEnvLogLevel, LOG_LEVEL_ENV_VAR, LOG_LEVEL_NAMES, - type LogObject, logger, - parseLogLevel, printLine, setLogLevel, } from "../../src/lib/logger.js"; -const clack = vi.hoisted(() => ({ - confirm: vi.fn(), - text: vi.fn(), -})); - -vi.mock("@clack/prompts", () => clack); - -describe("parseLogLevel", () => { - test("maps known level names to correct numeric values", () => { - expect(parseLogLevel("error")).toBe(0); - expect(parseLogLevel("warn")).toBe(1); - expect(parseLogLevel("log")).toBe(2); - expect(parseLogLevel("info")).toBe(3); - expect(parseLogLevel("debug")).toBe(4); - expect(parseLogLevel("trace")).toBe(5); - }); - - test("is case-insensitive", () => { - expect(parseLogLevel("ERROR")).toBe(0); - expect(parseLogLevel("Debug")).toBe(4); - expect(parseLogLevel("TRACE")).toBe(5); - expect(parseLogLevel("Warn")).toBe(1); - }); - - test("trims whitespace", () => { - expect(parseLogLevel(" debug ")).toBe(4); - expect(parseLogLevel("\ttrace\n")).toBe(5); - }); - - test("returns default (info=3) for unrecognized values", () => { - expect(parseLogLevel("verbose")).toBe(3); - expect(parseLogLevel("")).toBe(3); - expect(parseLogLevel("unknown")).toBe(3); - expect(parseLogLevel("42")).toBe(3); - }); -}); - -describe("LOG_LEVEL_NAMES", () => { - test("contains all expected level names with index = numeric level", () => { - expect(LOG_LEVEL_NAMES).toEqual([ - "error", // 0 - "warn", // 1 - "log", // 2 - "info", // 3 - "debug", // 4 - "trace", // 5 - ]); - }); - - test("all names are valid for parseLogLevel", () => { - for (const name of LOG_LEVEL_NAMES) { - const level = parseLogLevel(name); - expect(level).toBeGreaterThanOrEqual(0); - expect(level).toBeLessThanOrEqual(5); - } - }); -}); - describe("LOG_LEVEL_ENV_VAR", () => { test("is SENTRY_LOG_LEVEL", () => { expect(LOG_LEVEL_ENV_VAR).toBe("SENTRY_LOG_LEVEL"); @@ -280,150 +223,39 @@ describe("attachSentryReporter", () => { test("can be called without error", () => { // attachSentryReporter is idempotent and safe to call even when // Sentry is not initialized (it catches errors internally) - expect(() => attachSentryReporter(createConsolaReporter())).not.toThrow(); + expect(() => + attachSentryReporter(sentryReporter(sentryLogger)), + ).not.toThrow(); }); test("is idempotent (second call is a no-op)", () => { // First call may or may not succeed depending on Sentry state - attachSentryReporter(createConsolaReporter()); + attachSentryReporter(sentryReporter(sentryLogger)); // Second call should be a no-op due to internal guard - expect(() => attachSentryReporter(createConsolaReporter())).not.toThrow(); - }); -}); - -describe("reporters", () => { - let originalLevel: number; - let records: LogObject[]; - let stderr: ReturnType; - - beforeEach(() => { - originalLevel = logger.level; - records = []; - stderr = vi.spyOn(process.stderr, "write").mockImplementation(() => true); - }); - - afterEach(() => { - setLogLevel(originalLevel); - stderr.mockRestore(); - }); - - test("receive consola-shaped records that Sentry's reporter reads", () => { - // The level and reporter list are shared, so a reporter added through the - // root logger also sees records from scoped loggers. - logger.addReporter({ log: (logObj) => records.push(logObj) }); - const scoped = logger.withTag("upgrade").withTag("delta"); - setLogLevel(3); - - scoped.warn("Patch %s failed", "0.13.0"); - scoped.debug("dropped above the level"); - - expect(records).toEqual([ - { - date: expect.any(Date), - args: ["Patch %s failed", "0.13.0"], - type: "warn", - level: 1, - tag: "upgrade:delta", - }, - ]); + expect(() => + attachSentryReporter(sentryReporter(sentryLogger)), + ).not.toThrow(); }); }); describe("stderr output", () => { - const ENV_KEYS = ["CI", "TEST", "NODE_ENV", "NO_COLOR", "FORCE_COLOR"]; - let savedEnv: Record; - let stderr: ReturnType; - - /** Text written to stderr so far. */ - const written = () => - stderr.mock.calls.map((call) => String(call[0])).join(""); - - beforeEach(() => { - savedEnv = Object.fromEntries( - ENV_KEYS.map((key) => [key, process.env[key]]), - ); - stderr = vi.spyOn(process.stderr, "write").mockImplementation(() => true); - }); - - afterEach(() => { - for (const key of ENV_KEYS) { - if (savedEnv[key] === undefined) { - delete process.env[key]; - } else { - process.env[key] = savedEnv[key]; - } - } - stderr.mockRestore(); - }); - - test("writes plain [type] [tag] lines in tests and CI", () => { - process.env.CI = "true"; - logger.withTag("auth").info("Logged in as %s", "jane"); - expect(written()).toBe("[info] [auth] Logged in as jane\n"); - }); - - test("writes an error's message, stack, and cause but not its other properties", () => { - process.env.CI = "true"; - const error = Object.assign( - new Error("Request failed", { cause: new Error("socket hang up") }), - { response: { headers: { authorization: "Bearer secret" } } }, - ); - - logger.warn("Upload failed:", error); - - const output = written(); - expect(output).toMatch(/^\[warn\] Upload failed: Request failed\n {2}at /); - expect(output).toContain("\n\n [cause]: socket hang up\n at "); - expect(output).not.toContain("secret"); - }); - - test("writes icons, badges, and inline code in the interactive layout", () => { - for (const key of ["CI", "TEST"]) { - delete process.env[key]; + test("reads the library-mode env instead of process.env", () => { + const stderr = vi + .spyOn(process.stderr, "write") + .mockImplementation(() => true); + try { + withEnv({ NODE_ENV: "production", NO_COLOR: "1" }, () => { + logger.withTag("auth").success("Run `sentry auth status`"); + }); + withEnv({ CI: "true" }, () => { + logger.withTag("auth").info("Logged in"); + }); + expect(stderr.mock.calls.map((call) => String(call[0]))).toEqual([ + "[auth] ✔ Run sentry auth status\n", + "[info] [auth] Logged in\n", + ]); + } finally { + stderr.mockRestore(); } - process.env.NODE_ENV = "production"; - process.env.NO_COLOR = "1"; - - logger.withTag("auth").success("Run `sentry auth status`"); - logger.warn("Token expires soon"); - - // Non-TTY stderr has no columns, so the tag leads the line. - expect(written()).toBe( - "[auth] ✔ Run sentry auth status\n\n WARN Token expires soon\n\n", - ); - }); -}); - -describe("prompt", () => { - test("returns clack's cancel symbol instead of the initial value on Ctrl+C", async () => { - const cancel = Symbol("clack:cancel"); - clack.confirm.mockResolvedValue(cancel); - - const answer = await logger.prompt("Import from .sentryclirc?", { - type: "confirm", - initial: true, - }); - - expect(clack.confirm).toHaveBeenCalledWith({ - message: "Import from .sentryclirc?", - initialValue: true, - }); - expect(answer).toBe(cancel); - }); - - test("passes text prompt options through to clack", async () => { - clack.text.mockResolvedValue("my-project"); - - const answer = await logger - .withTag("delete") - .prompt("Type the project slug:", { type: "text", placeholder: "slug" }); - - expect(clack.text).toHaveBeenCalledWith({ - message: "Type the project slug:", - placeholder: "slug", - initialValue: undefined, - defaultValue: undefined, - }); - expect(answer).toBe("my-project"); }); }); diff --git a/packages/cli/test/lib/prompt.test.ts b/packages/cli/test/lib/prompt.test.ts new file mode 100644 index 000000000..a156d8abe --- /dev/null +++ b/packages/cli/test/lib/prompt.test.ts @@ -0,0 +1,46 @@ +/** Unit tests for the CLI's `@clack/prompts` wrapper. */ + +import { describe, expect, test, vi } from "vitest"; +import { prompt } from "../../src/lib/prompt.js"; + +const clack = vi.hoisted(() => ({ + confirm: vi.fn(), + text: vi.fn(), +})); + +vi.mock("@clack/prompts", () => clack); + +describe("prompt", () => { + test("returns clack's cancel symbol instead of the initial value on Ctrl+C", async () => { + const cancel = Symbol("clack:cancel"); + clack.confirm.mockResolvedValue(cancel); + + const answer = await prompt("Import from .sentryclirc?", { + type: "confirm", + initial: true, + }); + + expect(clack.confirm).toHaveBeenCalledWith({ + message: "Import from .sentryclirc?", + initialValue: true, + }); + expect(answer).toBe(cancel); + }); + + test("passes text prompt options through to clack", async () => { + clack.text.mockResolvedValue("my-project"); + + const answer = await prompt("Type the project slug:", { + type: "text", + placeholder: "slug", + }); + + expect(clack.text).toHaveBeenCalledWith({ + message: "Type the project slug:", + placeholder: "slug", + initialValue: undefined, + defaultValue: undefined, + }); + expect(answer).toBe("my-project"); + }); +}); diff --git a/packages/cli/test/lib/seer-trial.test.ts b/packages/cli/test/lib/seer-trial.test.ts index bb3d73647..0f5283243 100644 --- a/packages/cli/test/lib/seer-trial.test.ts +++ b/packages/cli/test/lib/seer-trial.test.ts @@ -9,11 +9,14 @@ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +vi.mock("../../src/lib/prompt.js", () => ({ prompt: vi.fn() })); + // oxlint-disable-next-line sentry-cli/no-namespace-import -- needed for spyOn mocking import * as apiClient from "../../src/lib/api-client.js"; import { SeerError } from "../../src/lib/errors.js"; // oxlint-disable-next-line sentry-cli/no-namespace-import -- needed for spyOn mocking import * as loggerModule from "../../src/lib/logger.js"; +import { prompt } from "../../src/lib/prompt.js"; import { isTrialEligible, promptAndStartTrial, @@ -55,7 +58,7 @@ describe("isTrialEligible", () => { describe("promptAndStartTrial", () => { let getProductTrialsSpy: ReturnType; let startProductTrialSpy: ReturnType; - let loggerPromptSpy: ReturnType; + let promptSpy: ReturnType; let loggerWithTagSpy: ReturnType; let logDebugCalls: string[]; let logInfoCalls: string[]; @@ -85,9 +88,10 @@ describe("promptAndStartTrial", () => { .mockResolvedValue(undefined); // Mock the logger's withTag to return an object with all needed methods - loggerPromptSpy = vi.spyOn({ prompt: async () => false }, "prompt"); + promptSpy = vi.mocked(prompt); + promptSpy.mockReset(); + promptSpy.mockResolvedValue(false); const mockLogInstance = { - prompt: loggerPromptSpy, debug: (...args: unknown[]) => { logDebugCalls.push(args.map(String).join(" ")); }, @@ -122,7 +126,7 @@ describe("promptAndStartTrial", () => { expect(result).toBe(false); expect(getProductTrialsSpy).toHaveBeenCalledWith("test-org"); // Should not prompt if no trial available - expect(loggerPromptSpy).not.toHaveBeenCalled(); + expect(promptSpy).not.toHaveBeenCalled(); // Should show expired/upgrade message expect( logInfoCalls.some((m) => m.includes("No Seer trial available")), @@ -140,7 +144,7 @@ describe("promptAndStartTrial", () => { const result = await promptAndStartTrial("test-org", "no_budget"); expect(result).toBe(false); - expect(loggerPromptSpy).not.toHaveBeenCalled(); + expect(promptSpy).not.toHaveBeenCalled(); }); test("returns false when seer trial is already started", async () => { @@ -151,7 +155,7 @@ describe("promptAndStartTrial", () => { const result = await promptAndStartTrial("test-org", "no_budget"); expect(result).toBe(false); - expect(loggerPromptSpy).not.toHaveBeenCalled(); + expect(promptSpy).not.toHaveBeenCalled(); }); test("returns false when trial check throws (graceful degradation)", async () => { @@ -160,12 +164,12 @@ describe("promptAndStartTrial", () => { const result = await promptAndStartTrial("test-org", "no_budget"); expect(result).toBe(false); - expect(loggerPromptSpy).not.toHaveBeenCalled(); + expect(promptSpy).not.toHaveBeenCalled(); }); test("returns false when user declines the prompt", async () => { getProductTrialsSpy.mockResolvedValue([MOCK_SEER_TRIAL]); - loggerPromptSpy.mockResolvedValue(false); + promptSpy.mockResolvedValue(false); const result = await promptAndStartTrial("test-org", "no_budget"); @@ -179,7 +183,7 @@ describe("promptAndStartTrial", () => { test("returns false when user cancels with Ctrl+C (Symbol)", async () => { getProductTrialsSpy.mockResolvedValue([MOCK_SEER_TRIAL]); // The prompt returns Symbol(clack:cancel) on Ctrl+C - loggerPromptSpy.mockResolvedValue(Symbol("clack:cancel")); + promptSpy.mockResolvedValue(Symbol("clack:cancel")); const result = await promptAndStartTrial("test-org", "no_budget"); @@ -189,7 +193,7 @@ describe("promptAndStartTrial", () => { test("starts trial and returns true on confirmation", async () => { getProductTrialsSpy.mockResolvedValue([MOCK_SEER_TRIAL]); - loggerPromptSpy.mockResolvedValue(true); + promptSpy.mockResolvedValue(true); startProductTrialSpy.mockResolvedValue(undefined); const result = await promptAndStartTrial("test-org", "no_budget"); @@ -209,7 +213,7 @@ describe("promptAndStartTrial", () => { { ...MOCK_SEER_TRIAL, category: "seerAutofix" }, MOCK_SEER_TRIAL, ]); - loggerPromptSpy.mockResolvedValue(true); + promptSpy.mockResolvedValue(true); startProductTrialSpy.mockResolvedValue(undefined); const result = await promptAndStartTrial("test-org", "no_budget"); @@ -222,7 +226,7 @@ describe("promptAndStartTrial", () => { getProductTrialsSpy.mockResolvedValue([ { ...MOCK_SEER_TRIAL, category: "seerAutofix" }, ]); - loggerPromptSpy.mockResolvedValue(true); + promptSpy.mockResolvedValue(true); startProductTrialSpy.mockResolvedValue(undefined); const result = await promptAndStartTrial("test-org", "no_budget"); @@ -236,7 +240,7 @@ describe("promptAndStartTrial", () => { test("returns false when trial start fails and shows settings link", async () => { getProductTrialsSpy.mockResolvedValue([MOCK_SEER_TRIAL]); - loggerPromptSpy.mockResolvedValue(true); + promptSpy.mockResolvedValue(true); startProductTrialSpy.mockRejectedValue(new Error("API error")); const result = await promptAndStartTrial("test-org", "no_budget"); @@ -255,7 +259,7 @@ describe("promptAndStartTrial", () => { test("shows correct context message for not_enabled reason", async () => { getProductTrialsSpy.mockResolvedValue([MOCK_SEER_TRIAL]); - loggerPromptSpy.mockResolvedValue(false); + promptSpy.mockResolvedValue(false); await promptAndStartTrial("test-org", "not_enabled"); @@ -266,7 +270,7 @@ describe("promptAndStartTrial", () => { test("shows correct context message for no_budget reason", async () => { getProductTrialsSpy.mockResolvedValue([MOCK_SEER_TRIAL]); - loggerPromptSpy.mockResolvedValue(false); + promptSpy.mockResolvedValue(false); await promptAndStartTrial("test-org", "no_budget"); @@ -277,12 +281,12 @@ describe("promptAndStartTrial", () => { test("includes trial length in prompt message", async () => { getProductTrialsSpy.mockResolvedValue([MOCK_SEER_TRIAL]); - loggerPromptSpy.mockResolvedValue(false); + promptSpy.mockResolvedValue(false); await promptAndStartTrial("test-org", "no_budget"); - expect(loggerPromptSpy).toHaveBeenCalled(); - const promptMessage = loggerPromptSpy.mock.calls[0]?.[0] as string; + expect(promptSpy).toHaveBeenCalled(); + const promptMessage = promptSpy.mock.calls[0]?.[0] as string; expect(promptMessage).toContain("14-day"); }); @@ -290,12 +294,12 @@ describe("promptAndStartTrial", () => { getProductTrialsSpy.mockResolvedValue([ { ...MOCK_SEER_TRIAL, lengthDays: null }, ]); - loggerPromptSpy.mockResolvedValue(false); + promptSpy.mockResolvedValue(false); await promptAndStartTrial("test-org", "no_budget"); - expect(loggerPromptSpy).toHaveBeenCalled(); - const promptMessage = loggerPromptSpy.mock.calls[0]?.[0] as string; + expect(promptSpy).toHaveBeenCalled(); + const promptMessage = promptSpy.mock.calls[0]?.[0] as string; expect(promptMessage).not.toContain("day"); expect(promptMessage).toContain("free Seer trial"); }); diff --git a/packages/cli/test/lib/timezone.test.ts b/packages/cli/test/lib/timezone.test.ts index 24a468df4..f8a9e6549 100644 --- a/packages/cli/test/lib/timezone.test.ts +++ b/packages/cli/test/lib/timezone.test.ts @@ -1,8 +1,8 @@ /** * Tests for src/lib/timezone.ts * - * Covers the pure helpers (`formatLogTime`, `fixedOffsetZone`) and the - * decision logic of `initTimezone`. The bug being guarded against: SEA + * Covers the pure helper `fixedOffsetZone` and the decision logic of + * `initTimezone`. The bug being guarded against: SEA * binaries fall back to UTC when they cannot resolve the OS zone, so log * timestamps appear hours off from the user's local clock. */ @@ -11,54 +11,10 @@ import { afterEach, describe, expect, test, vi } from "vitest"; import { detectOsTimezone, fixedOffsetZone, - formatLogTime, initTimezone, runtimeTimezone, } from "../../src/lib/timezone.js"; -describe("formatLogTime", () => { - afterEach(() => { - vi.restoreAllMocks(); - }); - - test("renders 24-hour HH:MM:SS in local time", () => { - // 2024-01-15T14:28:59Z. Force a +480 (US/Pacific standard, UTC-8) offset so - // the local wall-clock is 06:28:59 regardless of the host machine's zone. - vi.spyOn(Date.prototype, "getTimezoneOffset").mockReturnValue(480); - const date = new Date("2024-01-15T14:28:59.000Z"); - expect(formatLogTime(date)).toBe("06:28:59"); - }); - - test("UTC offset renders the UTC wall-clock", () => { - vi.spyOn(Date.prototype, "getTimezoneOffset").mockReturnValue(0); - const date = new Date("2024-01-15T14:28:59.000Z"); - expect(formatLogTime(date)).toBe("14:28:59"); - }); - - test("east-of-UTC (negative offset) shifts forward", () => { - // Berlin winter is UTC+1 → getTimezoneOffset() === -60. - vi.spyOn(Date.prototype, "getTimezoneOffset").mockReturnValue(-60); - const date = new Date("2024-01-15T23:30:00.000Z"); - // 23:30Z + 1h = 00:30 next day → wraps to 00:30. - expect(formatLogTime(date)).toBe("00:30:00"); - }); - - test("zero-pads single-digit components", () => { - vi.spyOn(Date.prototype, "getTimezoneOffset").mockReturnValue(0); - const date = new Date("2024-01-15T01:02:03.000Z"); - expect(formatLogTime(date)).toBe("01:02:03"); - }); - - test("output is always 8 chars in HH:MM:SS shape", () => { - vi.spyOn(Date.prototype, "getTimezoneOffset").mockReturnValue(0); - for (let h = 0; h < 24; h++) { - const date = new Date(Date.UTC(2024, 0, 1, h, h % 60, (h * 2) % 60)); - const out = formatLogTime(date); - expect(out).toMatch(/^\d{2}:\d{2}:\d{2}$/); - } - }); -}); - describe("fixedOffsetZone", () => { test("west of UTC (positive offset) → Etc/GMT+N", () => { // US/Pacific standard is +480 min behind UTC → UTC-8. diff --git a/packages/cli/test/script/cli-startup.test.ts b/packages/cli/test/script/cli-startup.test.ts index adf72cbb1..243b143de 100644 --- a/packages/cli/test/script/cli-startup.test.ts +++ b/packages/cli/test/script/cli-startup.test.ts @@ -6,8 +6,12 @@ import { expect, test } from "vitest"; const ANY_IMPORT = /.*/; -/** Hostname and origin predicates are pure and have no startup side effects. */ +/** + * Hostname and origin predicates and the logger are pure and have no startup + * side effects. + */ const PURE_SENTRY_IMPORTS = [ + "@sentry/logger", "@sentry/toolkit-core/sentry-host", "@sentry/toolkit-core/sentry-origin", ]; diff --git a/packages/logger/LICENSE b/packages/logger/LICENSE new file mode 100644 index 000000000..09e7c6f7d --- /dev/null +++ b/packages/logger/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Functional Software, Inc. dba Sentry + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/logger/README.md b/packages/logger/README.md new file mode 100644 index 000000000..5811a5245 --- /dev/null +++ b/packages/logger/README.md @@ -0,0 +1,223 @@ +# @sentry/logger + +A small structured logger for Node.js and Bun with no dependencies. Each log +call becomes a record: level, tag, message arguments, and attributes. The +logger hands each record to its reporters, which decide where it goes: + +- `fancyReporter`: consola-style terminal output for people. +- `jsonReporter`: one JSON object per line, for log collectors. +- `logfmtReporter`: one `key=value` line per call, for log collectors. +- `sentryReporter`: [Sentry structured logs](https://docs.sentry.io/product/explore/logs/), + without a dependency on any Sentry package. + +```sh +npm install @sentry/logger +``` + +## Why another logger? + +The [Sentry CLI](https://github.com/getsentry/toolkit/tree/main/packages/cli) +used `consola`, which was the largest third-party package in the bundle that +shell completion loads on every TAB press. Replacing it cut that bundle from +about 322 KB to 249 KB and halved its load time. The CLI needed levels, tags, +consola's terminal layout, and Sentry log forwarding. No logger we measured +did that for less. Bundled and minified, `@logtape/logtape` is about 68 KB, +`tslog` 68 KB, `pino` 61 KB, and `signale` 49 KB; `loglevel` is about 3 KB +but has no tags and no reporter hook. `@sentry/logger` is about 5 KB (2.5 KB +gzipped) with all of its reporters. + +Sentry forwarding takes the SDK's `logger` as an argument instead of importing +it, so the logger stays dependency-free and never loads the SDK on its own. + +## Usage + +```ts +import { createLogger } from "@sentry/logger"; + +const logger = createLogger(); + +logger.info("Checking for updates..."); +logger.success("Upgrade complete!"); +logger.warn("Token expires soon"); + +const log = logger.withTag("upgrade"); +log.debug("Resolved %s → %s", "0.12.0", "0.13.0"); // hidden at the default level +``` + +Log methods take `console.log`-style arguments, `%s` placeholders included: +`error`, `warn`, `log`, `info`, `success`, `fail`, `start`, `debug`, and +`trace`. They stay bound, so `const { info } = logger` works. + +Without `reporters`, a logger writes with one `fancyReporter()` on stderr. + +### Tags and attributes + +`withTag()` and `withAttributes()` return scoped loggers. Nested tags join with +`:`, and attributes from each scope are merged: + +```ts +const log = logger.withTag("auth").withAttributes({ user: "jane" }); +log.withTag("token").info("Refreshed"); // tag "auth:token", user "jane" +``` + +Attribute values are strings, numbers, booleans, or objects of them. The JSON +reporter writes objects nested. The logfmt and Sentry reporters expand them +into dotted keys, so `{ http: { status_code: 404 } }` becomes +`http.status_code=404`. Expansion stops five levels down, where an object is +written as `[Object]`. Scopes merge attributes shallowly: a nested object from +`withAttributes()` replaces the one with the same key from the parent. + +### Levels + +| Level | Value | Shows | +| ----- | ----- | ------------------------------------------ | +| error | 0 | error | +| warn | 1 | + warn | +| log | 2 | + log | +| info | 3 | + info, success, start, fail (the default) | +| debug | 4 | + debug | +| trace | 5 | + trace | + +A root logger and every logger scoped from it share one level and one reporter +list. Setting `logger.level` later also changes the scoped loggers that +modules created at import time. + +```ts +import { createLogger, parseLogLevel } from "@sentry/logger"; + +const logger = createLogger(); +logger.level = parseLogLevel(process.env.MY_TOOL_LOG_LEVEL ?? "info"); +``` + +`parseLogLevel` is case-insensitive and returns `info` (3) for an unknown name. +`LOG_LEVEL_NAMES` lists the names; the index of each name is its level. + +## Reporters + +Pass reporters to `createLogger()`, or add one later with `addReporter()`: + +```ts +import { createLogger, jsonReporter } from "@sentry/logger"; + +const logger = createLogger({ + level: 3, + reporters: [jsonReporter({ stream: process.stdout })], +}); +``` + +Every reporter that writes text writes to `process.stderr` unless you pass a +`stream`: any object with a `write(chunk: string)` method. + +### Fancy + +`fancyReporter()` renders consola's "fancy" layout: icons, colors, +`WARN`/`ERROR` badges, and a right-aligned `tag HH:MM:SS`. When `CI` or `TEST` +is set, or `NODE_ENV` is `test`, lines are plain: `[info] [upgrade] message`. +`NO_COLOR` turns colors off and `FORCE_COLOR` turns them on without a TTY. + +```ts +fancyReporter({ + stream: process.stderr, + env: () => process.env, + formatTime: (date) => date.toISOString(), +}); +``` + +- `env`: returns the environment that picks the layout and colors. It is + called on each log call. Pass a function when your tool runs with an + environment of its own, such as a library mode that must not read the host's + `process.env`. +- `formatTime`: renders the time next to the tag. Defaults to + `formatLocalTime`, a 24-hour `HH:MM:SS` in local time that does not depend on + ICU, so it stays correct in Node single-executable builds. + +Errors render as their message, stack frames, and causes. Their other +properties, which can hold request or response details, are left out. + +### JSON and logfmt + +```ts +createLogger({ reporters: [jsonReporter()] }) + .withTag("auth") + .info("Logged in"); +// {"time":"2026-01-15T14:28:59.000Z","level":"info","tag":"auth","msg":"Logged in"} + +createLogger({ reporters: [logfmtReporter()] }) + .withTag("auth") + .info("Logged in"); +// time=2026-01-15T14:28:59.000Z level=info tag=auth msg="Logged in" +``` + +Each line has `time` (ISO 8601), `level`, `type` when it differs from the level +(such as `success`), `tag` when set, `msg`, and then the attributes. An +attribute named like one of those fields is dropped. + +### Sentry + +`sentryReporter()` takes the SDK's `logger`, so it works with `@sentry/node`, +`@sentry/bun`, `@sentry/browser`, and the rest without depending on any of +them: + +```ts +import * as Sentry from "@sentry/node"; +import { sentryReporter } from "@sentry/logger"; + +Sentry.init({ dsn: "...", enableLogs: true }); +logger.addReporter(sentryReporter(Sentry.logger)); +``` + +Each call is sent at the matching Sentry severity (`fail` as `error`; `log`, +`success`, and `start` as `info`) with the record's attributes (objects +expanded into dotted keys) plus +`logger.tag` (when set), `logger.type`, and +`sentry.origin: "auto.log.sentry_logger"`. Errors contribute their message and +causes, not their stacks. + +### Your own + +A reporter is any object with a `log(record)` method. Records keep consola's +`{ date, args, type, level, tag }` shape and add `attributes`: + +```ts +import { createLogger, type LogRecord } from "@sentry/logger"; + +const records: LogRecord[] = []; +const logger = createLogger({ reporters: [{ log: (r) => records.push(r) }] }); +``` + +## Performance + +`pnpm --filter @sentry/logger run bench` times each reporter against the +cheapest code that does the same job, each case in its own Node process. +Medians from one run on Node 22, linux-x64: + +| Case | Baseline | Logger | +| ---------------------------------------- | ------------------------------------------------- | ------- | +| Call below the level (`debug` at `info`) | 11 ns (no-op function call) | 1 ns | +| Call that reaches a no-op reporter | 11 ns (no-op function call) | 104 ns | +| Fancy line in CI | 70 ns (`util.format` + write) | 316 ns | +| Fancy line with colors and the time | 70 ns (`util.format` + write) | 1.7 µs | +| JSON line | 1.5 µs (`toISOString` + `JSON.stringify` + write) | 1.0 µs | +| logfmt line | 1.5 µs (`toISOString` + `JSON.stringify` + write) | 1.7 µs | +| logfmt line with a nested attribute | 1.5 µs (same as above) | 1.8 µs | +| Sentry structured log | 78 ns (`util.format` + `Sentry.logger.info`) | 291 ns | +| Startup | 28.0 ms (import `node:util`, `node:path`) | 28.3 ms | + +Calls below the level cost nothing measurable. A call that is recorded +allocates its record and `Date`, about 100 ns. Formatting costs +hundreds of nanoseconds, or about 1.7 µs for a colored line that also renders +the local time and right-aligns it. On the same machine, writing one line to +`process.stderr` (a file) takes about 1.2 µs, so formatting costs the same +order as the write it feeds. Results vary by machine; compare runs on the same +one. + +## Development + +The package lives in [getsentry/toolkit](https://github.com/getsentry/toolkit) +under `packages/logger`. Workspace packages import its TypeScript source +directly; `pnpm --filter @sentry/logger run build` writes the `dist/` files +that the published package's `exports` point to. +`pnpm --filter @sentry/logger run test:package` packs the package and runs the +consumer project in `test/package/consumer` against the tarball, as ES modules +and as CommonJS. Releases go through the repository's Release workflow with the +`logger` product. diff --git a/packages/logger/bench/run.mjs b/packages/logger/bench/run.mjs new file mode 100644 index 000000000..bfdd529f5 --- /dev/null +++ b/packages/logger/bench/run.mjs @@ -0,0 +1,210 @@ +// Measures what the logger adds on top of the cheapest code that does the same +// job, using the built package in dist/. Run with +// `pnpm --filter @sentry/logger run bench`. +// +// Each case runs in its own process, so one case's JIT feedback cannot slow +// another. A baseline does the unavoidable part of the work: nothing for a +// call that is filtered out or reaches a no-op reporter, and `util.format`, +// `toISOString()`, and `JSON.stringify` plus a stream write for reporters that +// write text. Times are medians of timed samples, in nanoseconds per call. + +import { execFileSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import { formatWithOptions } from "node:util"; + +const SAMPLES = 9; +const SAMPLE_MS = 100; +const STARTUP_RUNS = 21; +const ENTRY = new URL("../dist/index.js", import.meta.url).href; + +const groups = [ + ["filtered out (debug at the info level)", ["noop", "debug"]], + ["no-op reporter", ["noop", "noopReporter"]], + ["fancy line, CI", ["formatWrite", "fancyPlain"]], + ["fancy line, colors", ["formatWrite", "fancyColor"]], + ["JSON line", ["jsonWrite", "json"]], + ["logfmt line", ["jsonWrite", "logfmt"]], + ["logfmt line, nested attribute", ["jsonWrite", "logfmtNested"]], + ["Sentry structured log", ["formatSentry", "sentry"]], +]; + +const labels = { + noop: "baseline: no-op function", + debug: "logger.debug()", + noopReporter: "logger.info()", + formatWrite: "baseline: util.format + write", + fancyPlain: "fancyReporter", + fancyColor: "fancyReporter", + jsonWrite: "baseline: toISOString + JSON.stringify + write", + json: "jsonReporter", + logfmt: "logfmtReporter", + logfmtNested: "logfmtReporter", + formatSentry: "baseline: util.format + Sentry.logger.info", + sentry: "sentryReporter", +}; + +/** Build the function a child process times, for one case name. */ +async function setUp(name) { + const { + createLogger, + fancyReporter, + jsonReporter, + logfmtReporter, + sentryReporter, + } = await import(ENTRY); + let sink; + const stream = { write: (chunk) => (sink = chunk) }; + const send = (message, attributes) => (sink = attributes ?? message); + const sentryLogger = { + trace: send, + debug: send, + info: send, + warn: send, + error: send, + }; + const attributes = { user: "jane", "http.status_code": 200 }; + const logger = (reporter) => + createLogger({ reporters: [reporter] }) + .withTag("bench") + .withAttributes(attributes); + const format = () => + formatWithOptions({ colors: false }, "Logged in as %s", "jane"); + + switch (name) { + case "noop": { + const noop = (...args) => (sink = args); + return () => noop("Logged in as %s", "jane"); + } + case "debug": { + const log = logger({ log: (record) => (sink = record) }); + return () => log.debug("Logged in as %s", "jane"); + } + case "noopReporter": { + const log = logger({ log: (record) => (sink = record) }); + return () => log.info("Logged in as %s", "jane"); + } + case "formatWrite": + return () => stream.write(`${format()}\n`); + case "fancyPlain": { + const log = logger(fancyReporter({ stream, env: () => ({ CI: "1" }) })); + return () => log.info("Logged in as %s", "jane"); + } + case "fancyColor": { + const colorStream = { ...stream, columns: 120 }; + const env = () => ({ FORCE_COLOR: "1" }); + const log = logger(fancyReporter({ stream: colorStream, env })); + return () => log.info("Logged in as %s", "jane"); + } + case "jsonWrite": + return () => + stream.write( + `${JSON.stringify({ + time: new Date().toISOString(), + level: "info", + tag: "bench", + msg: format(), + ...attributes, + })}\n`, + ); + case "json": { + const log = logger(jsonReporter({ stream })); + return () => log.info("Logged in as %s", "jane"); + } + case "logfmt": { + const log = logger(logfmtReporter({ stream })); + return () => log.info("Logged in as %s", "jane"); + } + case "logfmtNested": { + const log = createLogger({ reporters: [logfmtReporter({ stream })] }) + .withTag("bench") + .withAttributes({ user: "jane", http: { status_code: 200 } }); + return () => log.info("Logged in as %s", "jane"); + } + case "formatSentry": + return () => + sentryLogger.info(format(), { ...attributes, "logger.tag": "bench" }); + case "sentry": { + const log = logger(sentryReporter(sentryLogger)); + return () => log.info("Logged in as %s", "jane"); + } + default: + throw new Error(`Unknown case: ${name}`); + } +} + +function median(values) { + const sorted = [...values].sort((a, b) => a - b); + return sorted[Math.floor(sorted.length / 2)]; +} + +/** Nanoseconds per call of `fn`: the median of timed samples, after a warm-up. */ +function measure(fn) { + const sample = () => { + let calls = 0; + const start = process.hrtime.bigint(); + const deadline = start + BigInt(SAMPLE_MS * 1e6); + let now = start; + while (now < deadline) { + for (let i = 0; i < 1000; i++) { + fn(); + } + calls += 1000; + now = process.hrtime.bigint(); + } + return Number(now - start) / calls; + }; + sample(); + return median(Array.from({ length: SAMPLES }, sample)); +} + +/** Median wall time, in milliseconds, of a fresh `node` running `code`. */ +function startupTime(code) { + return median( + Array.from({ length: STARTUP_RUNS }, () => { + const start = process.hrtime.bigint(); + execFileSync(process.execPath, ["--input-type=module", "-e", code]); + return Number(process.hrtime.bigint() - start) / 1e6; + }), + ); +} + +const [caseName] = process.argv.slice(2); +if (caseName) { + process.stdout.write(String(measure(await setUp(caseName)))); +} else { + const script = fileURLToPath(import.meta.url); + const time = (name) => + Number( + execFileSync(process.execPath, [script, name], { encoding: "utf8" }), + ); + const ns = (value) => `${value.toFixed(value < 10 ? 1 : 0)} ns`; + const rows = []; + for (const [group, [baselineName, name]] of groups) { + const baseline = time(baselineName); + const result = time(name); + rows.push({ + group, + baseline: `${ns(baseline)} (${labels[baselineName].replace("baseline: ", "")})`, + logger: `${ns(result)} (${labels[name]})`, + overhead: `${result >= baseline ? "+" : ""}${ns(result - baseline)}`, + }); + } + + // The baseline imports the Node built-ins the logger uses, which a CLI loads + // anyway, and an empty ES module, which starts the module loader. + const empty = startupTime( + `await import("node:path"); await import("node:util"); await import("data:text/javascript,");`, + ); + const loaded = startupTime( + `const { createLogger } = await import(${JSON.stringify(ENTRY)}); createLogger();`, + ); + rows.push({ + group: "startup", + baseline: `${empty.toFixed(1)} ms (import node:util, node:path)`, + logger: `${loaded.toFixed(1)} ms (import + createLogger())`, + overhead: `${loaded >= empty ? "+" : ""}${(loaded - empty).toFixed(1)} ms`, + }); + + console.log(`Node ${process.version}, ${process.platform}-${process.arch}`); + console.table(rows); +} diff --git a/packages/logger/package.json b/packages/logger/package.json new file mode 100644 index 000000000..cbb12de6a --- /dev/null +++ b/packages/logger/package.json @@ -0,0 +1,72 @@ +{ + "name": "@sentry/logger", + "version": "1.0.0-dev.0", + "description": "A small, dependency-free structured logger with consola-style, JSON, logfmt, and Sentry reporters", + "type": "module", + "license": "MIT", + "author": "Sentry", + "homepage": "https://github.com/getsentry/toolkit/tree/main/packages/logger#readme", + "keywords": [ + "sentry", + "logger", + "logging", + "structured-logging", + "logfmt", + "consola" + ], + "bugs": { + "url": "https://github.com/getsentry/toolkit/issues" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/getsentry/toolkit.git", + "directory": "packages/logger" + }, + "engines": { + "node": ">=18" + }, + "sideEffects": false, + "sentryCi": { + "e2e": "test:package" + }, + "files": [ + "dist", + "LICENSE", + "README.md" + ], + "exports": { + ".": { + "types": "./src/index.ts", + "default": "./src/index.ts" + } + }, + "publishConfig": { + "access": "public", + "exports": { + ".": { + "import": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "require": { + "types": "./dist/index.d.cts", + "default": "./dist/index.cjs" + } + } + } + }, + "scripts": { + "bench": "tsdown && node bench/run.mjs", + "build": "tsdown", + "test": "vitest run", + "test:ci": "vitest run --reporter=default --reporter=junit --outputFile=tests.junit.xml", + "test:package": "bash test/package/check.sh", + "tsc": "tsc --noEmit" + }, + "devDependencies": { + "@types/node": "catalog:", + "tsdown": "catalog:", + "typescript": "catalog:", + "vitest": "catalog:" + } +} diff --git a/packages/logger/src/fields.ts b/packages/logger/src/fields.ts new file mode 100644 index 000000000..fb2c309a5 --- /dev/null +++ b/packages/logger/src/fields.ts @@ -0,0 +1,115 @@ +/** The fields that the JSON and logfmt reporters write, and dotted-key expansion. */ + +import { LOG_LEVEL_NAMES } from "./levels.js"; +import { formatMessage } from "./message.js"; +import type { + LogAttributePrimitive, + LogAttributeValue, + LogRecord, +} from "./types.js"; + +/** Field names the reporters own. Attributes with these names are dropped. */ +const BUILT_IN_FIELDS = new Set(["time", "level", "type", "tag", "msg"]); + +/** The second {@link isoTime} last formatted, and its text before the milliseconds. */ +let cachedSecond = Number.NaN; +let cachedPrefix = ""; + +/** + * `date.toISOString()`, formatting each second only once. `toISOString()` + * costs more than the rest of a JSON line, and log lines mostly share a + * second. + */ +function isoTime(date: Date): string { + const time = date.getTime(); + const second = Math.floor(time / 1000); + if (second !== cachedSecond) { + cachedSecond = second; + // Drop "mmmZ"; years outside 0000-9999 make the rest longer. + cachedPrefix = new Date(second * 1000).toISOString().slice(0, -4); + } + const ms = time - second * 1000; + return `${cachedPrefix}${ms < 10 ? "00" : ms < 100 ? "0" : ""}${ms}Z`; +} + +/** + * A record as an object with ordered fields: `time` (ISO 8601), `level` + * (`error`, `warn`, `log`, `info`, `debug`, or `trace`), `type` when it differs + * from the level (such as `success`), `tag` when set, `msg`, and then the + * record's attributes. + */ +export function recordFields( + record: LogRecord, +): Record { + const level = LOG_LEVEL_NAMES[record.level] ?? "info"; + const fields: Record = { + time: isoTime(record.date), + level, + }; + if (record.type !== level) { + fields.type = record.type; + } + if (record.tag) { + fields.tag = record.tag; + } + fields.msg = formatMessage(record.args, { stack: true }); + const { attributes } = record; + for (const key of Object.keys(attributes)) { + if (BUILT_IN_FIELDS.has(key)) { + continue; + } + setField(fields, key, attributes[key] as LogAttributeValue); + } + return fields; +} + +/** Set `target[key]`, as an own field even when `key` is `__proto__`. */ +function setField(target: Record, key: string, value: T): void { + if (key === "__proto__") { + // Assigning `__proto__` would set the prototype instead of a field. + Object.defineProperty(target, key, { + value, + enumerable: true, + writable: true, + configurable: true, + }); + } else { + target[key] = value; + } +} + +/** + * How many levels of nested objects expand into dotted keys. An object nested + * deeper is written as {@link TRUNCATED}, which also stops reference cycles. + */ +const MAX_ATTRIBUTE_DEPTH = 5; + +/** The value written in place of an object nested too deeply. */ +const TRUNCATED = "[Object]"; + +/** + * Add `value` to `target` under `key`, expanding objects into dotted keys: + * `{ http: { status_code: 404 } }` becomes `http.status_code: 404`. Empty + * objects add nothing. + */ +export function flattenAttribute( + target: Record, + key: string, + value: LogAttributeValue, + depth = 1, +): void { + if (typeof value !== "object" || value === null) { + setField(target, key, value); + } else if (depth > MAX_ATTRIBUTE_DEPTH) { + setField(target, key, TRUNCATED); + } else { + for (const child of Object.keys(value)) { + flattenAttribute( + target, + `${key}.${child}`, + value[child] as LogAttributeValue, + depth + 1, + ); + } + } +} diff --git a/packages/logger/src/index.ts b/packages/logger/src/index.ts new file mode 100644 index 000000000..ec1c5e4b8 --- /dev/null +++ b/packages/logger/src/index.ts @@ -0,0 +1,36 @@ +/** + * A small, dependency-free structured logger for Node.js and Bun. + * + * A logger turns each call into a {@link LogRecord} (level, tag, arguments, + * and attributes) and hands it to its reporters: `fancyReporter` for people + * at a terminal, `jsonReporter` and `logfmtReporter` for log collectors, and + * `sentryReporter` for Sentry structured logs. + * + * @module + */ + +export { + DEFAULT_LOG_LEVEL, + LOG_LEVEL_NAMES, + type LogLevelName, + parseLogLevel, +} from "./levels.js"; +export { createLogger, type Logger, type LoggerOptions } from "./logger.js"; +export { + type FancyReporterOptions, + fancyReporter, + formatLocalTime, +} from "./reporters/fancy.js"; +export { jsonReporter, type StreamReporterOptions } from "./reporters/json.js"; +export { logfmtReporter } from "./reporters/logfmt.js"; +export { type SentryLogger, sentryReporter } from "./reporters/sentry.js"; +export type { + LogAttributePrimitive, + LogAttributes, + LogAttributeValue, + LogEnv, + LogRecord, + LogStream, + LogType, + Reporter, +} from "./types.js"; diff --git a/packages/logger/src/levels.ts b/packages/logger/src/levels.ts new file mode 100644 index 000000000..4b04f5e4a --- /dev/null +++ b/packages/logger/src/levels.ts @@ -0,0 +1,36 @@ +/** Log level names and parsing. */ + +/** + * Valid log level names. The array index is the numeric level: + * 0 = error, 1 = warn, 2 = log, 3 = info (default), 4 = debug, 5 = trace. + */ +export const LOG_LEVEL_NAMES = [ + "error", + "warn", + "log", + "info", + "debug", + "trace", +] as const; + +/** A valid log level name. */ +export type LogLevelName = (typeof LOG_LEVEL_NAMES)[number]; + +/** Level 3 shows info, success, start, log, fail — standard user-facing output. */ +export const DEFAULT_LOG_LEVEL = 3; + +/** + * Parse a log level name into a numeric level. + * + * Returns the default level (info, 3) for unrecognized values so that a typo + * in an env var or flag doesn't break the program. + * + * @param name - Level name, case-insensitive + * @returns numeric level + */ +export function parseLogLevel(name: string): number { + const idx = LOG_LEVEL_NAMES.indexOf( + name.toLowerCase().trim() as LogLevelName, + ); + return idx === -1 ? DEFAULT_LOG_LEVEL : idx; +} diff --git a/packages/logger/src/logger.ts b/packages/logger/src/logger.ts new file mode 100644 index 000000000..1bbffb516 --- /dev/null +++ b/packages/logger/src/logger.ts @@ -0,0 +1,129 @@ +/** The logger: levels, tags, attributes, and the reporters it fans out to. */ + +import { DEFAULT_LOG_LEVEL } from "./levels.js"; +import { fancyReporter } from "./reporters/fancy.js"; +import { + LOG_TYPE_LEVELS, + type LogAttributes, + type LogRecord, + type LogType, + type Reporter, +} from "./types.js"; + +/** Options for {@link createLogger}. */ +export type LoggerOptions = { + /** Initial level. Defaults to 3 (info). */ + level?: number; + /** Where log calls go. Defaults to one `fancyReporter()` on stderr. */ + reporters?: readonly Reporter[]; +}; + +/** Level and reporters shared by a root logger and its scoped loggers. */ +type LoggerState = { level: number; reporters: Reporter[] }; + +/** A log method: `console.log`-style arguments, with `%s`-style placeholders. */ +type LogMethod = (...args: unknown[]) => void; + +/** + * A logger, optionally scoped to a tag and attributes carried by each record. + * Create a root logger with {@link createLogger}; `withTag()` and + * `withAttributes()` return scoped loggers. + */ +class Logger { + /** Tag shown with each message; nested tags join with `:`. */ + readonly tag: string; + /** Attributes added to each record. */ + readonly attributes: LogAttributes; + + readonly #state: LoggerState; + + // Arrow properties keep the methods bound, so they can be passed around. + readonly error: LogMethod = (...args) => this.#emit("error", args); + readonly warn: LogMethod = (...args) => this.#emit("warn", args); + readonly log: LogMethod = (...args) => this.#emit("log", args); + readonly info: LogMethod = (...args) => this.#emit("info", args); + readonly success: LogMethod = (...args) => this.#emit("success", args); + readonly fail: LogMethod = (...args) => this.#emit("fail", args); + readonly start: LogMethod = (...args) => this.#emit("start", args); + readonly debug: LogMethod = (...args) => this.#emit("debug", args); + readonly trace: LogMethod = (...args) => this.#emit("trace", args); + + constructor(state: LoggerState, tag: string, attributes: LogAttributes) { + this.#state = state; + this.tag = tag; + this.attributes = attributes; + } + + /** The shared level: calls above it are dropped. */ + get level(): number { + return this.#state.level; + } + + set level(level: number) { + this.#state.level = level; + } + + /** Return a logger whose records carry `tag`, nested under this logger's tag. */ + withTag(tag: string): Logger { + return new Logger( + this.#state, + this.tag ? `${this.tag}:${tag}` : tag, + this.attributes, + ); + } + + /** Return a logger whose records carry `attributes` on top of this logger's. */ + withAttributes(attributes: LogAttributes): Logger { + return new Logger(this.#state, this.tag, { + ...this.attributes, + ...attributes, + }); + } + + /** Send every later log call, from this logger and its relatives, to `reporter` too. */ + addReporter(reporter: Reporter): void { + this.#state.reporters.push(reporter); + } + + #emit(type: LogType, args: unknown[]): void { + const level = LOG_TYPE_LEVELS[type]; + if (level > this.#state.level) { + return; + } + const record: LogRecord = { + date: new Date(), + args, + type, + level, + tag: this.tag, + attributes: this.attributes, + }; + for (const reporter of this.#state.reporters) { + reporter.log(record); + } + } +} + +/** + * Create a root logger. + * + * ```ts + * import { createLogger } from "@sentry/logger"; + * + * const logger = createLogger(); + * logger.info("Checking for updates..."); + * logger.withTag("upgrade").debug("Resolved 0.12.0 → 0.13.0"); + * ``` + */ +export function createLogger(options: LoggerOptions = {}): Logger { + return new Logger( + { + level: options.level ?? DEFAULT_LOG_LEVEL, + reporters: [...(options.reporters ?? [fancyReporter()])], + }, + "", + {}, + ); +} + +export type { Logger }; diff --git a/packages/logger/src/message.ts b/packages/logger/src/message.ts new file mode 100644 index 000000000..3cd65f4a2 --- /dev/null +++ b/packages/logger/src/message.ts @@ -0,0 +1,64 @@ +/** Formats log arguments into a message, the way `console.log` would. */ + +import { sep } from "node:path"; +import { formatWithOptions } from "node:util"; + +/** Options for formatting log arguments, as consola passed them. */ +const INSPECT_OPTIONS = { colors: false, compact: true } as const; + +/** An `Error`, or anything else carrying a stack. */ +type ErrorLike = { message?: unknown; stack: string; cause?: unknown }; + +function isErrorLike(value: unknown): value is ErrorLike { + return ( + typeof value === "object" && + value !== null && + typeof (value as { stack?: unknown }).stack === "string" + ); +} + +/** + * Render an error as its message, its stack frames, and its causes. Unlike + * `util.inspect`, this leaves out the error's other properties, which can + * hold request or response details. + */ +function formatError(error: ErrorLike, depth: number, stack: boolean): string { + const message = + typeof error.message === "string" + ? error.message + : formatWithOptions(INSPECT_OPTIONS, error); + const prefix = depth > 0 ? `${" ".repeat(depth)}[cause]: ` : ""; + let cause = ""; + if (isErrorLike(error.cause)) { + cause = `${stack ? "\n\n" : " "}${formatError(error.cause, depth + 1, stack)}`; + } else if (error.cause) { + cause = `${stack ? `\n\n${" ".repeat(depth + 1)}` : " "}[cause]: ${formatWithOptions(INSPECT_OPTIONS, error.cause)}`; + } + if (!stack) { + return `${depth > 0 ? "[cause]: " : ""}${message}${cause}`; + } + const indent = " ".repeat(depth + 1); + const cwd = process.cwd() + sep; + const frames = error.stack + .split("\n") + .slice(message.split("\n").length) + .map( + (frame) => indent + frame.trim().replace("file://", "").replace(cwd, ""), + ) + .join("\n"); + return `${prefix}${message}\n${frames}${cause}`; +} + +/** + * Format log arguments with `util.format` semantics. Errors render as their + * message and causes, plus their stack frames when `stack` is true. + */ +export function formatMessage( + args: readonly unknown[], + { stack }: { stack: boolean }, +): string { + return formatWithOptions( + INSPECT_OPTIONS, + ...args.map((arg) => (isErrorLike(arg) ? formatError(arg, 0, stack) : arg)), + ); +} diff --git a/packages/logger/src/reporters/fancy.ts b/packages/logger/src/reporters/fancy.ts new file mode 100644 index 000000000..b32dcb14c --- /dev/null +++ b/packages/logger/src/reporters/fancy.ts @@ -0,0 +1,266 @@ +/** + * The consola-style reporter: icons, colors, `WARN`/`ERROR` badges, and a + * right-aligned `tag HH:MM:SS`. CI logs and test runs get plain + * `[type] [tag] message` lines instead. + */ + +import { formatMessage } from "../message.js"; +import type { + LogEnv, + LogRecord, + LogStream, + LogType, + Reporter, +} from "../types.js"; + +/** Options for {@link fancyReporter}. */ +export type FancyReporterOptions = { + /** Where lines go. Defaults to `process.stderr`. */ + stream?: LogStream; + /** + * Returns the environment that picks the layout and colors (`CI`, `TEST`, + * `NODE_ENV`, `NO_COLOR`, `FORCE_COLOR`, `TERM`, ...). Called on each log + * call. Defaults to `process.env`. + */ + env?: () => LogEnv; + /** Renders the time next to the tag. Defaults to {@link formatLocalTime}. */ + formatTime?: (date: Date) => string; +}; + +/** + * Format a `Date` as a 24-hour `HH:MM:SS` clock string in local time. + * + * Unlike `date.toLocaleTimeString()`, this does not depend on ICU being able + * to resolve a zone: it derives the local wall-clock time from the UTC time + * and `getTimezoneOffset()`, which reflects the OS offset even in runtimes + * that cache a stale UTC formatter, such as some Node SEA builds. + * + * @param date - The log record's timestamp + * @returns A `HH:MM:SS` string in local time (e.g. `"06:28:59"`) + */ +export function formatLocalTime(date: Date): string { + // getTimezoneOffset() is minutes behind UTC (positive west of UTC). Shifting + // the epoch by it and reading UTC getters yields local wall-clock components + // without relying on the ICU formatter. + const localMs = date.getTime() - date.getTimezoneOffset() * 60_000; + const local = new Date(localMs); + const hh = String(local.getUTCHours()).padStart(2, "0"); + const mm = String(local.getUTCMinutes()).padStart(2, "0"); + const ss = String(local.getUTCSeconds()).padStart(2, "0"); + return `${hh}:${mm}:${ss}`; +} + +/** SGR open and close codes for the styles the reporter uses. */ +const ANSI_CODES = { + black: [30, 39], + red: [31, 39], + green: [32, 39], + yellow: [33, 39], + magenta: [35, 39], + cyan: [36, 39], + gray: [90, 39], + underline: [4, 24], + bgRed: [41, 49], + bgYellow: [43, 49], +} as const; + +type AnsiStyle = keyof typeof ANSI_CODES; + +/** Icon color per log type. The error and warn levels render as badges instead. */ +const TYPE_COLORS: Record = { + error: "red", + warn: "yellow", + log: "gray", + info: "cyan", + success: "green", + fail: "red", + start: "magenta", + debug: "gray", + trace: "gray", +}; + +/** Badge background for the error (0) and warn (1) levels. */ +const BADGE_BACKGROUNDS: Record = { + 0: "bgRed", + 1: "bgYellow", +}; + +/** Unicode icon per log type, and its ASCII fallback. */ +const TYPE_ICONS: Record = { + error: ["✖", "×"], + warn: ["⚠", "‼"], + log: ["", ""], + info: ["ℹ", "i"], + success: ["✔", "√"], + fail: ["✖", "×"], + start: ["◐", "o"], + debug: ["⚙", "D"], + trace: ["→", "→"], +}; + +/** Inline `code` spans, rendered in cyan without their backticks. */ +const CODE_SPAN_PATTERN = /`([^`]+)`/g; + +/** ` _word_ ` spans, rendered underlined. */ +const UNDERLINE_PATTERN = /\s+_([^_]+)_\s+/g; + +/** Whether an env var is set to something other than `""` or `"false"`. */ +function isEnabled(value: string | undefined): boolean { + return Boolean(value) && value !== "false"; +} + +/** + * Whether to render the fancy layout. CI logs and test runs get plain + * `[type] [tag] message` lines instead, as they did with consola. + */ +function useFancyLayout(env: LogEnv): boolean { + return !(isEnabled(env.CI) || env.NODE_ENV === "test" || isEnabled(env.TEST)); +} + +/** Whether to write ANSI colors: `NO_COLOR`, then `FORCE_COLOR`, then TTY detection. */ +function useColor(env: LogEnv, stream: LogStream): boolean { + if (env.NO_COLOR) { + return false; + } + if (env.FORCE_COLOR) { + return env.FORCE_COLOR !== "0"; + } + return Boolean(stream.isTTY) && env.TERM !== "dumb"; +} + +/** Whether the terminal can draw the Unicode icons. */ +function supportsUnicode(env: LogEnv): boolean { + if (process.platform !== "win32") { + return env.TERM !== "linux"; + } + return ( + Boolean(env.WT_SESSION) || + env.TERM_PROGRAM === "vscode" || + env.TERM === "xterm-256color" || + env.TERM === "alacritty" || + env.TERMINAL_EMULATOR === "JetBrains-JediTerm" + ); +} + +/** + * Approximate visible width of a line: its length without SGR escapes + * (`ESC [ digits-and-semicolons m`). Wide characters count as one column, + * which only shifts the right-aligned tag. A loop rather than a regex replace, + * which was the most expensive step of a colored line. + */ +function visibleWidth(text: string): number { + if (!text.includes("\x1b")) { + return text.length; + } + let width = 0; + for (let i = 0; i < text.length; i++) { + if (text.charCodeAt(i) === 0x1b && text.charCodeAt(i + 1) === 0x5b) { + let end = i + 2; + let code = text.charCodeAt(end); + while ((code >= 0x30 && code <= 0x39) || code === 0x3b) { + code = text.charCodeAt(++end); + } + if (code === 0x6d) { + i = end; + continue; + } + } + width++; + } + return width; +} + +/** Wrap `text` in the SGR codes for `style`, when colors are on and there is text. */ +function paint(color: boolean, style: AnsiStyle, text: string): string { + if (!(color && text)) { + return text; + } + const [open, close] = ANSI_CODES[style]; + return `\x1b[${open}m${text}\x1b[${close}m`; +} + +/** Render inline `code` and ` _underline_ ` spans. */ +function markup(color: boolean, text: string): string { + let marked = text; + if (marked.includes("`")) { + marked = marked.replace(CODE_SPAN_PATTERN, (_, code: string) => + paint(color, "cyan", code), + ); + } + if (marked.includes("_")) { + marked = marked.replace( + UNDERLINE_PATTERN, + (_, word: string) => ` ${paint(color, "underline", word)} `, + ); + } + return marked; +} + +/** `a` and `b` joined by a space, leaving out the empty ones. */ +function joinWords(a: string, b: string): string { + return a && b ? `${a} ${b}` : a || b; +} + +/** Render `record` as terminal lines, without a trailing newline. */ +function formatRecord( + record: LogRecord, + env: LogEnv, + stream: LogStream, + formatTime: (date: Date) => string, +): string { + const message = formatMessage(record.args, { stack: true }); + if (!useFancyLayout(env)) { + return joinWords( + joinWords(`[${record.type}]`, record.tag && `[${record.tag}]`), + message, + ); + } + + const color = useColor(env, stream); + const newline = message.indexOf("\n"); + const firstLine = newline === -1 ? message : message.slice(0, newline); + const badge = BADGE_BACKGROUNDS[record.level]; + const type = badge + ? paint( + color, + badge, + paint(color, "black", ` ${record.type.toUpperCase()} `), + ) + : paint( + color, + TYPE_COLORS[record.type], + TYPE_ICONS[record.type][supportsUnicode(env) ? 0 : 1], + ); + + const columns = stream.columns || 0; + const tag = record.tag ? paint(color, "gray", record.tag) : ""; + const date = columns ? paint(color, "gray", formatTime(record.date)) : ""; + const left = joinWords(type, markup(color, firstLine)); + const right = joinWords(tag, date); + const space = + columns >= 80 ? columns - visibleWidth(left) - visibleWidth(right) - 2 : 0; + + let line = + space > 0 + ? left + " ".repeat(space) + right + : (right ? `${paint(color, "gray", `[${right}]`)} ` : "") + left; + if (newline !== -1) { + line += markup(color, message.slice(newline)); + } + return badge ? `\n${line}\n` : line; +} + +/** + * A reporter for people at a terminal, in consola's "fancy" layout. Each log + * call is one write. + */ +export function fancyReporter(options: FancyReporterOptions = {}): Reporter { + const getEnv = options.env ?? (() => process.env); + const formatTime = options.formatTime ?? formatLocalTime; + return { + log(record) { + const stream = options.stream ?? process.stderr; + stream.write(`${formatRecord(record, getEnv(), stream, formatTime)}\n`); + }, + }; +} diff --git a/packages/logger/src/reporters/json.ts b/packages/logger/src/reporters/json.ts new file mode 100644 index 000000000..da44871ca --- /dev/null +++ b/packages/logger/src/reporters/json.ts @@ -0,0 +1,26 @@ +/** Newline-delimited JSON, one object per log call, for log collectors. */ + +import { recordFields } from "../fields.js"; +import type { LogStream, Reporter } from "../types.js"; + +/** Options for {@link jsonReporter} and `logfmtReporter`. */ +export type StreamReporterOptions = { + /** Where lines go. Defaults to `process.stderr`. */ + stream?: LogStream; +}; + +/** + * A reporter that writes each log call as one JSON line: + * + * ```json + * {"time":"2026-01-15T14:28:59.000Z","level":"info","tag":"auth","msg":"Logged in","user":"jane"} + * ``` + */ +export function jsonReporter(options: StreamReporterOptions = {}): Reporter { + return { + log(record) { + const line = JSON.stringify(recordFields(record)); + (options.stream ?? process.stderr).write(`${line}\n`); + }, + }; +} diff --git a/packages/logger/src/reporters/logfmt.ts b/packages/logger/src/reporters/logfmt.ts new file mode 100644 index 000000000..1b2b10f62 --- /dev/null +++ b/packages/logger/src/reporters/logfmt.ts @@ -0,0 +1,85 @@ +/** logfmt lines (`key=value key="quoted value"`), one per log call. */ + +import { flattenAttribute, recordFields } from "../fields.js"; +import type { + LogAttributePrimitive, + LogAttributeValue, + Reporter, +} from "../types.js"; +import type { StreamReporterOptions } from "./json.js"; + +/** Values that need quotes: empty, or holding a space, `=`, `"`, `\`, or a control character. */ +// oxlint-disable-next-line no-control-regex -- control characters must be quoted +const NEEDS_QUOTES = /^$|[\s="\\\x00-\x1f\x7f]/; + +/** Characters escaped inside a quoted value. */ +// oxlint-disable-next-line no-control-regex -- control characters must be escaped +const ESCAPED = /["\\\x00-\x1f\x7f]/g; + +const ESCAPES: Record = { + '"': '\\"', + "\\": "\\\\", + "\n": "\\n", + "\r": "\\r", + "\t": "\\t", +}; + +/** + * Characters a logfmt key cannot hold: a space, `=`, `"`, or a control + * character. Everything else, `.` included, passes through, as in + * go-logfmt and node-logfmt. + */ +// oxlint-disable-next-line no-control-regex -- control characters must be replaced +const INVALID_KEY_CHARACTER = /[\s="\x00-\x1f\x7f]/; +const INVALID_KEY_CHARACTERS = new RegExp(INVALID_KEY_CHARACTER.source, "g"); + +/** `key` with invalid characters replaced by `_`. Most keys need no replacing. */ +function formatKey(key: string): string { + return INVALID_KEY_CHARACTER.test(key) + ? key.replace(INVALID_KEY_CHARACTERS, "_") + : key; +} + +function formatValue(value: LogAttributePrimitive): string { + const text = String(value); + if (!NEEDS_QUOTES.test(text)) { + return text; + } + const escaped = text.replace( + ESCAPED, + (char) => + ESCAPES[char] ?? `\\u${char.charCodeAt(0).toString(16).padStart(4, "0")}`, + ); + return `"${escaped}"`; +} + +/** + * A reporter that writes each log call as one logfmt line: + * + * ```text + * time=2026-01-15T14:28:59.000Z level=info tag=auth msg="Logged in" user=jane + * ``` + * + * Object attributes expand into dotted keys (`http.status_code=404`). + */ +export function logfmtReporter(options: StreamReporterOptions = {}): Reporter { + return { + log(record) { + const fields = recordFields(record); + let line = ""; + for (const key of Object.keys(fields)) { + const value = fields[key] as LogAttributeValue; + if (typeof value !== "object") { + line += `${line ? " " : ""}${formatKey(key)}=${formatValue(value)}`; + continue; + } + const flat: Record = {}; + flattenAttribute(flat, key, value); + for (const flatKey of Object.keys(flat)) { + line += `${line ? " " : ""}${formatKey(flatKey)}=${formatValue(flat[flatKey] as LogAttributePrimitive)}`; + } + } + (options.stream ?? process.stderr).write(`${line}\n`); + }, + }; +} diff --git a/packages/logger/src/reporters/sentry.ts b/packages/logger/src/reporters/sentry.ts new file mode 100644 index 000000000..7df24058b --- /dev/null +++ b/packages/logger/src/reporters/sentry.ts @@ -0,0 +1,78 @@ +/** + * Forwards log calls to Sentry structured logs without depending on a Sentry + * package: pass the SDK's `logger` (`Sentry.logger` from `@sentry/node`, + * `@sentry/bun`, `@sentry/browser`, ...), and this reporter calls it. + */ + +import { flattenAttribute } from "../fields.js"; +import { formatMessage } from "../message.js"; +import type { + LogAttributePrimitive, + LogAttributeValue, + LogType, + Reporter, +} from "../types.js"; + +/** Sentry severities the reporter uses. */ +type SentrySeverity = "trace" | "debug" | "info" | "warn" | "error"; + +/** The part of the Sentry SDK's `logger` that the reporter calls. */ +export type SentryLogger = Record< + SentrySeverity, + (message: string, attributes?: Record) => void +>; + +/** Sentry severity for each log type. */ +const SEVERITIES: Record = { + error: "error", + fail: "error", + warn: "warn", + log: "info", + info: "info", + success: "info", + start: "info", + debug: "debug", + trace: "trace", +}; + +/** `sentry.origin` of every forwarded log, for filtering in Sentry. */ +const ORIGIN = "auto.log.sentry_logger"; + +/** + * A reporter that sends each log call to Sentry structured logs, at the + * severity matching its type. + * + * The message is the formatted arguments; errors contribute their message + * and causes, not their stacks. The record's attributes are sent as log + * attributes, objects expanded into dotted keys, with `logger.tag` (when + * set), `logger.type`, and `sentry.origin`. + * + * ```ts + * import * as Sentry from "@sentry/node"; + * + * logger.addReporter(sentryReporter(Sentry.logger)); + * ``` + */ +export function sentryReporter(sentryLogger: SentryLogger): Reporter { + return { + log(record) { + const attributes: Record = {}; + for (const key of Object.keys(record.attributes)) { + flattenAttribute( + attributes, + key, + record.attributes[key] as LogAttributeValue, + ); + } + if (record.tag) { + attributes["logger.tag"] = record.tag; + } + attributes["logger.type"] = record.type; + attributes["sentry.origin"] = ORIGIN; + sentryLogger[SEVERITIES[record.type]]( + formatMessage(record.args, { stack: false }), + attributes, + ); + }, + }; +} diff --git a/packages/logger/src/types.ts b/packages/logger/src/types.ts new file mode 100644 index 000000000..d91784ce9 --- /dev/null +++ b/packages/logger/src/types.ts @@ -0,0 +1,67 @@ +/** Types shared by the logger and its reporters. */ + +/** + * Numeric level of each log method. A call is recorded when its level is at + * most the logger's level. + */ +export const LOG_TYPE_LEVELS = { + error: 0, + warn: 1, + log: 2, + info: 3, + success: 3, + fail: 3, + start: 3, + debug: 4, + trace: 5, +} as const; + +/** A log method name, such as `info` or `success`. */ +export type LogType = keyof typeof LOG_TYPE_LEVELS; + +/** A structured attribute value that is not an object. */ +export type LogAttributePrimitive = string | number | boolean; + +/** + * A structured attribute value: a primitive, or an object of them. Reporters + * that need flat fields (logfmt, Sentry) expand objects into dotted keys. + */ +export type LogAttributeValue = + | LogAttributePrimitive + | { readonly [key: string]: LogAttributeValue }; + +/** Structured attributes attached to log records. */ +export type LogAttributes = Readonly>; + +/** + * One log call. + * + * `date`, `args`, `type`, `level`, and `tag` keep consola's log object shape, + * so reporters written for consola can read records. + */ +export type LogRecord = { + date: Date; + /** The arguments passed to the log method, formatted like `console.log`. */ + args: readonly unknown[]; + type: LogType; + level: number; + /** The logger's tag; nested tags join with `:`. Empty for the root logger. */ + tag: string; + /** Attributes from `withAttributes()`. */ + attributes: LogAttributes; +}; + +/** Receives every log call at or below the logger's level. */ +export type Reporter = { + log(record: LogRecord): void; +}; + +/** Where text reporters write. `process.stdout` and `process.stderr` fit. */ +export type LogStream = { + write(chunk: string): unknown; + isTTY?: boolean; + columns?: number; +}; + +/** Environment variables the fancy reporter reads, such as `NO_COLOR` and `CI`. */ +export type LogEnv = Readonly>; diff --git a/packages/logger/test/logger.test.ts b/packages/logger/test/logger.test.ts new file mode 100644 index 000000000..0813f24c2 --- /dev/null +++ b/packages/logger/test/logger.test.ts @@ -0,0 +1,471 @@ +import { + afterEach, + beforeEach, + describe, + expect, + test, + vi, + type MockInstance, +} from "vitest"; +import { + createLogger, + fancyReporter, + formatLocalTime, + jsonReporter, + LOG_LEVEL_NAMES, + type LogRecord, + logfmtReporter, + parseLogLevel, + type SentryLogger, + sentryReporter, +} from "../src/index.js"; + +/** A stream that keeps what reporters write to it. */ +function memoryStream(options: { columns?: number } = {}) { + const chunks: string[] = []; + return { + chunks, + text: () => chunks.join(""), + write: (chunk: string) => chunks.push(chunk), + ...options, + }; +} + +/** A logger that only records what it would report. */ +function recordingLogger() { + const records: LogRecord[] = []; + const logger = createLogger({ + reporters: [{ log: (record) => records.push(record) }], + }); + return { logger, records }; +} + +const DATE = new Date("2024-01-15T14:28:59.000Z"); + +describe("parseLogLevel", () => { + test("maps known level names to correct numeric values", () => { + expect(LOG_LEVEL_NAMES.map(parseLogLevel)).toEqual([0, 1, 2, 3, 4, 5]); + }); + + test("is case-insensitive and trims whitespace", () => { + expect(parseLogLevel("ERROR")).toBe(0); + expect(parseLogLevel("\tTrace\n")).toBe(5); + }); + + test("returns default (info=3) for unrecognized values", () => { + expect(parseLogLevel("verbose")).toBe(3); + expect(parseLogLevel("")).toBe(3); + }); +}); + +describe("logger", () => { + test("records carry the tag, attributes, type, level, and arguments", () => { + const { logger, records } = recordingLogger(); + const scoped = logger + .withTag("upgrade") + .withAttributes({ org: "acme", attempt: 1 }) + .withTag("delta") + .withAttributes({ attempt: 2 }); + + scoped.warn("Patch %s failed", "0.13.0"); + scoped.debug("dropped above the level"); + + expect(records).toEqual([ + { + date: expect.any(Date), + args: ["Patch %s failed", "0.13.0"], + type: "warn", + level: 1, + tag: "upgrade:delta", + attributes: { org: "acme", attempt: 2 }, + }, + ]); + }); + + test("scoped loggers share the root's level, including ones created earlier", () => { + const { logger } = recordingLogger(); + const child = logger.withTag("parent").withAttributes({ a: 1 }); + logger.level = 5; + expect(child.level).toBe(5); + child.level = 0; + expect(logger.level).toBe(0); + }); + + test("a reporter added later sees records from every related logger", () => { + const { logger } = recordingLogger(); + const child = logger.withTag("child"); + const late: LogRecord[] = []; + logger.addReporter({ log: (record) => late.push(record) }); + child.info("hi"); + expect(late).toMatchObject([{ tag: "child" }]); + }); + + test("separate root loggers do not share state", () => { + const first = createLogger({ level: 0, reporters: [] }); + const second = createLogger({ reporters: [] }); + first.level = 5; + expect(second.level).toBe(3); + }); + + test("methods stay bound when passed around", () => { + const { logger, records } = recordingLogger(); + const { info } = logger.withTag("detached"); + info("hello"); + expect(records).toMatchObject([{ type: "info", tag: "detached" }]); + }); + + describe("default reporter", () => { + let stderr: MockInstance; + let savedCi: string | undefined; + + beforeEach(() => { + savedCi = process.env.CI; + process.env.CI = "true"; + stderr = vi.spyOn(process.stderr, "write").mockImplementation(() => true); + }); + + afterEach(() => { + stderr.mockRestore(); + if (savedCi === undefined) { + delete process.env.CI; + } else { + process.env.CI = savedCi; + } + }); + + test("is the fancy reporter on stderr", () => { + createLogger().withTag("auth").info("Logged in"); + expect(stderr).toHaveBeenCalledWith("[info] [auth] Logged in\n"); + }); + }); +}); + +describe("formatLocalTime", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + test("renders 24-hour HH:MM:SS in local time", () => { + // Force a +480 (US/Pacific standard, UTC-8) offset so the local wall-clock + // is 06:28:59 regardless of the host machine's zone. + vi.spyOn(Date.prototype, "getTimezoneOffset").mockReturnValue(480); + expect(formatLocalTime(DATE)).toBe("06:28:59"); + }); + + test("east-of-UTC (negative offset) wraps past midnight", () => { + vi.spyOn(Date.prototype, "getTimezoneOffset").mockReturnValue(-60); + expect(formatLocalTime(new Date("2024-01-15T23:30:00.000Z"))).toBe( + "00:30:00", + ); + }); + + test("zero-pads single-digit components", () => { + vi.spyOn(Date.prototype, "getTimezoneOffset").mockReturnValue(0); + expect(formatLocalTime(new Date("2024-01-15T01:02:03.000Z"))).toBe( + "01:02:03", + ); + }); +}); + +describe("fancyReporter", () => { + function fancyLogger( + env: Record, + options: { columns?: number; formatTime?: (date: Date) => string } = {}, + ) { + const stream = memoryStream({ columns: options.columns }); + const logger = createLogger({ + reporters: [ + fancyReporter({ + stream, + env: () => env, + formatTime: options.formatTime, + }), + ], + }); + return { logger, stream }; + } + + test("writes plain [type] [tag] lines in CI", () => { + const { logger, stream } = fancyLogger({ CI: "true" }); + logger.withTag("auth").info("Logged in as %s", "jane"); + expect(stream.text()).toBe("[info] [auth] Logged in as jane\n"); + }); + + test("reads the env on each call", () => { + let env: Record = { CI: "true" }; + const stream = memoryStream(); + const logger = createLogger({ + reporters: [fancyReporter({ stream, env: () => env })], + }); + logger.info("plain"); + env = { NO_COLOR: "1" }; + logger.info("fancy"); + expect(stream.text()).toBe("[info] plain\nℹ fancy\n"); + }); + + test("writes an error's message, stack, and cause but not its other properties", () => { + const { logger, stream } = fancyLogger({ CI: "true" }); + const error = Object.assign( + new Error("Request failed", { cause: new Error("socket hang up") }), + { response: { headers: { authorization: "Bearer secret" } } }, + ); + + logger.warn("Upload failed:", error); + + expect(stream.text()).toMatch( + /^\[warn\] Upload failed: Request failed\n {2}at /, + ); + expect(stream.text()).toContain("\n\n [cause]: socket hang up\n at "); + expect(stream.text()).not.toContain("secret"); + }); + + test("writes icons, badges, and inline code in the interactive layout", () => { + const { logger, stream } = fancyLogger({ + NODE_ENV: "production", + NO_COLOR: "1", + }); + + logger.withTag("auth").success("Run `sentry auth status`"); + logger.warn("Token expires soon"); + + // A stream without columns puts the tag first. + expect(stream.text()).toBe( + "[auth] ✔ Run sentry auth status\n\n WARN Token expires soon\n\n", + ); + }); + + test("right-aligns the tag and the formatted time on wide terminals", () => { + const { logger, stream } = fancyLogger( + { NO_COLOR: "1" }, + { columns: 80, formatTime: () => "T" }, + ); + logger.withTag("auth").info("hi"); + expect(stream.text()).toBe(`ℹ hi${" ".repeat(68)}auth T\n`); + }); +}); + +describe("jsonReporter", () => { + test("writes one JSON object per call, built-in fields first", () => { + const stream = memoryStream(); + const reporter = jsonReporter({ stream }); + reporter.log({ + date: DATE, + args: ["Uploaded %d files", 3], + type: "success", + level: 3, + tag: "upload", + attributes: { org: "acme", msg: "ignored" }, + }); + reporter.log({ + date: DATE, + args: ["no tag"], + type: "info", + level: 3, + tag: "", + attributes: {}, + }); + + expect(stream.chunks).toEqual([ + '{"time":"2024-01-15T14:28:59.000Z","level":"info","type":"success","tag":"upload","msg":"Uploaded 3 files","org":"acme"}\n', + '{"time":"2024-01-15T14:28:59.000Z","level":"info","msg":"no tag"}\n', + ]); + }); + + test("writes the same time as toISOString() within and across seconds", () => { + const stream = memoryStream(); + const reporter = jsonReporter({ stream }); + const times = [ + 0, + DATE.getTime() + 7, + DATE.getTime() + 42, + DATE.getTime() + 999, + DATE.getTime() + 1000, + DATE.getTime() - 1, + -1, + -1001, + Date.UTC(-1, 0, 1, 0, 0, 0, 5), + Date.UTC(20000, 11, 31, 23, 59, 59, 250), + ]; + for (const time of times) { + reporter.log({ + date: new Date(time), + args: [], + type: "info", + level: 3, + tag: "", + attributes: {}, + }); + } + + expect(stream.chunks.map((chunk) => JSON.parse(chunk).time)).toEqual( + times.map((time) => new Date(time).toISOString()), + ); + }); + + test("keeps a __proto__ attribute as a field", () => { + const stream = memoryStream(); + jsonReporter({ stream }).log({ + date: DATE, + args: ["hi"], + type: "info", + level: 3, + tag: "", + attributes: JSON.parse('{"__proto__":"x","after":1}'), + }); + + expect(stream.text()).toBe( + '{"time":"2024-01-15T14:28:59.000Z","level":"info","msg":"hi","__proto__":"x","after":1}\n', + ); + }); +}); + +describe("logfmtReporter", () => { + test("writes key=value pairs, quoting and escaping where needed", () => { + const stream = memoryStream(); + logfmtReporter({ stream }).log({ + date: DATE, + args: ['Said "hi"\nthen left'], + type: "warn", + level: 1, + tag: "chat", + attributes: { + user: "jane doe", + count: 2, + ok: true, + "http.status_code": 404, + "bad key=": "x=y", + empty: "", + }, + }); + + expect(stream.text()).toBe( + 'time=2024-01-15T14:28:59.000Z level=warn tag=chat msg="Said \\"hi\\"\\nthen left" user="jane doe" count=2 ok=true http.status_code=404 bad_key_="x=y" empty=""\n', + ); + }); +}); + +describe("nested attributes", () => { + const record = (attributes: LogRecord["attributes"]): LogRecord => ({ + date: DATE, + args: ["hi"], + type: "info", + level: 3, + tag: "", + attributes, + }); + + test("logfmt expands objects into dotted keys", () => { + const stream = memoryStream(); + logfmtReporter({ stream }).log( + record({ + http: { method: "GET", response: { status_code: 404 } }, + empty: {}, + after: 1, + }), + ); + + expect(stream.text()).toBe( + "time=2024-01-15T14:28:59.000Z level=info msg=hi http.method=GET http.response.status_code=404 after=1\n", + ); + }); + + test("objects nested more than five levels deep are cut off", () => { + const cycle: Record = { ok: true }; + cycle.self = cycle; + const stream = memoryStream(); + logfmtReporter({ stream }).log( + record({ + a: { b: { c: { d: { e: { f: { g: 1 } } } } } }, + cycle, + } as never), + ); + + expect(stream.text()).toBe( + "time=2024-01-15T14:28:59.000Z level=info msg=hi a.b.c.d.e.f=[Object] cycle.ok=true cycle.self.ok=true cycle.self.self.ok=true cycle.self.self.self.ok=true cycle.self.self.self.self.ok=true cycle.self.self.self.self.self=[Object]\n", + ); + }); + + test("JSON keeps objects nested", () => { + const stream = memoryStream(); + jsonReporter({ stream }).log(record({ http: { status_code: 404 } })); + + expect(stream.text()).toBe( + '{"time":"2024-01-15T14:28:59.000Z","level":"info","msg":"hi","http":{"status_code":404}}\n', + ); + }); +}); + +describe("sentryReporter", () => { + function fakeSentryLogger() { + const calls: [string, string, Record | undefined][] = []; + const method = + (severity: string) => + (message: string, attributes?: Record) => + calls.push([severity, message, attributes]); + const sentryLogger: SentryLogger = { + trace: method("trace"), + debug: method("debug"), + info: method("info"), + warn: method("warn"), + error: method("error"), + }; + return { calls, sentryLogger }; + } + + test("forwards each call at its severity with its attributes", () => { + const { calls, sentryLogger } = fakeSentryLogger(); + const logger = createLogger({ + level: 5, + reporters: [sentryReporter(sentryLogger)], + }); + const scoped = logger.withTag("auth").withAttributes({ org: "acme" }); + + scoped.success("Logged in as %s", "jane"); + scoped.fail("Nope"); + logger.trace("deep"); + + expect(calls).toEqual([ + [ + "info", + "Logged in as jane", + { + org: "acme", + "logger.tag": "auth", + "logger.type": "success", + "sentry.origin": "auto.log.sentry_logger", + }, + ], + ["error", "Nope", expect.objectContaining({ "logger.type": "fail" })], + [ + "trace", + "deep", + { "logger.type": "trace", "sentry.origin": "auto.log.sentry_logger" }, + ], + ]); + }); + + test("expands object attributes into dotted keys", () => { + const { calls, sentryLogger } = fakeSentryLogger(); + createLogger({ reporters: [sentryReporter(sentryLogger)] }) + .withAttributes({ http: { status_code: 404, route: "/auth" } }) + .info("hi"); + + expect(calls[0]?.[2]).toEqual({ + "http.status_code": 404, + "http.route": "/auth", + "logger.type": "info", + "sentry.origin": "auto.log.sentry_logger", + }); + }); + + test("sends an error's message and cause without its stack", () => { + const { calls, sentryLogger } = fakeSentryLogger(); + createLogger({ reporters: [sentryReporter(sentryLogger)] }).error( + "Upload failed:", + new Error("Request failed", { cause: new Error("socket hang up") }), + ); + expect(calls[0]?.[1]).toBe( + "Upload failed: Request failed [cause]: socket hang up", + ); + }); +}); diff --git a/packages/logger/test/package/check.sh b/packages/logger/test/package/check.sh new file mode 100755 index 000000000..b2a43edf0 --- /dev/null +++ b/packages/logger/test/package/check.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash +# Pack @sentry/logger, install the tarball into a clean consumer project, and +# typecheck and run that project as ES modules and as CommonJS. The workspace +# imports the TypeScript source; this checks what npm users get instead. +set -euo pipefail + +package_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +work_dir="$(mktemp -d)" +trap 'rm -rf "$work_dir"' EXIT + +cd "$package_dir" +pnpm run build +pnpm pack --pack-destination "$work_dir" + +cp -R test/package/consumer "$work_dir/consumer" +cd "$work_dir/consumer" +npm install --ignore-scripts --no-audit --no-fund --no-save \ + "$work_dir"/sentry-logger-*.tgz typescript@^5.8.3 @types/node@^22 +npx tsc -p . +node out/esm.js +node out/cjs.cjs +echo "Packed @sentry/logger works from ES modules and CommonJS." diff --git a/packages/logger/test/package/consumer/cjs.cts b/packages/logger/test/package/consumer/cjs.cts new file mode 100644 index 000000000..57d60e13f --- /dev/null +++ b/packages/logger/test/package/consumer/cjs.cts @@ -0,0 +1,15 @@ +// Requires the packed package as CommonJS, as an npm consumer would. +import { strictEqual } from "node:assert"; +import logger = require("@sentry/logger"); + +const lines: string[] = []; +logger + .createLogger({ + reporters: [ + logger.logfmtReporter({ stream: { write: (line) => lines.push(line) } }), + ], + }) + .withTag("cjs") + .info("hello"); + +strictEqual(lines[0]?.includes(" tag=cjs msg=hello\n"), true); diff --git a/packages/logger/test/package/consumer/esm.ts b/packages/logger/test/package/consumer/esm.ts new file mode 100644 index 000000000..77c1d7c42 --- /dev/null +++ b/packages/logger/test/package/consumer/esm.ts @@ -0,0 +1,33 @@ +// Imports the packed package as ES modules, as an npm consumer would. +import { strictEqual } from "node:assert"; +import { + createLogger, + jsonReporter, + type LogRecord, + type SentryLogger, + sentryReporter, +} from "@sentry/logger"; + +const records: LogRecord[] = []; +const lines: string[] = []; +const sentryCalls: string[] = []; +const sentryLogger: SentryLogger = { + trace: () => {}, + debug: () => {}, + info: (message) => sentryCalls.push(message), + warn: () => {}, + error: () => {}, +}; + +const logger = createLogger({ + reporters: [ + { log: (record) => records.push(record) }, + jsonReporter({ stream: { write: (line) => lines.push(line) } }), + sentryReporter(sentryLogger), + ], +}); +logger.withTag("esm").withAttributes({ user: "jane" }).info("hello %s", "you"); + +strictEqual(records[0]?.tag, "esm"); +strictEqual(JSON.parse(lines[0] ?? "").user, "jane"); +strictEqual(sentryCalls[0], "hello you"); diff --git a/packages/logger/test/package/consumer/package.json b/packages/logger/test/package/consumer/package.json new file mode 100644 index 000000000..e986b24bb --- /dev/null +++ b/packages/logger/test/package/consumer/package.json @@ -0,0 +1,4 @@ +{ + "private": true, + "type": "module" +} diff --git a/packages/logger/test/package/consumer/tsconfig.json b/packages/logger/test/package/consumer/tsconfig.json new file mode 100644 index 000000000..5273f3ee6 --- /dev/null +++ b/packages/logger/test/package/consumer/tsconfig.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "module": "NodeNext", + "moduleResolution": "NodeNext", + "target": "ES2022", + "strict": true, + "outDir": "out", + "types": ["node"] + }, + "include": ["esm.ts", "cjs.cts"] +} diff --git a/packages/logger/tsconfig.json b/packages/logger/tsconfig.json new file mode 100644 index 000000000..d89cf4aec --- /dev/null +++ b/packages/logger/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2023"], + "module": "ESNext", + "moduleResolution": "Bundler", + "types": ["node"], + "strict": true, + "noEmit": true, + "skipLibCheck": true, + "isolatedModules": true + }, + "include": ["src/**/*.ts", "test/*.ts"] +} diff --git a/packages/logger/tsdown.config.ts b/packages/logger/tsdown.config.ts new file mode 100644 index 000000000..1c9be5d58 --- /dev/null +++ b/packages/logger/tsdown.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from "tsdown"; + +export default defineConfig({ + entry: ["src/index.ts"], + format: ["cjs", "esm"], + target: "node18", + platform: "node", + dts: true, + sourcemap: true, + clean: true, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 34b47fec2..d67903827 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -474,6 +474,9 @@ importers: '@sentry/core': specifier: 'catalog:' version: 11.6.0 + '@sentry/logger': + specifier: workspace:* + version: link:../logger '@sentry/mcp-server': specifier: workspace:* version: link:../mcp-server @@ -634,6 +637,21 @@ importers: specifier: 3.4.0 version: 3.4.0 + packages/logger: + devDependencies: + '@types/node': + specifier: 'catalog:' + version: 22.16.0 + tsdown: + specifier: 'catalog:' + version: 0.12.9(typescript@5.9.3) + typescript: + specifier: 'catalog:' + version: 5.9.3 + vitest: + specifier: 'catalog:' + version: 5.0.5(@opentelemetry/api@1.9.1)(@types/node@22.16.0)(@vitest/coverage-v8@5.0.5)(jsdom@28.1.0)(msw@2.10.2(@types/node@22.16.0)(typescript@5.9.3))(vite@8.3.3(@types/node@22.16.0)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0)) + packages/mcp-cloudflare: dependencies: '@ai-sdk/mcp':