From 3b25814bcca283006a342b3bc62b31df341ef4b0 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Sat, 31 Oct 2026 15:39:13 +0000 Subject: [PATCH 1/5] feat(cli-logger): Publish the CLI logger as @sentry/cli-logger Move the logger from packages/cli/src/lib/logger.ts into a new packages/cli-logger workspace package, published to npm as @sentry/cli-logger. createLogger() takes the CLI-specific pieces as options: env (the CLI passes getEnv for library mode) and prompt (the CLI keeps its completion-build guard around clackPrompt). formatLogTime moves into the package as the default formatLocalTime. The CLI's logger.ts keeps its exports, so callers and test mocks do not change. Releases go through the Release workflow's new cli-logger product. Fixes #1772 Co-Authored-By: BYK <126780+BYK@users.noreply.github.com> --- .craft.yml | 14 + .github/workflows/cli-logger-package.yml | 100 +++++ .github/workflows/merge-jobs.yml | 9 +- .github/workflows/release.yml | 5 + AGENTS.md | 1 + docs/README.md | 4 +- packages/cli-logger/LICENSE.md | 105 +++++ packages/cli-logger/README.md | 140 ++++++ packages/cli-logger/package.json | 88 ++++ packages/cli-logger/src/format.ts | 244 +++++++++++ packages/cli-logger/src/index.ts | 228 ++++++++++ packages/cli-logger/src/prompt.ts | 44 ++ packages/cli-logger/src/types.ts | 59 +++ packages/cli-logger/test/logger.test.ts | 287 +++++++++++++ packages/cli-logger/tsconfig.json | 14 + packages/cli-logger/tsdown.config.ts | 11 + packages/cli/package.json | 1 + packages/cli/src/lib/logger.ts | 430 ++----------------- packages/cli/src/lib/timezone.ts | 29 -- packages/cli/test/lib/logger.test.ts | 176 +------- packages/cli/test/lib/timezone.test.ts | 48 +-- packages/cli/test/script/cli-startup.test.ts | 6 +- pnpm-lock.yaml | 21 + 23 files changed, 1426 insertions(+), 638 deletions(-) create mode 100644 .github/workflows/cli-logger-package.yml create mode 100644 packages/cli-logger/LICENSE.md create mode 100644 packages/cli-logger/README.md create mode 100644 packages/cli-logger/package.json create mode 100644 packages/cli-logger/src/format.ts create mode 100644 packages/cli-logger/src/index.ts create mode 100644 packages/cli-logger/src/prompt.ts create mode 100644 packages/cli-logger/src/types.ts create mode 100644 packages/cli-logger/test/logger.test.ts create mode 100644 packages/cli-logger/tsconfig.json create mode 100644 packages/cli-logger/tsdown.config.ts diff --git a/.craft.yml b/.craft.yml index a51b1a97a..8d3bd2531 100644 --- a/.craft.yml +++ b/.craft.yml @@ -103,6 +103,20 @@ workspaces: assert_match version.to_s, shell_output("#{bin}/sentry --version").chomp end end + packages/cli-logger: + changelog: + policy: auto + preReleaseCommand: >- + bash -c 'cd packages/cli-logger && npm version "$CRAFT_NEW_VERSION" + --no-git-tag-version' + releaseBranchPrefix: release/cli-logger + targets: + - name: github + tagPrefix: "cli-logger@" + includeNames: '/^sentry-cli-logger-\d.*\.tgz$/' + - name: npm + id: "@sentry/cli-logger" + includeNames: '/^sentry-cli-logger-\d.*\.tgz$/' packages/mcp-server: changelogPolicy: none preReleaseCommand: >- diff --git a/.github/workflows/cli-logger-package.yml b/.github/workflows/cli-logger-package.yml new file mode 100644 index 000000000..1f973519e --- /dev/null +++ b/.github/workflows/cli-logger-package.yml @@ -0,0 +1,100 @@ +name: CLI Logger Package + +on: + push: + branches: [main] + pull_request: + paths: + - ".github/workflows/cli-logger-package.yml" + - "packages/cli-logger/**" + - "pnpm-lock.yaml" + +permissions: + contents: read + +jobs: + package-consumer: + name: Clean Consumer Check + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: "22" + + # pnpm/action-setup@v4 + - uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda + name: Install pnpm + with: + run_install: false + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Build and pack + run: | + pnpm --filter @sentry/cli-logger run build + pnpm --dir packages/cli-logger pack --pack-destination "$RUNNER_TEMP" + + # The workspace imports the TypeScript source; the packed package.json + # must point its exports at dist/ instead (publishConfig.exports). + - name: Import and typecheck from a clean consumer + shell: bash + run: | + set -euo pipefail + + consumer="$RUNNER_TEMP/cli-logger-consumer" + mkdir -p "$consumer" + cd "$consumer" + + export npm_config_cache="$RUNNER_TEMP/npm-cache" + echo '{ "private": true, "type": "module" }' > package.json + npm install --ignore-scripts --no-audit --no-fund "$RUNNER_TEMP"/sentry-cli-logger-*.tgz @clack/prompts@0.11.0 typescript@^5.8.3 @types/node@^22 + + cat > tsconfig.json <<'EOF' + { + "compilerOptions": { + "module": "NodeNext", + "moduleResolution": "NodeNext", + "noEmit": true, + "strict": true, + "target": "ES2022" + }, + "include": ["index.ts", "require.cts"] + } + EOF + + cat > index.ts <<'EOF' + import { createLogger, type LogObject } from "@sentry/cli-logger"; + import { clackPrompt } from "@sentry/cli-logger/prompt"; + + const records: LogObject[] = []; + const logger = createLogger({ prompt: clackPrompt }); + logger.addReporter({ log: (logObj) => records.push(logObj) }); + logger.withTag("consumer").info("hello"); + const answer: Promise = logger.prompt("Continue?", { + type: "confirm", + }); + void answer; + EOF + + cat > require.cts <<'EOF' + import logger = require("@sentry/cli-logger"); + logger.createLogger().info("hello"); + EOF + + npx tsc --noEmit + + CI=1 node --input-type=module -e ' + import { createLogger } from "@sentry/cli-logger"; + import { clackPrompt } from "@sentry/cli-logger/prompt"; + createLogger({ prompt: clackPrompt }).withTag("esm").info("ok"); + ' 2>&1 | grep -Fx "[info] [esm] ok" + CI=1 node -e ' + const { createLogger } = require("@sentry/cli-logger"); + require("@sentry/cli-logger/prompt"); + createLogger().withTag("cjs").info("ok"); + ' 2>&1 | grep -Fx "[info] [cjs] ok" diff --git a/.github/workflows/merge-jobs.yml b/.github/workflows/merge-jobs.yml index c021908dc..b93feed66 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/cli-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/cli-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 cli-logger + working-directory: packages/cli-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/cli-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..d521cd753 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -10,6 +10,7 @@ on: default: mcp options: - cli + - cli-logger - mcp bump: description: Version bump type @@ -64,6 +65,10 @@ jobs: workspace=packages/cli package_path=packages/cli ;; + cli-logger) + workspace=packages/cli-logger + package_path=packages/cli-logger + ;; mcp) workspace=packages/mcp-server package_path=packages/mcp-server diff --git a/AGENTS.md b/AGENTS.md index 73ded3708..322c67711 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) +│ ├── cli-logger/ # Terminal logger (@sentry/cli-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..013f1ba08 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/cli-logger/` is the CLI's +logger, published to npm as `@sentry/cli-logger`; see its +[README](../packages/cli-logger/README.md). ## Ownership diff --git a/packages/cli-logger/LICENSE.md b/packages/cli-logger/LICENSE.md new file mode 100644 index 000000000..f97b1d1f6 --- /dev/null +++ b/packages/cli-logger/LICENSE.md @@ -0,0 +1,105 @@ +# Functional Source License, Version 1.1, Apache 2.0 Future License + +## Abbreviation + +FSL-1.1-Apache-2.0 + +## Notice + +Copyright 2008-2024 Functional Software, Inc. dba Sentry + +## Terms and Conditions + +### Licensor ("We") + +The party offering the Software under these Terms and Conditions. + +### The Software + +The "Software" is each version of the software that we make available under +these Terms and Conditions, as indicated by our inclusion of these Terms and +Conditions with the Software. + +### License Grant + +Subject to your compliance with this License Grant and the Patents, +Redistribution and Trademark clauses below, we hereby grant you the right to +use, copy, modify, create derivative works, publicly perform, publicly display +and redistribute the Software for any Permitted Purpose identified below. + +### Permitted Purpose + +A Permitted Purpose is any purpose other than a Competing Use. A Competing Use +means making the Software available to others in a commercial product or +service that: + +1. substitutes for the Software; + +2. substitutes for any other product or service we offer using the Software + that exists as of the date we make the Software available; or + +3. offers the same or substantially similar functionality as the Software. + +Permitted Purposes specifically include using the Software: + +1. for your internal use and access; + +2. for non-commercial education; + +3. for non-commercial research; and + +4. in connection with professional services that you provide to a licensee + using the Software in accordance with these Terms and Conditions. + +### Patents + +To the extent your use for a Permitted Purpose would necessarily infringe our +patents, the license grant above includes a license under our patents. If you +make a claim against any party that the Software infringes or contributes to +the infringement of any patent, then your patent license to the Software ends +immediately. + +### Redistribution + +The Terms and Conditions apply to all copies, modifications and derivatives of +the Software. + +If you redistribute any copies, modifications or derivatives of the Software, +you must include a copy of or a link to these Terms and Conditions and not +remove any copyright notices provided in or with the Software. + +### Disclaimer + +THE SOFTWARE IS PROVIDED "AS IS" AND WITHOUT WARRANTIES OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING WITHOUT LIMITATION WARRANTIES OF FITNESS FOR A PARTICULAR +PURPOSE, MERCHANTABILITY, TITLE OR NON-INFRINGEMENT. + +IN NO EVENT WILL WE HAVE ANY LIABILITY TO YOU ARISING OUT OF OR RELATED TO THE +SOFTWARE, INCLUDING INDIRECT, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES, +EVEN IF WE HAVE BEEN INFORMED OF THEIR POSSIBILITY IN ADVANCE. + +### Trademarks + +Except for displaying the License Details and identifying us as the origin of +the Software, you have no right under these Terms and Conditions to use our +trademarks, trade names, service marks or product names. + +## Grant of Future License + +We hereby irrevocably grant you an additional license to use the Software under +the Apache License, Version 2.0 that is effective on the second anniversary of +the date we make the Software available. On or after that date, you may use the +Software under the Apache License, Version 2.0, in which case the following +will apply: + +Licensed under the Apache License, Version 2.0 (the "License"); you may not use +this file except in compliance with the License. + +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed +under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR +CONDITIONS OF ANY KIND, either express or implied. See the License for the +specific language governing permissions and limitations under the License. diff --git a/packages/cli-logger/README.md b/packages/cli-logger/README.md new file mode 100644 index 000000000..36856fc68 --- /dev/null +++ b/packages/cli-logger/README.md @@ -0,0 +1,140 @@ +# @sentry/cli-logger + +A small terminal logger for Node.js command-line tools: log levels, tag scoping, +consola-style stderr output, and log records that +`Sentry.createConsolaReporter()` can forward to Sentry structured logs. The +logger itself depends only on `node:util` and `node:path`. The Sentry CLI uses it in +place of `consola`, whose size slowed every shell completion. + +```sh +npm install @sentry/cli-logger +``` + +## Usage + +```ts +import { createLogger } from "@sentry/cli-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 0.12.0 → 0.13.0"); // hidden at the default level +``` + +All output goes to stderr, so stdout stays free for a command's data. In an +interactive terminal, lines get 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. + +Errors passed to a log method render as their message, stack frames, and +causes. Their other properties, which can hold request or response details, +are left out. + +## 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 | + +A root logger and every logger its `withTag()` calls return 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/cli-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, and the index of each name is its level. + +## Options + +```ts +createLogger({ + level: 3, + env: () => process.env, + formatTime: (date) => date.toISOString(), + prompt: clackPrompt, +}); +``` + +- `level`: the initial level. Defaults to `3` (info). +- `env`: returns the environment the stderr output reads (`CI`, `NO_COLOR`, + `FORCE_COLOR`, `TERM`, ...). It is called on each log call. Defaults to + `process.env`. 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. +- `prompt`: answers `logger.prompt()`. Without one, `logger.prompt()` rejects. + +## Forwarding logs to Sentry + +Log records keep consola's `{ date, args, type, level, tag }` shape, so the +reporter from `@sentry/node` works unchanged: + +```ts +import * as Sentry from "@sentry/node"; + +Sentry.init({ dsn: "...", enableLogs: true }); +logger.addReporter(Sentry.createConsolaReporter()); +``` + +Add the reporter after `Sentry.init()`. `addReporter()` takes any object with a +`log(logObj)` method, so other sinks work the same way. + +## Prompts + +`@sentry/cli-logger/prompt` exports `clackPrompt`, which asks with +[`@clack/prompts`](https://www.npmjs.com/package/@clack/prompts). Install +`@clack/prompts` yourself; it is an optional peer dependency and loads only when +a prompt runs. + +```ts +import { createLogger } from "@sentry/cli-logger"; +import { clackPrompt } from "@sentry/cli-logger/prompt"; + +const logger = createLogger({ prompt: clackPrompt }); + +const ok = await logger.prompt("Continue?", { type: "confirm", initial: true }); +if (ok !== true) { + process.exit(1); +} + +const name = await logger.prompt("Project name:", { + type: "text", + placeholder: "my-project", +}); +if (typeof name !== "string") { + process.exit(1); +} +``` + +A prompt resolves to the answer, or to clack's cancel symbol when the user +presses Ctrl+C. Check for `=== true` or `typeof answer === "string"` rather +than truthiness. + +Any function with the `Prompt` type works as `prompt`, which also makes +prompts easy to stub in tests. + +## Development + +The package lives in [getsentry/toolkit](https://github.com/getsentry/toolkit) +under `packages/cli-logger`. Workspace packages import its TypeScript source +directly; `pnpm --filter @sentry/cli-logger run build` writes the `dist/` +files that the published package's `exports` point to. Releases go through the +repository's Release workflow with the `cli-logger` product. diff --git a/packages/cli-logger/package.json b/packages/cli-logger/package.json new file mode 100644 index 000000000..85698a9a6 --- /dev/null +++ b/packages/cli-logger/package.json @@ -0,0 +1,88 @@ +{ + "name": "@sentry/cli-logger", + "version": "0.1.0", + "description": "A small terminal logger with levels, tags, consola-style stderr output, and Sentry log forwarding", + "type": "module", + "license": "FSL-1.1-Apache-2.0", + "author": "Sentry", + "homepage": "https://github.com/getsentry/toolkit/tree/main/packages/cli-logger#readme", + "keywords": [ + "sentry", + "logger", + "cli", + "consola" + ], + "bugs": { + "url": "https://github.com/getsentry/toolkit/issues" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/getsentry/toolkit.git", + "directory": "packages/cli-logger" + }, + "engines": { + "node": ">=18" + }, + "sideEffects": false, + "files": [ + "dist", + "LICENSE.md", + "README.md" + ], + "exports": { + ".": { + "types": "./src/index.ts", + "default": "./src/index.ts" + }, + "./prompt": { + "types": "./src/prompt.ts", + "default": "./src/prompt.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" + } + }, + "./prompt": { + "import": { + "types": "./dist/prompt.d.ts", + "default": "./dist/prompt.js" + }, + "require": { + "types": "./dist/prompt.d.cts", + "default": "./dist/prompt.cjs" + } + } + } + }, + "scripts": { + "build": "tsdown", + "test": "vitest run", + "test:ci": "vitest run --reporter=default --reporter=junit --outputFile=tests.junit.xml", + "tsc": "tsc --noEmit" + }, + "peerDependencies": { + "@clack/prompts": ">=0.11.0 <2" + }, + "peerDependenciesMeta": { + "@clack/prompts": { + "optional": true + } + }, + "devDependencies": { + "@clack/prompts": "0.11.0", + "@types/node": "catalog:", + "tsdown": "catalog:", + "typescript": "catalog:", + "vitest": "catalog:" + } +} diff --git a/packages/cli-logger/src/format.ts b/packages/cli-logger/src/format.ts new file mode 100644 index 000000000..f19ba0c83 --- /dev/null +++ b/packages/cli-logger/src/format.ts @@ -0,0 +1,244 @@ +/** + * Renders log records the way consola's "fancy" reporter did: 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 { sep } from "node:path"; +import { formatWithOptions } from "node:util"; + +import type { LogObject, LoggerEnv, LogType } from "./types.js"; + +/** How the stderr reporter reads its environment and renders the time. */ +export type FormatOptions = { + env: () => LoggerEnv; + 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: ["→", "→"], +}; + +/** 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(env: LoggerEnv): boolean { + 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(env: LoggerEnv): boolean { + 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(env: LoggerEnv): 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 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 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, 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(INSPECT_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(INSPECT_OPTIONS, error.cause)}`; + } + return `${prefix}${message}\n${frames}${cause}`; +} + +/** Render `logObj` as terminal lines, without a trailing newline. */ +export function formatLogObj( + logObj: LogObject, + { env: getEnv, formatTime }: FormatOptions, +): string { + const env = getEnv(); + const message = formatWithOptions( + INSPECT_OPTIONS, + ...logObj.args.map((arg) => (isErrorLike(arg) ? formatError(arg, 0) : arg)), + ); + if (!useFancyLayout(env)) { + return [`[${logObj.type}]`, logObj.tag && `[${logObj.tag}]`, message] + .filter(Boolean) + .join(" "); + } + + const color = useColor(env); + 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(env) ? 0 : 1], + ); + + const columns = process.stderr.columns || 0; + const tag = logObj.tag ? paint("gray", logObj.tag) : ""; + const date = columns ? paint("gray", formatTime(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; +} diff --git a/packages/cli-logger/src/index.ts b/packages/cli-logger/src/index.ts new file mode 100644 index 000000000..f1cd57266 --- /dev/null +++ b/packages/cli-logger/src/index.ts @@ -0,0 +1,228 @@ +/** + * A small terminal logger with log levels, tag scoping, and consola-style + * stderr output. 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. + * 2. **Any reporter passed to `addReporter()`**, such as + * `Sentry.createConsolaReporter()`. Log records keep consola's + * `{ date, args, type, level, tag }` shape so that reporter reads them. + * + * ## 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 | + * + * Every logger, including the scoped ones `withTag()` returns, shares its + * root's level and reporter list, so a later level change also reaches the + * scoped loggers that modules create at import time. + * + * @module + */ + +import { formatLocalTime, formatLogObj } from "./format.js"; +import { + type ConfirmPromptOptions, + LOG_TYPE_LEVELS, + type LogObject, + type LogReporter, + type LoggerEnv, + type LogType, + type Prompt, + type TextPromptOptions, +} from "./types.js"; + +export { formatLocalTime } from "./format.js"; +export type { + ConfirmPromptOptions, + LogObject, + LogReporter, + LoggerEnv, + LogType, + Prompt, + TextPromptOptions, +} from "./types.js"; + +/** + * 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 string */ +export type LogLevelName = (typeof LOG_LEVEL_NAMES)[number]; + +/** Level 3 shows info, success, start, log, fail — standard user-facing output. */ +const DEFAULT_LOG_LEVEL = 3; + +/** + * Parse a log level name into a numeric level. + * + * Returns {@link DEFAULT_LOG_LEVEL} 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; +} + +/** Options for {@link createLogger}. */ +export type LoggerOptions = { + /** Initial level. Defaults to {@link DEFAULT_LOG_LEVEL} (info). */ + level?: number; + /** + * Returns the environment the stderr reporter reads (`CI`, `NO_COLOR`, + * `FORCE_COLOR`, `TERM`, ...). Called on each log call. Defaults to + * `process.env`. + */ + env?: () => LoggerEnv; + /** Renders the time shown next to the tag. Defaults to {@link formatLocalTime}. */ + formatTime?: (date: Date) => string; + /** + * Answers `logger.prompt()`. Pass `clackPrompt` from + * `@sentry/cli-logger/prompt` to ask with `@clack/prompts`. Without one, + * `logger.prompt()` rejects. + */ + prompt?: Prompt; +}; + +/** Level, reporters, and prompt shared by a root logger and its scoped loggers. */ +type LoggerState = { + level: number; + reporters: LogReporter[]; + prompt: Prompt | undefined; +}; + +/** + * A logger, optionally scoped to a tag shown with each message. Create a root + * logger with {@link createLogger} and a scoped one with `withTag()`. + */ +class Logger { + /** Tag shown with each message; nested tags join with `:`. */ + readonly tag: string; + + readonly #state: LoggerState; + + // 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); + + /** + * Ask the user a question with the `prompt` passed to {@link createLogger}. + * Resolves to the answer, or to a cancel symbol on Ctrl+C. + */ + readonly prompt: Prompt; + + constructor(state: LoggerState, tag: string) { + this.#state = state; + this.tag = tag; + this.prompt = (( + message: string, + opts: ConfirmPromptOptions | TextPromptOptions, + ) => { + if (!state.prompt) { + return Promise.reject( + new Error( + "No prompt is configured. Pass `prompt` to createLogger(), such as `clackPrompt` from @sentry/cli-logger/prompt.", + ), + ); + } + return state.prompt(message, opts as ConfirmPromptOptions); + }) as Prompt; + } + + /** 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 messages carry `tag`, nested under this logger's tag. */ + withTag(tag: string): Logger { + return new Logger(this.#state, this.tag ? `${this.tag}:${tag}` : tag); + } + + /** Send every later log call, from this logger and its relatives, to `reporter` too. */ + addReporter(reporter: LogReporter): 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 logObj: LogObject = { + date: new Date(), + args, + type, + level, + tag: this.tag, + }; + for (const reporter of this.#state.reporters) { + reporter.log(logObj); + } + } +} + +/** + * Create a root logger that writes to stderr. + * + * ```ts + * import { createLogger } from "@sentry/cli-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 { + const format = { + env: options.env ?? (() => process.env), + formatTime: options.formatTime ?? formatLocalTime, + }; + const stderrReporter: LogReporter = { + log: (logObj) => { + process.stderr.write(`${formatLogObj(logObj, format)}\n`); + }, + }; + return new Logger( + { + level: options.level ?? DEFAULT_LOG_LEVEL, + reporters: [stderrReporter], + prompt: options.prompt, + }, + "", + ); +} + +export type { Logger }; diff --git a/packages/cli-logger/src/prompt.ts b/packages/cli-logger/src/prompt.ts new file mode 100644 index 000000000..d8a7d7c98 --- /dev/null +++ b/packages/cli-logger/src/prompt.ts @@ -0,0 +1,44 @@ +/** + * A {@link Prompt} backed by `@clack/prompts`, for `createLogger({ prompt })`. + * + * It lives in its own entry point so the logger itself never references + * `@clack/prompts`. `@clack/prompts` loads only when a prompt runs. + * + * @module + */ + +import type { + ConfirmPromptOptions, + Prompt, + TextPromptOptions, +} from "./types.js"; + +/** + * Ask with `@clack/prompts`. Resolves to the answer, or to clack's cancel + * symbol when the user presses Ctrl+C. + */ +function ask( + message: string, + opts: ConfirmPromptOptions, +): Promise; +function ask( + message: string, + opts: TextPromptOptions, +): Promise; +async function ask( + message: string, + opts: ConfirmPromptOptions | TextPromptOptions, +): Promise { + 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, + }); +} + +export const clackPrompt: Prompt = ask; diff --git a/packages/cli-logger/src/types.ts b/packages/cli-logger/src/types.ts new file mode 100644 index 000000000..d60d1f03b --- /dev/null +++ b/packages/cli-logger/src/types.ts @@ -0,0 +1,59 @@ +/** Types shared by the logger, its stderr reporter, and the prompt adapter. */ + +/** Level of each log method: a call prints when it 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 `warn`. */ +export 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; +}; + +/** Environment variables the logger reads, such as `NO_COLOR` and `CI`. */ +export type LoggerEnv = Record; + +/** Options for a yes/no prompt. */ +export type ConfirmPromptOptions = { type: "confirm"; initial?: boolean }; + +/** Options for a free-text prompt. */ +export type TextPromptOptions = { + type: "text"; + placeholder?: string; + initial?: string; + default?: string; +}; + +/** + * Asks the user a question on the terminal. + * + * Resolves to the answer, or to a cancel symbol when the user presses Ctrl+C, + * so callers check for `=== true` or `typeof answer === "string"`. + */ +export type Prompt = { + (message: string, opts: ConfirmPromptOptions): Promise; + (message: string, opts: TextPromptOptions): Promise; +}; diff --git a/packages/cli-logger/test/logger.test.ts b/packages/cli-logger/test/logger.test.ts new file mode 100644 index 000000000..6c2376ebd --- /dev/null +++ b/packages/cli-logger/test/logger.test.ts @@ -0,0 +1,287 @@ +import { + afterEach, + beforeEach, + describe, + expect, + type MockInstance, + test, + vi, +} from "vitest"; +import { + createLogger, + formatLocalTime, + LOG_LEVEL_NAMES, + type LogObject, + parseLogLevel, +} from "../src/index.js"; +import { clackPrompt } from "../src/prompt.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 and trims whitespace", () => { + expect(parseLogLevel("ERROR")).toBe(0); + 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("42")).toBe(3); + }); +}); + +describe("LOG_LEVEL_NAMES", () => { + test("index is the numeric level", () => { + expect(LOG_LEVEL_NAMES).toEqual([ + "error", + "warn", + "log", + "info", + "debug", + "trace", + ]); + }); +}); + +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(new Date("2024-01-15T14:28:59.000Z"))).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("levels and reporters", () => { + let stderr: MockInstance; + + beforeEach(() => { + stderr = vi.spyOn(process.stderr, "write").mockImplementation(() => true); + }); + + afterEach(() => { + stderr.mockRestore(); + }); + + test("scoped loggers share the root's level, including ones created earlier", () => { + const logger = createLogger(); + const child = logger.withTag("parent"); + const grandchild = child.withTag("sub"); + expect(grandchild.level).toBe(3); + + logger.level = 5; + expect(child.level).toBe(5); + expect(grandchild.level).toBe(5); + + grandchild.level = 0; + expect(logger.level).toBe(0); + }); + + test("separate root loggers do not share state", () => { + const first = createLogger({ level: 0 }); + const second = createLogger(); + first.level = 5; + expect(second.level).toBe(3); + }); + + test("reporters receive consola-shaped records that Sentry's reporter reads", () => { + const records: LogObject[] = []; + const logger = createLogger(); + // The reporter list is shared, so a reporter added through the root + // logger also sees records from scoped loggers. + const scoped = logger.withTag("upgrade").withTag("delta"); + logger.addReporter({ log: (logObj) => records.push(logObj) }); + + 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", + }, + ]); + }); + + test("methods stay bound when passed around", () => { + const records: LogObject[] = []; + const logger = createLogger(); + logger.addReporter({ log: (logObj) => records.push(logObj) }); + const { info } = logger.withTag("detached"); + info("hello"); + expect(records).toMatchObject([{ type: "info", tag: "detached" }]); + }); +}); + +describe("stderr output", () => { + let stderr: MockInstance; + + /** Text written to stderr so far. */ + const written = () => + stderr.mock.calls.map((call) => String(call[0])).join(""); + + beforeEach(() => { + stderr = vi.spyOn(process.stderr, "write").mockImplementation(() => true); + }); + + afterEach(() => { + stderr.mockRestore(); + }); + + test("writes plain [type] [tag] lines in CI", () => { + const logger = createLogger({ env: () => ({ CI: "true" }) }); + logger.withTag("auth").info("Logged in as %s", "jane"); + expect(written()).toBe("[info] [auth] Logged in as jane\n"); + }); + + test("reads the env on each call", () => { + let env: Record = { CI: "true" }; + const logger = createLogger({ env: () => env }); + logger.info("plain"); + env = { NO_COLOR: "1" }; + logger.info("fancy"); + expect(written()).toBe("[info] plain\nℹ fancy\n"); + }); + + test("writes an error's message, stack, and cause but not its other properties", () => { + const logger = createLogger({ 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", () => { + const logger = createLogger({ + env: () => ({ NODE_ENV: "production", 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", + ); + }); + + test("right-aligns the tag and the formatted time on wide terminals", () => { + const columns = Object.getOwnPropertyDescriptor(process.stderr, "columns"); + Object.defineProperty(process.stderr, "columns", { + value: 80, + configurable: true, + }); + try { + const logger = createLogger({ + env: () => ({ NO_COLOR: "1" }), + formatTime: () => "T", + }); + logger.withTag("auth").info("hi"); + const line = written(); + expect(line).toBe(`ℹ hi${" ".repeat(68)}auth T\n`); + } finally { + if (columns) { + Object.defineProperty(process.stderr, "columns", columns); + } else { + delete (process.stderr as { columns?: number }).columns; + } + } + }); +}); + +describe("prompt", () => { + test("rejects when no prompt is configured", async () => { + await expect( + createLogger().prompt("Continue?", { type: "confirm" }), + ).rejects.toThrow("No prompt is configured"); + }); + + test("scoped loggers use the root's prompt", async () => { + const prompt = vi.fn().mockResolvedValue("answer"); + const logger = createLogger({ prompt }); + await expect( + logger.withTag("x").prompt("Name?", { type: "text" }), + ).resolves.toBe("answer"); + expect(prompt).toHaveBeenCalledWith("Name?", { type: "text" }); + }); + + test("clackPrompt returns clack's cancel symbol instead of the initial value", async () => { + const cancel = Symbol("clack:cancel"); + clack.confirm.mockResolvedValue(cancel); + + const answer = await clackPrompt("Import from .sentryclirc?", { + type: "confirm", + initial: true, + }); + + expect(clack.confirm).toHaveBeenCalledWith({ + message: "Import from .sentryclirc?", + initialValue: true, + }); + expect(answer).toBe(cancel); + }); + + test("clackPrompt passes text prompt options through to clack", async () => { + clack.text.mockResolvedValue("my-project"); + + const answer = await clackPrompt("Type the project slug:", { + type: "text", + placeholder: "slug", + default: "fallback", + }); + + expect(clack.text).toHaveBeenCalledWith({ + message: "Type the project slug:", + placeholder: "slug", + initialValue: undefined, + defaultValue: "fallback", + }); + expect(answer).toBe("my-project"); + }); +}); diff --git a/packages/cli-logger/tsconfig.json b/packages/cli-logger/tsconfig.json new file mode 100644 index 000000000..e7654fd0f --- /dev/null +++ b/packages/cli-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/cli-logger/tsdown.config.ts b/packages/cli-logger/tsdown.config.ts new file mode 100644 index 000000000..c9d1d867e --- /dev/null +++ b/packages/cli-logger/tsdown.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from "tsdown"; + +export default defineConfig({ + entry: ["src/index.ts", "src/prompt.ts"], + format: ["cjs", "esm"], + target: "node18", + platform: "node", + dts: true, + sourcemap: true, + clean: true, +}); diff --git a/packages/cli/package.json b/packages/cli/package.json index 84d5dfad8..cc863ee31 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -103,6 +103,7 @@ "@hono/node-server": "^2.1.3", "@mastra/client-js": "^1.38.0", "@sentry/api": "catalog:", + "@sentry/cli-logger": "workspace:*", "@sentry/core": "catalog:", "@sentry/mcp-server": "workspace:*", "@sentry/node": "catalog:", diff --git a/packages/cli/src/lib/logger.ts b/packages/cli/src/lib/logger.ts index 94e5007ce..90b5c3235 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 + * The logger comes from `@sentry/cli-logger` (`packages/cli-logger`): levels, + * tag scoping, consola-style stderr output, and consola-shaped log records. + * 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. + * 1. **The stderr reporter** (built in) — renders consola's "fancy" layout 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. **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). + * structured logs via `_INTERNAL_captureLog`. 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 | + * See `packages/cli-logger/README.md` for the log levels. * * ## Usage * @@ -50,18 +39,21 @@ * - `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 { + type ConfirmPromptOptions, + createLogger, + type LogReporter, + parseLogLevel, + type TextPromptOptions, +} from "@sentry/cli-logger"; import { getEnv } from "./env.js"; -import { formatLogTime } from "./timezone.js"; + +export { LOG_LEVEL_NAMES, parseLogLevel } from "@sentry/cli-logger"; +export type { LogLevelName, LogObject, LogReporter } from "@sentry/cli-logger"; /** * Environment variable name for controlling CLI log verbosity. @@ -71,51 +63,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,50 +79,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 @@ -184,7 +87,7 @@ type TextPromptOptions = { declare const SENTRY_CLI_COMPLETION_BUILD: boolean | undefined; /** - * Ask the user a question on the terminal. + * Ask the user a question on the terminal with `@clack/prompts`. * * 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"`. @@ -209,80 +112,8 @@ async function askUser( } 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); - } + const { clackPrompt } = await import("@sentry/cli-logger/prompt"); + return await clackPrompt(message, opts as ConfirmPromptOptions); } } @@ -303,7 +134,7 @@ 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({ env: getEnv, prompt: askUser }); /** * Write an already-formatted line to stderr verbatim, bypassing the logger. @@ -371,216 +202,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/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/lib/logger.test.ts b/packages/cli/test/lib/logger.test.ts index 98305916e..7fb904cb8 100644 --- a/packages/cli/test/lib/logger.test.ts +++ b/packages/cli/test/lib/logger.test.ts @@ -1,20 +1,20 @@ /** - * 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, the library-mode env, and prompts. The logger itself + * is tested in packages/cli-logger. */ import { createConsolaReporter } 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"; @@ -26,57 +26,6 @@ const clack = vi.hoisted(() => ({ 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"); @@ -291,106 +240,25 @@ describe("attachSentryReporter", () => { }); }); -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", - }, - ]); - }); -}); - 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", - ); }); }); 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..bfdb80ff2 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/cli-logger", "@sentry/toolkit-core/sentry-host", "@sentry/toolkit-core/sentry-origin", ]; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 34b47fec2..c91310c65 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -471,6 +471,9 @@ importers: '@sentry/api': specifier: 'catalog:' version: 0.428.0(typescript@5.9.3)(valibot@1.4.2(typescript@5.9.3))(zod@4.6.7) + '@sentry/cli-logger': + specifier: workspace:* + version: link:../cli-logger '@sentry/core': specifier: 'catalog:' version: 11.6.0 @@ -634,6 +637,24 @@ importers: specifier: 3.4.0 version: 3.4.0 + packages/cli-logger: + devDependencies: + '@clack/prompts': + specifier: 0.11.0 + version: 0.11.0 + '@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': From b3f62c1d4c574c8bffb0d9c6eeec9efbf2749bee Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Sun, 1 Nov 2026 14:36:30 +0000 Subject: [PATCH 2/5] ref(logger): Rename to @sentry/logger with structured reporters Address review feedback on the package: - Rename @sentry/cli-logger to @sentry/logger (packages/logger), since nothing in it is CLI-specific. Start at 1.0.0-dev.0 under MIT. - Restructure around structured records: each call becomes a LogRecord (level, type, tag, args, attributes) that fans out to reporters. Ship fancyReporter (consola-style), jsonReporter, logfmtReporter, and sentryReporter, which takes the SDK's `logger` so the package has no Sentry dependency. Add withAttributes() for structured fields. - Move prompts out of the package into the CLI (src/lib/prompt.ts), so the logger has no dependencies at all. - Drop the logger's preReleaseCommand: Craft v3 bumps package.json in the workspace directory through the npm target. - Keep the packed-consumer check as real files under test/package/consumer, run by test/package/check.sh. - Explain in the README why we wrote another logger. The CLI now forwards logs with sentryReporter(Sentry.logger), so its Sentry log attributes change from consola.* to logger.tag/logger.type. --- .craft.yml | 16 +- .github/workflows/cli-logger-package.yml | 100 ----- .github/workflows/logger-package.yml | 43 +++ .github/workflows/merge-jobs.yml | 10 +- .github/workflows/release.yml | 8 +- AGENTS.md | 2 +- docs/README.md | 6 +- packages/cli-logger/LICENSE.md | 105 ------ packages/cli-logger/README.md | 140 ------- packages/cli-logger/src/index.ts | 228 ------------ packages/cli-logger/src/prompt.ts | 44 --- packages/cli-logger/src/types.ts | 59 --- packages/cli-logger/test/logger.test.ts | 287 -------------- packages/cli/package.json | 2 +- packages/cli/src/cli.ts | 9 +- packages/cli/src/commands/auth/login.ts | 3 +- packages/cli/src/commands/cli/defaults.ts | 3 +- packages/cli/src/commands/cli/feedback.ts | 4 +- packages/cli/src/commands/cli/import.ts | 3 +- packages/cli/src/commands/issue/plan.ts | 3 +- packages/cli/src/commands/log/view.ts | 3 +- packages/cli/src/commands/trial/start.ts | 3 +- packages/cli/src/lib/logger.ts | 85 ++--- packages/cli/src/lib/mutate-command.ts | 8 +- packages/cli/src/lib/prompt.ts | 64 ++++ packages/cli/src/lib/seer-trial.ts | 3 +- packages/cli/src/lib/telemetry.ts | 3 +- packages/cli/test/commands/auth/login.test.ts | 14 +- .../cli/test/commands/issue/plan.func.test.ts | 7 +- packages/cli/test/commands/log/view.test.ts | 8 +- .../cli/test/commands/project/delete.test.ts | 16 +- packages/cli/test/lib/logger.test.ts | 58 +-- packages/cli/test/lib/prompt.test.ts | 46 +++ packages/cli/test/lib/seer-trial.test.ts | 46 +-- packages/cli/test/script/cli-startup.test.ts | 2 +- packages/logger/LICENSE | 21 ++ packages/logger/README.md | 193 ++++++++++ packages/{cli-logger => logger}/package.json | 42 +-- packages/logger/src/fields.ts | 35 ++ packages/logger/src/index.ts | 35 ++ packages/logger/src/levels.ts | 36 ++ packages/logger/src/logger.ts | 129 +++++++ packages/logger/src/message.ts | 64 ++++ .../src/reporters/fancy.ts} | 138 +++---- packages/logger/src/reporters/json.ts | 26 ++ packages/logger/src/reporters/logfmt.ts | 58 +++ packages/logger/src/reporters/sentry.ts | 64 ++++ packages/logger/src/types.ts | 59 +++ packages/logger/test/logger.test.ts | 352 ++++++++++++++++++ packages/logger/test/package/check.sh | 22 ++ packages/logger/test/package/consumer/cjs.cts | 15 + packages/logger/test/package/consumer/esm.ts | 33 ++ .../logger/test/package/consumer/package.json | 4 + .../test/package/consumer/tsconfig.json | 11 + packages/{cli-logger => logger}/tsconfig.json | 2 +- .../{cli-logger => logger}/tsdown.config.ts | 2 +- pnpm-lock.yaml | 11 +- 57 files changed, 1517 insertions(+), 1276 deletions(-) delete mode 100644 .github/workflows/cli-logger-package.yml create mode 100644 .github/workflows/logger-package.yml delete mode 100644 packages/cli-logger/LICENSE.md delete mode 100644 packages/cli-logger/README.md delete mode 100644 packages/cli-logger/src/index.ts delete mode 100644 packages/cli-logger/src/prompt.ts delete mode 100644 packages/cli-logger/src/types.ts delete mode 100644 packages/cli-logger/test/logger.test.ts create mode 100644 packages/cli/src/lib/prompt.ts create mode 100644 packages/cli/test/lib/prompt.test.ts create mode 100644 packages/logger/LICENSE create mode 100644 packages/logger/README.md rename packages/{cli-logger => logger}/package.json (59%) create mode 100644 packages/logger/src/fields.ts create mode 100644 packages/logger/src/index.ts create mode 100644 packages/logger/src/levels.ts create mode 100644 packages/logger/src/logger.ts create mode 100644 packages/logger/src/message.ts rename packages/{cli-logger/src/format.ts => logger/src/reporters/fancy.ts} (61%) create mode 100644 packages/logger/src/reporters/json.ts create mode 100644 packages/logger/src/reporters/logfmt.ts create mode 100644 packages/logger/src/reporters/sentry.ts create mode 100644 packages/logger/src/types.ts create mode 100644 packages/logger/test/logger.test.ts create mode 100755 packages/logger/test/package/check.sh create mode 100644 packages/logger/test/package/consumer/cjs.cts create mode 100644 packages/logger/test/package/consumer/esm.ts create mode 100644 packages/logger/test/package/consumer/package.json create mode 100644 packages/logger/test/package/consumer/tsconfig.json rename packages/{cli-logger => logger}/tsconfig.json (85%) rename packages/{cli-logger => logger}/tsdown.config.ts (80%) diff --git a/.craft.yml b/.craft.yml index 8d3bd2531..76610423b 100644 --- a/.craft.yml +++ b/.craft.yml @@ -103,20 +103,18 @@ workspaces: assert_match version.to_s, shell_output("#{bin}/sentry --version").chomp end end - packages/cli-logger: + # No preReleaseCommand: Craft bumps package.json through the npm target. + packages/logger: changelog: policy: auto - preReleaseCommand: >- - bash -c 'cd packages/cli-logger && npm version "$CRAFT_NEW_VERSION" - --no-git-tag-version' - releaseBranchPrefix: release/cli-logger + releaseBranchPrefix: release/logger targets: - name: github - tagPrefix: "cli-logger@" - includeNames: '/^sentry-cli-logger-\d.*\.tgz$/' + tagPrefix: "logger@" + includeNames: '/^sentry-logger-\d.*\.tgz$/' - name: npm - id: "@sentry/cli-logger" - includeNames: '/^sentry-cli-logger-\d.*\.tgz$/' + id: "@sentry/logger" + includeNames: '/^sentry-logger-\d.*\.tgz$/' packages/mcp-server: changelogPolicy: none preReleaseCommand: >- diff --git a/.github/workflows/cli-logger-package.yml b/.github/workflows/cli-logger-package.yml deleted file mode 100644 index 1f973519e..000000000 --- a/.github/workflows/cli-logger-package.yml +++ /dev/null @@ -1,100 +0,0 @@ -name: CLI Logger Package - -on: - push: - branches: [main] - pull_request: - paths: - - ".github/workflows/cli-logger-package.yml" - - "packages/cli-logger/**" - - "pnpm-lock.yaml" - -permissions: - contents: read - -jobs: - package-consumer: - name: Clean Consumer Check - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v4 - - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: "22" - - # pnpm/action-setup@v4 - - uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda - name: Install pnpm - with: - run_install: false - - - name: Install dependencies - run: pnpm install --frozen-lockfile - - - name: Build and pack - run: | - pnpm --filter @sentry/cli-logger run build - pnpm --dir packages/cli-logger pack --pack-destination "$RUNNER_TEMP" - - # The workspace imports the TypeScript source; the packed package.json - # must point its exports at dist/ instead (publishConfig.exports). - - name: Import and typecheck from a clean consumer - shell: bash - run: | - set -euo pipefail - - consumer="$RUNNER_TEMP/cli-logger-consumer" - mkdir -p "$consumer" - cd "$consumer" - - export npm_config_cache="$RUNNER_TEMP/npm-cache" - echo '{ "private": true, "type": "module" }' > package.json - npm install --ignore-scripts --no-audit --no-fund "$RUNNER_TEMP"/sentry-cli-logger-*.tgz @clack/prompts@0.11.0 typescript@^5.8.3 @types/node@^22 - - cat > tsconfig.json <<'EOF' - { - "compilerOptions": { - "module": "NodeNext", - "moduleResolution": "NodeNext", - "noEmit": true, - "strict": true, - "target": "ES2022" - }, - "include": ["index.ts", "require.cts"] - } - EOF - - cat > index.ts <<'EOF' - import { createLogger, type LogObject } from "@sentry/cli-logger"; - import { clackPrompt } from "@sentry/cli-logger/prompt"; - - const records: LogObject[] = []; - const logger = createLogger({ prompt: clackPrompt }); - logger.addReporter({ log: (logObj) => records.push(logObj) }); - logger.withTag("consumer").info("hello"); - const answer: Promise = logger.prompt("Continue?", { - type: "confirm", - }); - void answer; - EOF - - cat > require.cts <<'EOF' - import logger = require("@sentry/cli-logger"); - logger.createLogger().info("hello"); - EOF - - npx tsc --noEmit - - CI=1 node --input-type=module -e ' - import { createLogger } from "@sentry/cli-logger"; - import { clackPrompt } from "@sentry/cli-logger/prompt"; - createLogger({ prompt: clackPrompt }).withTag("esm").info("ok"); - ' 2>&1 | grep -Fx "[info] [esm] ok" - CI=1 node -e ' - const { createLogger } = require("@sentry/cli-logger"); - require("@sentry/cli-logger/prompt"); - createLogger().withTag("cjs").info("ok"); - ' 2>&1 | grep -Fx "[info] [cjs] ok" diff --git a/.github/workflows/logger-package.yml b/.github/workflows/logger-package.yml new file mode 100644 index 000000000..6e88c881d --- /dev/null +++ b/.github/workflows/logger-package.yml @@ -0,0 +1,43 @@ +name: Logger Package + +on: + push: + branches: [main] + pull_request: + paths: + - ".github/workflows/logger-package.yml" + - "packages/logger/**" + - "pnpm-lock.yaml" + +permissions: + contents: read + +jobs: + package-consumer: + name: Clean Consumer Check + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: "22" + + # pnpm/action-setup@v4 + - uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda + name: Install pnpm + with: + run_install: false + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + # The workspace imports the TypeScript source; the packed package.json + # must point its exports at dist/ instead (publishConfig.exports). + # The consumer project lives in packages/logger/test/package/consumer. + - name: Pack and check from a clean consumer + env: + npm_config_cache: ${{ runner.temp }}/npm-cache + run: pnpm --filter @sentry/logger run test:package diff --git a/.github/workflows/merge-jobs.yml b/.github/workflows/merge-jobs.yml index b93feed66..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/**", "release/cli-logger/**"] + 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...' --filter @sentry/cli-logger --if-present run build + run: pnpm --filter '@sentry/mcp-server...' --filter @sentry/logger --if-present run build - name: Run linter run: pnpm lint @@ -38,8 +38,8 @@ jobs: working-directory: packages/mcp-server run: pnpm pack --pack-destination dist - - name: Package cli-logger - working-directory: packages/cli-logger + - name: Package logger + working-directory: packages/logger run: pnpm pack --pack-destination dist - name: Archive artifacts @@ -48,7 +48,7 @@ jobs: name: ${{ github.sha }} path: | ${{ github.workspace }}/packages/mcp-server/dist/*.tgz - ${{ github.workspace }}/packages/cli-logger/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 d521cd753..47a665636 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -10,7 +10,7 @@ on: default: mcp options: - cli - - cli-logger + - logger - mcp bump: description: Version bump type @@ -65,9 +65,9 @@ jobs: workspace=packages/cli package_path=packages/cli ;; - cli-logger) - workspace=packages/cli-logger - package_path=packages/cli-logger + logger) + workspace=packages/logger + package_path=packages/logger ;; mcp) workspace=packages/mcp-server diff --git a/AGENTS.md b/AGENTS.md index 322c67711..f50faf526 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -22,7 +22,7 @@ toolkit/ │ └── local/ # Sentry Local browser viewer ├── packages/ │ ├── cli/ # Sentry CLI (see packages/cli/AGENTS.md) -│ ├── cli-logger/ # Terminal logger (@sentry/cli-logger on npm) +│ ├── 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 013f1ba08..5b464571e 100644 --- a/docs/README.md +++ b/docs/README.md @@ -29,9 +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. `packages/cli-logger/` is the CLI's -logger, published to npm as `@sentry/cli-logger`; see its -[README](../packages/cli-logger/README.md). +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-logger/LICENSE.md b/packages/cli-logger/LICENSE.md deleted file mode 100644 index f97b1d1f6..000000000 --- a/packages/cli-logger/LICENSE.md +++ /dev/null @@ -1,105 +0,0 @@ -# Functional Source License, Version 1.1, Apache 2.0 Future License - -## Abbreviation - -FSL-1.1-Apache-2.0 - -## Notice - -Copyright 2008-2024 Functional Software, Inc. dba Sentry - -## Terms and Conditions - -### Licensor ("We") - -The party offering the Software under these Terms and Conditions. - -### The Software - -The "Software" is each version of the software that we make available under -these Terms and Conditions, as indicated by our inclusion of these Terms and -Conditions with the Software. - -### License Grant - -Subject to your compliance with this License Grant and the Patents, -Redistribution and Trademark clauses below, we hereby grant you the right to -use, copy, modify, create derivative works, publicly perform, publicly display -and redistribute the Software for any Permitted Purpose identified below. - -### Permitted Purpose - -A Permitted Purpose is any purpose other than a Competing Use. A Competing Use -means making the Software available to others in a commercial product or -service that: - -1. substitutes for the Software; - -2. substitutes for any other product or service we offer using the Software - that exists as of the date we make the Software available; or - -3. offers the same or substantially similar functionality as the Software. - -Permitted Purposes specifically include using the Software: - -1. for your internal use and access; - -2. for non-commercial education; - -3. for non-commercial research; and - -4. in connection with professional services that you provide to a licensee - using the Software in accordance with these Terms and Conditions. - -### Patents - -To the extent your use for a Permitted Purpose would necessarily infringe our -patents, the license grant above includes a license under our patents. If you -make a claim against any party that the Software infringes or contributes to -the infringement of any patent, then your patent license to the Software ends -immediately. - -### Redistribution - -The Terms and Conditions apply to all copies, modifications and derivatives of -the Software. - -If you redistribute any copies, modifications or derivatives of the Software, -you must include a copy of or a link to these Terms and Conditions and not -remove any copyright notices provided in or with the Software. - -### Disclaimer - -THE SOFTWARE IS PROVIDED "AS IS" AND WITHOUT WARRANTIES OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING WITHOUT LIMITATION WARRANTIES OF FITNESS FOR A PARTICULAR -PURPOSE, MERCHANTABILITY, TITLE OR NON-INFRINGEMENT. - -IN NO EVENT WILL WE HAVE ANY LIABILITY TO YOU ARISING OUT OF OR RELATED TO THE -SOFTWARE, INCLUDING INDIRECT, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES, -EVEN IF WE HAVE BEEN INFORMED OF THEIR POSSIBILITY IN ADVANCE. - -### Trademarks - -Except for displaying the License Details and identifying us as the origin of -the Software, you have no right under these Terms and Conditions to use our -trademarks, trade names, service marks or product names. - -## Grant of Future License - -We hereby irrevocably grant you an additional license to use the Software under -the Apache License, Version 2.0 that is effective on the second anniversary of -the date we make the Software available. On or after that date, you may use the -Software under the Apache License, Version 2.0, in which case the following -will apply: - -Licensed under the Apache License, Version 2.0 (the "License"); you may not use -this file except in compliance with the License. - -You may obtain a copy of the License at - -http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software distributed -under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR -CONDITIONS OF ANY KIND, either express or implied. See the License for the -specific language governing permissions and limitations under the License. diff --git a/packages/cli-logger/README.md b/packages/cli-logger/README.md deleted file mode 100644 index 36856fc68..000000000 --- a/packages/cli-logger/README.md +++ /dev/null @@ -1,140 +0,0 @@ -# @sentry/cli-logger - -A small terminal logger for Node.js command-line tools: log levels, tag scoping, -consola-style stderr output, and log records that -`Sentry.createConsolaReporter()` can forward to Sentry structured logs. The -logger itself depends only on `node:util` and `node:path`. The Sentry CLI uses it in -place of `consola`, whose size slowed every shell completion. - -```sh -npm install @sentry/cli-logger -``` - -## Usage - -```ts -import { createLogger } from "@sentry/cli-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 0.12.0 → 0.13.0"); // hidden at the default level -``` - -All output goes to stderr, so stdout stays free for a command's data. In an -interactive terminal, lines get 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. - -Errors passed to a log method render as their message, stack frames, and -causes. Their other properties, which can hold request or response details, -are left out. - -## 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 | - -A root logger and every logger its `withTag()` calls return 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/cli-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, and the index of each name is its level. - -## Options - -```ts -createLogger({ - level: 3, - env: () => process.env, - formatTime: (date) => date.toISOString(), - prompt: clackPrompt, -}); -``` - -- `level`: the initial level. Defaults to `3` (info). -- `env`: returns the environment the stderr output reads (`CI`, `NO_COLOR`, - `FORCE_COLOR`, `TERM`, ...). It is called on each log call. Defaults to - `process.env`. 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. -- `prompt`: answers `logger.prompt()`. Without one, `logger.prompt()` rejects. - -## Forwarding logs to Sentry - -Log records keep consola's `{ date, args, type, level, tag }` shape, so the -reporter from `@sentry/node` works unchanged: - -```ts -import * as Sentry from "@sentry/node"; - -Sentry.init({ dsn: "...", enableLogs: true }); -logger.addReporter(Sentry.createConsolaReporter()); -``` - -Add the reporter after `Sentry.init()`. `addReporter()` takes any object with a -`log(logObj)` method, so other sinks work the same way. - -## Prompts - -`@sentry/cli-logger/prompt` exports `clackPrompt`, which asks with -[`@clack/prompts`](https://www.npmjs.com/package/@clack/prompts). Install -`@clack/prompts` yourself; it is an optional peer dependency and loads only when -a prompt runs. - -```ts -import { createLogger } from "@sentry/cli-logger"; -import { clackPrompt } from "@sentry/cli-logger/prompt"; - -const logger = createLogger({ prompt: clackPrompt }); - -const ok = await logger.prompt("Continue?", { type: "confirm", initial: true }); -if (ok !== true) { - process.exit(1); -} - -const name = await logger.prompt("Project name:", { - type: "text", - placeholder: "my-project", -}); -if (typeof name !== "string") { - process.exit(1); -} -``` - -A prompt resolves to the answer, or to clack's cancel symbol when the user -presses Ctrl+C. Check for `=== true` or `typeof answer === "string"` rather -than truthiness. - -Any function with the `Prompt` type works as `prompt`, which also makes -prompts easy to stub in tests. - -## Development - -The package lives in [getsentry/toolkit](https://github.com/getsentry/toolkit) -under `packages/cli-logger`. Workspace packages import its TypeScript source -directly; `pnpm --filter @sentry/cli-logger run build` writes the `dist/` -files that the published package's `exports` point to. Releases go through the -repository's Release workflow with the `cli-logger` product. diff --git a/packages/cli-logger/src/index.ts b/packages/cli-logger/src/index.ts deleted file mode 100644 index f1cd57266..000000000 --- a/packages/cli-logger/src/index.ts +++ /dev/null @@ -1,228 +0,0 @@ -/** - * A small terminal logger with log levels, tag scoping, and consola-style - * stderr output. 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. - * 2. **Any reporter passed to `addReporter()`**, such as - * `Sentry.createConsolaReporter()`. Log records keep consola's - * `{ date, args, type, level, tag }` shape so that reporter reads them. - * - * ## 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 | - * - * Every logger, including the scoped ones `withTag()` returns, shares its - * root's level and reporter list, so a later level change also reaches the - * scoped loggers that modules create at import time. - * - * @module - */ - -import { formatLocalTime, formatLogObj } from "./format.js"; -import { - type ConfirmPromptOptions, - LOG_TYPE_LEVELS, - type LogObject, - type LogReporter, - type LoggerEnv, - type LogType, - type Prompt, - type TextPromptOptions, -} from "./types.js"; - -export { formatLocalTime } from "./format.js"; -export type { - ConfirmPromptOptions, - LogObject, - LogReporter, - LoggerEnv, - LogType, - Prompt, - TextPromptOptions, -} from "./types.js"; - -/** - * 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 string */ -export type LogLevelName = (typeof LOG_LEVEL_NAMES)[number]; - -/** Level 3 shows info, success, start, log, fail — standard user-facing output. */ -const DEFAULT_LOG_LEVEL = 3; - -/** - * Parse a log level name into a numeric level. - * - * Returns {@link DEFAULT_LOG_LEVEL} 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; -} - -/** Options for {@link createLogger}. */ -export type LoggerOptions = { - /** Initial level. Defaults to {@link DEFAULT_LOG_LEVEL} (info). */ - level?: number; - /** - * Returns the environment the stderr reporter reads (`CI`, `NO_COLOR`, - * `FORCE_COLOR`, `TERM`, ...). Called on each log call. Defaults to - * `process.env`. - */ - env?: () => LoggerEnv; - /** Renders the time shown next to the tag. Defaults to {@link formatLocalTime}. */ - formatTime?: (date: Date) => string; - /** - * Answers `logger.prompt()`. Pass `clackPrompt` from - * `@sentry/cli-logger/prompt` to ask with `@clack/prompts`. Without one, - * `logger.prompt()` rejects. - */ - prompt?: Prompt; -}; - -/** Level, reporters, and prompt shared by a root logger and its scoped loggers. */ -type LoggerState = { - level: number; - reporters: LogReporter[]; - prompt: Prompt | undefined; -}; - -/** - * A logger, optionally scoped to a tag shown with each message. Create a root - * logger with {@link createLogger} and a scoped one with `withTag()`. - */ -class Logger { - /** Tag shown with each message; nested tags join with `:`. */ - readonly tag: string; - - readonly #state: LoggerState; - - // 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); - - /** - * Ask the user a question with the `prompt` passed to {@link createLogger}. - * Resolves to the answer, or to a cancel symbol on Ctrl+C. - */ - readonly prompt: Prompt; - - constructor(state: LoggerState, tag: string) { - this.#state = state; - this.tag = tag; - this.prompt = (( - message: string, - opts: ConfirmPromptOptions | TextPromptOptions, - ) => { - if (!state.prompt) { - return Promise.reject( - new Error( - "No prompt is configured. Pass `prompt` to createLogger(), such as `clackPrompt` from @sentry/cli-logger/prompt.", - ), - ); - } - return state.prompt(message, opts as ConfirmPromptOptions); - }) as Prompt; - } - - /** 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 messages carry `tag`, nested under this logger's tag. */ - withTag(tag: string): Logger { - return new Logger(this.#state, this.tag ? `${this.tag}:${tag}` : tag); - } - - /** Send every later log call, from this logger and its relatives, to `reporter` too. */ - addReporter(reporter: LogReporter): 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 logObj: LogObject = { - date: new Date(), - args, - type, - level, - tag: this.tag, - }; - for (const reporter of this.#state.reporters) { - reporter.log(logObj); - } - } -} - -/** - * Create a root logger that writes to stderr. - * - * ```ts - * import { createLogger } from "@sentry/cli-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 { - const format = { - env: options.env ?? (() => process.env), - formatTime: options.formatTime ?? formatLocalTime, - }; - const stderrReporter: LogReporter = { - log: (logObj) => { - process.stderr.write(`${formatLogObj(logObj, format)}\n`); - }, - }; - return new Logger( - { - level: options.level ?? DEFAULT_LOG_LEVEL, - reporters: [stderrReporter], - prompt: options.prompt, - }, - "", - ); -} - -export type { Logger }; diff --git a/packages/cli-logger/src/prompt.ts b/packages/cli-logger/src/prompt.ts deleted file mode 100644 index d8a7d7c98..000000000 --- a/packages/cli-logger/src/prompt.ts +++ /dev/null @@ -1,44 +0,0 @@ -/** - * A {@link Prompt} backed by `@clack/prompts`, for `createLogger({ prompt })`. - * - * It lives in its own entry point so the logger itself never references - * `@clack/prompts`. `@clack/prompts` loads only when a prompt runs. - * - * @module - */ - -import type { - ConfirmPromptOptions, - Prompt, - TextPromptOptions, -} from "./types.js"; - -/** - * Ask with `@clack/prompts`. Resolves to the answer, or to clack's cancel - * symbol when the user presses Ctrl+C. - */ -function ask( - message: string, - opts: ConfirmPromptOptions, -): Promise; -function ask( - message: string, - opts: TextPromptOptions, -): Promise; -async function ask( - message: string, - opts: ConfirmPromptOptions | TextPromptOptions, -): Promise { - 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, - }); -} - -export const clackPrompt: Prompt = ask; diff --git a/packages/cli-logger/src/types.ts b/packages/cli-logger/src/types.ts deleted file mode 100644 index d60d1f03b..000000000 --- a/packages/cli-logger/src/types.ts +++ /dev/null @@ -1,59 +0,0 @@ -/** Types shared by the logger, its stderr reporter, and the prompt adapter. */ - -/** Level of each log method: a call prints when it 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 `warn`. */ -export 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; -}; - -/** Environment variables the logger reads, such as `NO_COLOR` and `CI`. */ -export type LoggerEnv = Record; - -/** Options for a yes/no prompt. */ -export type ConfirmPromptOptions = { type: "confirm"; initial?: boolean }; - -/** Options for a free-text prompt. */ -export type TextPromptOptions = { - type: "text"; - placeholder?: string; - initial?: string; - default?: string; -}; - -/** - * Asks the user a question on the terminal. - * - * Resolves to the answer, or to a cancel symbol when the user presses Ctrl+C, - * so callers check for `=== true` or `typeof answer === "string"`. - */ -export type Prompt = { - (message: string, opts: ConfirmPromptOptions): Promise; - (message: string, opts: TextPromptOptions): Promise; -}; diff --git a/packages/cli-logger/test/logger.test.ts b/packages/cli-logger/test/logger.test.ts deleted file mode 100644 index 6c2376ebd..000000000 --- a/packages/cli-logger/test/logger.test.ts +++ /dev/null @@ -1,287 +0,0 @@ -import { - afterEach, - beforeEach, - describe, - expect, - type MockInstance, - test, - vi, -} from "vitest"; -import { - createLogger, - formatLocalTime, - LOG_LEVEL_NAMES, - type LogObject, - parseLogLevel, -} from "../src/index.js"; -import { clackPrompt } from "../src/prompt.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 and trims whitespace", () => { - expect(parseLogLevel("ERROR")).toBe(0); - 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("42")).toBe(3); - }); -}); - -describe("LOG_LEVEL_NAMES", () => { - test("index is the numeric level", () => { - expect(LOG_LEVEL_NAMES).toEqual([ - "error", - "warn", - "log", - "info", - "debug", - "trace", - ]); - }); -}); - -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(new Date("2024-01-15T14:28:59.000Z"))).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("levels and reporters", () => { - let stderr: MockInstance; - - beforeEach(() => { - stderr = vi.spyOn(process.stderr, "write").mockImplementation(() => true); - }); - - afterEach(() => { - stderr.mockRestore(); - }); - - test("scoped loggers share the root's level, including ones created earlier", () => { - const logger = createLogger(); - const child = logger.withTag("parent"); - const grandchild = child.withTag("sub"); - expect(grandchild.level).toBe(3); - - logger.level = 5; - expect(child.level).toBe(5); - expect(grandchild.level).toBe(5); - - grandchild.level = 0; - expect(logger.level).toBe(0); - }); - - test("separate root loggers do not share state", () => { - const first = createLogger({ level: 0 }); - const second = createLogger(); - first.level = 5; - expect(second.level).toBe(3); - }); - - test("reporters receive consola-shaped records that Sentry's reporter reads", () => { - const records: LogObject[] = []; - const logger = createLogger(); - // The reporter list is shared, so a reporter added through the root - // logger also sees records from scoped loggers. - const scoped = logger.withTag("upgrade").withTag("delta"); - logger.addReporter({ log: (logObj) => records.push(logObj) }); - - 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", - }, - ]); - }); - - test("methods stay bound when passed around", () => { - const records: LogObject[] = []; - const logger = createLogger(); - logger.addReporter({ log: (logObj) => records.push(logObj) }); - const { info } = logger.withTag("detached"); - info("hello"); - expect(records).toMatchObject([{ type: "info", tag: "detached" }]); - }); -}); - -describe("stderr output", () => { - let stderr: MockInstance; - - /** Text written to stderr so far. */ - const written = () => - stderr.mock.calls.map((call) => String(call[0])).join(""); - - beforeEach(() => { - stderr = vi.spyOn(process.stderr, "write").mockImplementation(() => true); - }); - - afterEach(() => { - stderr.mockRestore(); - }); - - test("writes plain [type] [tag] lines in CI", () => { - const logger = createLogger({ env: () => ({ CI: "true" }) }); - logger.withTag("auth").info("Logged in as %s", "jane"); - expect(written()).toBe("[info] [auth] Logged in as jane\n"); - }); - - test("reads the env on each call", () => { - let env: Record = { CI: "true" }; - const logger = createLogger({ env: () => env }); - logger.info("plain"); - env = { NO_COLOR: "1" }; - logger.info("fancy"); - expect(written()).toBe("[info] plain\nℹ fancy\n"); - }); - - test("writes an error's message, stack, and cause but not its other properties", () => { - const logger = createLogger({ 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", () => { - const logger = createLogger({ - env: () => ({ NODE_ENV: "production", 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", - ); - }); - - test("right-aligns the tag and the formatted time on wide terminals", () => { - const columns = Object.getOwnPropertyDescriptor(process.stderr, "columns"); - Object.defineProperty(process.stderr, "columns", { - value: 80, - configurable: true, - }); - try { - const logger = createLogger({ - env: () => ({ NO_COLOR: "1" }), - formatTime: () => "T", - }); - logger.withTag("auth").info("hi"); - const line = written(); - expect(line).toBe(`ℹ hi${" ".repeat(68)}auth T\n`); - } finally { - if (columns) { - Object.defineProperty(process.stderr, "columns", columns); - } else { - delete (process.stderr as { columns?: number }).columns; - } - } - }); -}); - -describe("prompt", () => { - test("rejects when no prompt is configured", async () => { - await expect( - createLogger().prompt("Continue?", { type: "confirm" }), - ).rejects.toThrow("No prompt is configured"); - }); - - test("scoped loggers use the root's prompt", async () => { - const prompt = vi.fn().mockResolvedValue("answer"); - const logger = createLogger({ prompt }); - await expect( - logger.withTag("x").prompt("Name?", { type: "text" }), - ).resolves.toBe("answer"); - expect(prompt).toHaveBeenCalledWith("Name?", { type: "text" }); - }); - - test("clackPrompt returns clack's cancel symbol instead of the initial value", async () => { - const cancel = Symbol("clack:cancel"); - clack.confirm.mockResolvedValue(cancel); - - const answer = await clackPrompt("Import from .sentryclirc?", { - type: "confirm", - initial: true, - }); - - expect(clack.confirm).toHaveBeenCalledWith({ - message: "Import from .sentryclirc?", - initialValue: true, - }); - expect(answer).toBe(cancel); - }); - - test("clackPrompt passes text prompt options through to clack", async () => { - clack.text.mockResolvedValue("my-project"); - - const answer = await clackPrompt("Type the project slug:", { - type: "text", - placeholder: "slug", - default: "fallback", - }); - - expect(clack.text).toHaveBeenCalledWith({ - message: "Type the project slug:", - placeholder: "slug", - initialValue: undefined, - defaultValue: "fallback", - }); - expect(answer).toBe("my-project"); - }); -}); diff --git a/packages/cli/package.json b/packages/cli/package.json index cc863ee31..0da5ada0b 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -103,8 +103,8 @@ "@hono/node-server": "^2.1.3", "@mastra/client-js": "^1.38.0", "@sentry/api": "catalog:", - "@sentry/cli-logger": "workspace:*", "@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 90b5c3235..0582db6af 100644 --- a/packages/cli/src/lib/logger.ts +++ b/packages/cli/src/lib/logger.ts @@ -1,20 +1,20 @@ /** * Structured logging for Sentry CLI. * - * The logger comes from `@sentry/cli-logger` (`packages/cli-logger`): levels, - * tag scoping, consola-style stderr output, and consola-shaped log records. - * This module configures it for the CLI. 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 and + * 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. **Sentry.createConsolaReporter()** — auto-forwards all log messages to Sentry - * structured logs via `_INTERNAL_captureLog`. Library mode drops these logs - * in `beforeSendLog` (see telemetry.ts). + * 2. **`sentryReporter(Sentry.logger)`** — forwards all log messages to Sentry + * structured logs. Library mode drops these logs in `beforeSendLog` (see + * telemetry.ts). * - * See `packages/cli-logger/README.md` for the log levels. + * Prompts live in `prompt.ts`. See `packages/logger/README.md` for the log + * levels. * * ## Usage * @@ -43,17 +43,16 @@ */ import { - type ConfirmPromptOptions, createLogger, - type LogReporter, + fancyReporter, parseLogLevel, - type TextPromptOptions, -} from "@sentry/cli-logger"; + type Reporter, +} from "@sentry/logger"; import { getEnv } from "./env.js"; -export { LOG_LEVEL_NAMES, parseLogLevel } from "@sentry/cli-logger"; -export type { LogLevelName, LogObject, LogReporter } from "@sentry/cli-logger"; +export { LOG_LEVEL_NAMES, parseLogLevel } from "@sentry/logger"; +export type { LogLevelName, LogRecord, Reporter } from "@sentry/logger"; /** * Environment variable name for controlling CLI log verbosity. @@ -79,44 +78,6 @@ export function getEnvLogLevel(): number | null { return null; } -/** - * 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 with `@clack/prompts`. - * - * 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 { clackPrompt } = await import("@sentry/cli-logger/prompt"); - return await clackPrompt(message, opts as ConfirmPromptOptions); - } -} - /** * Global CLI logger instance. * @@ -134,7 +95,9 @@ async function askUser( * by bin.ts calling `setLogLevel(getEnvLogLevel())` — not at module load time — * so tests can override the env var after import. */ -export const logger = createLogger({ env: getEnv, prompt: askUser }); +export const logger = createLogger({ + reporters: [fancyReporter({ env: getEnv })], +}); /** * Write an already-formatted line to stderr verbatim, bypassing the logger. @@ -168,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; } 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/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 7fb904cb8..25f35bc3a 100644 --- a/packages/cli/test/lib/logger.test.ts +++ b/packages/cli/test/lib/logger.test.ts @@ -2,11 +2,12 @@ * Unit tests for the CLI's logger configuration. * * Tests getEnvLogLevel, setLogLevel (with withTag propagation), - * attachSentryReporter, the library-mode env, and prompts. The logger itself - * is tested in packages/cli-logger. + * 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 { @@ -19,13 +20,6 @@ import { setLogLevel, } from "../../src/lib/logger.js"; -const clack = vi.hoisted(() => ({ - confirm: vi.fn(), - text: vi.fn(), -})); - -vi.mock("@clack/prompts", () => clack); - describe("LOG_LEVEL_ENV_VAR", () => { test("is SENTRY_LOG_LEVEL", () => { expect(LOG_LEVEL_ENV_VAR).toBe("SENTRY_LOG_LEVEL"); @@ -229,14 +223,18 @@ 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(); + expect(() => + attachSentryReporter(sentryReporter(sentryLogger)), + ).not.toThrow(); }); }); @@ -261,37 +259,3 @@ describe("stderr output", () => { } }); }); - -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/script/cli-startup.test.ts b/packages/cli/test/script/cli-startup.test.ts index bfdb80ff2..243b143de 100644 --- a/packages/cli/test/script/cli-startup.test.ts +++ b/packages/cli/test/script/cli-startup.test.ts @@ -11,7 +11,7 @@ const ANY_IMPORT = /.*/; * side effects. */ const PURE_SENTRY_IMPORTS = [ - "@sentry/cli-logger", + "@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..023448ae8 --- /dev/null +++ b/packages/logger/README.md @@ -0,0 +1,193 @@ +# @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, or booleans. The JSON and logfmt +reporters write them as fields, and the Sentry reporter sends them as log +attributes. + +### 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 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) }] }); +``` + +## 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/cli-logger/package.json b/packages/logger/package.json similarity index 59% rename from packages/cli-logger/package.json rename to packages/logger/package.json index 85698a9a6..6b5a201e4 100644 --- a/packages/cli-logger/package.json +++ b/packages/logger/package.json @@ -1,15 +1,17 @@ { - "name": "@sentry/cli-logger", - "version": "0.1.0", - "description": "A small terminal logger with levels, tags, consola-style stderr output, and Sentry log forwarding", + "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": "FSL-1.1-Apache-2.0", + "license": "MIT", "author": "Sentry", - "homepage": "https://github.com/getsentry/toolkit/tree/main/packages/cli-logger#readme", + "homepage": "https://github.com/getsentry/toolkit/tree/main/packages/logger#readme", "keywords": [ "sentry", "logger", - "cli", + "logging", + "structured-logging", + "logfmt", "consola" ], "bugs": { @@ -18,7 +20,7 @@ "repository": { "type": "git", "url": "git+https://github.com/getsentry/toolkit.git", - "directory": "packages/cli-logger" + "directory": "packages/logger" }, "engines": { "node": ">=18" @@ -26,17 +28,13 @@ "sideEffects": false, "files": [ "dist", - "LICENSE.md", + "LICENSE", "README.md" ], "exports": { ".": { "types": "./src/index.ts", "default": "./src/index.ts" - }, - "./prompt": { - "types": "./src/prompt.ts", - "default": "./src/prompt.ts" } }, "publishConfig": { @@ -51,16 +49,6 @@ "types": "./dist/index.d.cts", "default": "./dist/index.cjs" } - }, - "./prompt": { - "import": { - "types": "./dist/prompt.d.ts", - "default": "./dist/prompt.js" - }, - "require": { - "types": "./dist/prompt.d.cts", - "default": "./dist/prompt.cjs" - } } } }, @@ -68,18 +56,10 @@ "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" }, - "peerDependencies": { - "@clack/prompts": ">=0.11.0 <2" - }, - "peerDependenciesMeta": { - "@clack/prompts": { - "optional": true - } - }, "devDependencies": { - "@clack/prompts": "0.11.0", "@types/node": "catalog:", "tsdown": "catalog:", "typescript": "catalog:", diff --git a/packages/logger/src/fields.ts b/packages/logger/src/fields.ts new file mode 100644 index 000000000..a9b0122f4 --- /dev/null +++ b/packages/logger/src/fields.ts @@ -0,0 +1,35 @@ +/** The flat fields that the JSON and logfmt reporters write. */ + +import { LOG_LEVEL_NAMES } from "./levels.js"; +import { formatMessage } from "./message.js"; +import type { 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"]); + +/** + * A record as ordered key-value pairs: `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): [string, LogAttributeValue][] { + const level = LOG_LEVEL_NAMES[record.level] ?? "info"; + const fields: [string, LogAttributeValue][] = [ + ["time", record.date.toISOString()], + ["level", level], + ]; + if (record.type !== level) { + fields.push(["type", record.type]); + } + if (record.tag) { + fields.push(["tag", record.tag]); + } + fields.push(["msg", formatMessage(record.args, { stack: true })]); + for (const [key, value] of Object.entries(record.attributes)) { + if (!BUILT_IN_FIELDS.has(key)) { + fields.push([key, value]); + } + } + return fields; +} diff --git a/packages/logger/src/index.ts b/packages/logger/src/index.ts new file mode 100644 index 000000000..3d5624de6 --- /dev/null +++ b/packages/logger/src/index.ts @@ -0,0 +1,35 @@ +/** + * 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 { + 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/cli-logger/src/format.ts b/packages/logger/src/reporters/fancy.ts similarity index 61% rename from packages/cli-logger/src/format.ts rename to packages/logger/src/reporters/fancy.ts index f19ba0c83..bad295ac1 100644 --- a/packages/cli-logger/src/format.ts +++ b/packages/logger/src/reporters/fancy.ts @@ -1,18 +1,30 @@ /** - * Renders log records the way consola's "fancy" reporter did: 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. + * 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 { sep } from "node:path"; -import { formatWithOptions } from "node:util"; - -import type { LogObject, LoggerEnv, LogType } from "./types.js"; - -/** How the stderr reporter reads its environment and renders the time. */ -export type FormatOptions = { - env: () => LoggerEnv; - formatTime: (date: Date) => string; +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; }; /** @@ -105,23 +117,23 @@ function isEnabled(value: string | undefined): boolean { * 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: LoggerEnv): boolean { +function useFancyLayout(env: LogEnv): boolean { 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(env: LoggerEnv): boolean { +/** 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(process.stderr.isTTY) && env.TERM !== "dumb"; + return Boolean(stream.isTTY) && env.TERM !== "dumb"; } /** Whether the terminal can draw the Unicode icons. */ -function supportsUnicode(env: LoggerEnv): boolean { +function supportsUnicode(env: LogEnv): boolean { if (process.platform !== "win32") { return env.TERM !== "linux"; } @@ -142,66 +154,21 @@ function visibleWidth(text: string): number { return text.replace(ANSI_PATTERN, "").length; } -/** 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, 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(INSPECT_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(INSPECT_OPTIONS, error.cause)}`; - } - return `${prefix}${message}\n${frames}${cause}`; -} - -/** Render `logObj` as terminal lines, without a trailing newline. */ -export function formatLogObj( - logObj: LogObject, - { env: getEnv, formatTime }: FormatOptions, +/** Render `record` as terminal lines, without a trailing newline. */ +function formatRecord( + record: LogRecord, + env: LogEnv, + stream: LogStream, + formatTime: (date: Date) => string, ): string { - const env = getEnv(); - const message = formatWithOptions( - INSPECT_OPTIONS, - ...logObj.args.map((arg) => (isErrorLike(arg) ? formatError(arg, 0) : arg)), - ); + const message = formatMessage(record.args, { stack: true }); if (!useFancyLayout(env)) { - return [`[${logObj.type}]`, logObj.tag && `[${logObj.tag}]`, message] + return [`[${record.type}]`, record.tag && `[${record.tag}]`, message] .filter(Boolean) .join(" "); } - const color = useColor(env); + const color = useColor(env, stream); const paint = (style: AnsiStyle, text: string): string => { if (!(color && text)) { return text; @@ -218,17 +185,17 @@ export function formatLogObj( ); const [firstLine = "", ...additional] = message.split("\n"); - const badge = BADGE_BACKGROUNDS[logObj.level]; + const badge = BADGE_BACKGROUNDS[record.level]; const type = badge - ? paint(badge, paint("black", ` ${logObj.type.toUpperCase()} `)) + ? paint(badge, paint("black", ` ${record.type.toUpperCase()} `)) : paint( - TYPE_COLORS[logObj.type], - TYPE_ICONS[logObj.type][supportsUnicode(env) ? 0 : 1], + TYPE_COLORS[record.type], + TYPE_ICONS[record.type][supportsUnicode(env) ? 0 : 1], ); - const columns = process.stderr.columns || 0; - const tag = logObj.tag ? paint("gray", logObj.tag) : ""; - const date = columns ? paint("gray", formatTime(logObj.date)) : ""; + const columns = stream.columns || 0; + const tag = record.tag ? paint("gray", record.tag) : ""; + const date = columns ? paint("gray", formatTime(record.date)) : ""; const left = [type, markup(firstLine)].filter(Boolean).join(" "); const right = [tag, date].filter(Boolean).join(" "); const space = columns - visibleWidth(left) - visibleWidth(right) - 2; @@ -242,3 +209,18 @@ export function formatLogObj( } 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..d2eeefb7a --- /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(Object.fromEntries(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..8f51754b6 --- /dev/null +++ b/packages/logger/src/reporters/logfmt.ts @@ -0,0 +1,58 @@ +/** logfmt lines (`key=value key="quoted value"`), one per log call. */ + +import { recordFields } from "../fields.js"; +import type { 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", +}; + +/** Keys keep only characters that cannot break the `key=value` layout. */ +const UNSAFE_KEY_CHARACTERS = /[^\w.:/-]/g; + +function formatValue(value: LogAttributeValue): 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 + * ``` + */ +export function logfmtReporter(options: StreamReporterOptions = {}): Reporter { + return { + log(record) { + const line = recordFields(record) + .map( + ([key, value]) => + `${key.replace(UNSAFE_KEY_CHARACTERS, "_")}=${formatValue(value)}`, + ) + .join(" "); + (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..df5c6bafa --- /dev/null +++ b/packages/logger/src/reporters/sentry.ts @@ -0,0 +1,64 @@ +/** + * 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 { formatMessage } from "../message.js"; +import type { 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, 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) { + sentryLogger[SEVERITIES[record.type]]( + formatMessage(record.args, { stack: false }), + { + ...record.attributes, + ...(record.tag && { "logger.tag": record.tag }), + "logger.type": record.type, + "sentry.origin": ORIGIN, + }, + ); + }, + }; +} diff --git a/packages/logger/src/types.ts b/packages/logger/src/types.ts new file mode 100644 index 000000000..25fd53343 --- /dev/null +++ b/packages/logger/src/types.ts @@ -0,0 +1,59 @@ +/** 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. */ +export type LogAttributeValue = string | number | boolean; + +/** 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..b0440c931 --- /dev/null +++ b/packages/logger/test/logger.test.ts @@ -0,0 +1,352 @@ +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', + ]); + }); +}); + +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, "bad key": "x=y" }, + }); + + 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 bad_key="x=y"\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("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/cli-logger/tsconfig.json b/packages/logger/tsconfig.json similarity index 85% rename from packages/cli-logger/tsconfig.json rename to packages/logger/tsconfig.json index e7654fd0f..d89cf4aec 100644 --- a/packages/cli-logger/tsconfig.json +++ b/packages/logger/tsconfig.json @@ -10,5 +10,5 @@ "skipLibCheck": true, "isolatedModules": true }, - "include": ["src/**/*.ts", "test/**/*.ts"] + "include": ["src/**/*.ts", "test/*.ts"] } diff --git a/packages/cli-logger/tsdown.config.ts b/packages/logger/tsdown.config.ts similarity index 80% rename from packages/cli-logger/tsdown.config.ts rename to packages/logger/tsdown.config.ts index c9d1d867e..1c9be5d58 100644 --- a/packages/cli-logger/tsdown.config.ts +++ b/packages/logger/tsdown.config.ts @@ -1,7 +1,7 @@ import { defineConfig } from "tsdown"; export default defineConfig({ - entry: ["src/index.ts", "src/prompt.ts"], + entry: ["src/index.ts"], format: ["cjs", "esm"], target: "node18", platform: "node", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c91310c65..d67903827 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -471,12 +471,12 @@ importers: '@sentry/api': specifier: 'catalog:' version: 0.428.0(typescript@5.9.3)(valibot@1.4.2(typescript@5.9.3))(zod@4.6.7) - '@sentry/cli-logger': - specifier: workspace:* - version: link:../cli-logger '@sentry/core': specifier: 'catalog:' version: 11.6.0 + '@sentry/logger': + specifier: workspace:* + version: link:../logger '@sentry/mcp-server': specifier: workspace:* version: link:../mcp-server @@ -637,11 +637,8 @@ importers: specifier: 3.4.0 version: 3.4.0 - packages/cli-logger: + packages/logger: devDependencies: - '@clack/prompts': - specifier: 0.11.0 - version: 0.11.0 '@types/node': specifier: 'catalog:' version: 22.16.0 From cd271021d01e6799e2daa0d8894f4ae1bef31f64 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Sun, 1 Nov 2026 15:24:52 +0000 Subject: [PATCH 3/5] ref(logger): Run the package check in Test, use Craft placeholders - Drop the separate Logger Package workflow. The package declares `sentryCi.e2e: test:package`, so the Test workflow's project matrix packs it and runs the clean consumer check whenever the logger or a dependent changes. - Use Craft's {{workspaceName}} and {{packageSimpleName}} placeholders for the logger workspace's branch prefix, tag prefix, and tarball names. Craft 3.5 resolves them to the same values as before. - Follow logfmt's key rules: only spaces, `=`, `"`, and control characters are replaced, so dotted keys like `http.status_code` pass through unchanged. --- .craft.yml | 9 +++--- .github/workflows/logger-package.yml | 43 ------------------------- packages/logger/package.json | 3 ++ packages/logger/src/reporters/logfmt.ts | 11 +++++-- packages/logger/test/logger.test.ts | 11 +++++-- 5 files changed, 24 insertions(+), 53 deletions(-) delete mode 100644 .github/workflows/logger-package.yml diff --git a/.craft.yml b/.craft.yml index 76610423b..cd6d29968 100644 --- a/.craft.yml +++ b/.craft.yml @@ -107,14 +107,13 @@ workspaces: packages/logger: changelog: policy: auto - releaseBranchPrefix: release/logger + releaseBranchPrefix: "release/{{workspaceName}}" targets: - name: github - tagPrefix: "logger@" - includeNames: '/^sentry-logger-\d.*\.tgz$/' + tagPrefix: "{{workspaceName}}@" + includeNames: '/^{{packageSimpleName}}-\d.*\.tgz$/' - name: npm - id: "@sentry/logger" - includeNames: '/^sentry-logger-\d.*\.tgz$/' + includeNames: '/^{{packageSimpleName}}-\d.*\.tgz$/' packages/mcp-server: changelogPolicy: none preReleaseCommand: >- diff --git a/.github/workflows/logger-package.yml b/.github/workflows/logger-package.yml deleted file mode 100644 index 6e88c881d..000000000 --- a/.github/workflows/logger-package.yml +++ /dev/null @@ -1,43 +0,0 @@ -name: Logger Package - -on: - push: - branches: [main] - pull_request: - paths: - - ".github/workflows/logger-package.yml" - - "packages/logger/**" - - "pnpm-lock.yaml" - -permissions: - contents: read - -jobs: - package-consumer: - name: Clean Consumer Check - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v4 - - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: "22" - - # pnpm/action-setup@v4 - - uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda - name: Install pnpm - with: - run_install: false - - - name: Install dependencies - run: pnpm install --frozen-lockfile - - # The workspace imports the TypeScript source; the packed package.json - # must point its exports at dist/ instead (publishConfig.exports). - # The consumer project lives in packages/logger/test/package/consumer. - - name: Pack and check from a clean consumer - env: - npm_config_cache: ${{ runner.temp }}/npm-cache - run: pnpm --filter @sentry/logger run test:package diff --git a/packages/logger/package.json b/packages/logger/package.json index 6b5a201e4..0dd81bb13 100644 --- a/packages/logger/package.json +++ b/packages/logger/package.json @@ -26,6 +26,9 @@ "node": ">=18" }, "sideEffects": false, + "sentryCi": { + "e2e": "test:package" + }, "files": [ "dist", "LICENSE", diff --git a/packages/logger/src/reporters/logfmt.ts b/packages/logger/src/reporters/logfmt.ts index 8f51754b6..0a432e455 100644 --- a/packages/logger/src/reporters/logfmt.ts +++ b/packages/logger/src/reporters/logfmt.ts @@ -20,8 +20,13 @@ const ESCAPES: Record = { "\t": "\\t", }; -/** Keys keep only characters that cannot break the `key=value` layout. */ -const UNSAFE_KEY_CHARACTERS = /[^\w.:/-]/g; +/** + * 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_CHARACTERS = /[\s="\x00-\x1f\x7f]/g; function formatValue(value: LogAttributeValue): string { const text = String(value); @@ -49,7 +54,7 @@ export function logfmtReporter(options: StreamReporterOptions = {}): Reporter { const line = recordFields(record) .map( ([key, value]) => - `${key.replace(UNSAFE_KEY_CHARACTERS, "_")}=${formatValue(value)}`, + `${key.replace(INVALID_KEY_CHARACTERS, "_")}=${formatValue(value)}`, ) .join(" "); (options.stream ?? process.stderr).write(`${line}\n`); diff --git a/packages/logger/test/logger.test.ts b/packages/logger/test/logger.test.ts index b0440c931..11932a903 100644 --- a/packages/logger/test/logger.test.ts +++ b/packages/logger/test/logger.test.ts @@ -281,11 +281,18 @@ describe("logfmtReporter", () => { type: "warn", level: 1, tag: "chat", - attributes: { user: "jane doe", count: 2, ok: true, "bad key": "x=y" }, + 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 bad_key="x=y"\n', + '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', ); }); }); From 1dd94a85f8df47bb95c815d365995753eed0dbd8 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Sun, 1 Nov 2026 15:34:11 +0000 Subject: [PATCH 4/5] perf(logger): Add a benchmark and trim reporter hot paths Measure the logger against the cheapest code that does the same job, and cut the costs the benchmark found. - Add `pnpm --filter @sentry/logger run bench`, which times each case in its own Node process, and record its results in the README. - Format each record's ISO timestamp once per second instead of calling `toISOString()` per line. - Build the structured fields as an object, so the JSON reporter stringifies it directly; keep a `__proto__` attribute as a field. - Measure visible width with a loop instead of an ANSI regex replace, and skip the markup regexes when a line has no delimiters. - Test logfmt keys before replacing characters in them. Output is byte-identical to the previous build across about 24,600 formatted records. --- packages/logger/README.md | 25 +++ packages/logger/bench/run.mjs | 202 ++++++++++++++++++++++++ packages/logger/package.json | 1 + packages/logger/src/fields.ts | 66 ++++++-- packages/logger/src/reporters/fancy.ts | 112 ++++++++----- packages/logger/src/reporters/json.ts | 2 +- packages/logger/src/reporters/logfmt.ts | 21 ++- packages/logger/test/logger.test.ts | 47 ++++++ 8 files changed, 417 insertions(+), 59 deletions(-) create mode 100644 packages/logger/bench/run.mjs diff --git a/packages/logger/README.md b/packages/logger/README.md index 023448ae8..09ffdb3f6 100644 --- a/packages/logger/README.md +++ b/packages/logger/README.md @@ -181,6 +181,31 @@ 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 | +| Sentry structured log | 78 ns (`util.format` + `Sentry.logger.info`) | 314 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) diff --git a/packages/logger/bench/run.mjs b/packages/logger/bench/run.mjs new file mode 100644 index 000000000..9ef87c82f --- /dev/null +++ b/packages/logger/bench/run.mjs @@ -0,0 +1,202 @@ +// 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"]], + ["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", + 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 "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).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 index 0dd81bb13..cbb12de6a 100644 --- a/packages/logger/package.json +++ b/packages/logger/package.json @@ -56,6 +56,7 @@ } }, "scripts": { + "bench": "tsdown && node bench/run.mjs", "build": "tsdown", "test": "vitest run", "test:ci": "vitest run --reporter=default --reporter=junit --outputFile=tests.junit.xml", diff --git a/packages/logger/src/fields.ts b/packages/logger/src/fields.ts index a9b0122f4..addf02115 100644 --- a/packages/logger/src/fields.ts +++ b/packages/logger/src/fields.ts @@ -7,28 +7,64 @@ import type { 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 = ""; + /** - * A record as ordered key-value pairs: `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. + * `date.toISOString()`, formatting each second only once. `toISOString()` + * costs more than the rest of a JSON line, and log lines mostly share a + * second. */ -export function recordFields(record: LogRecord): [string, LogAttributeValue][] { +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: [string, LogAttributeValue][] = [ - ["time", record.date.toISOString()], - ["level", level], - ]; + const fields: Record = { + time: isoTime(record.date), + level, + }; if (record.type !== level) { - fields.push(["type", record.type]); + fields.type = record.type; } if (record.tag) { - fields.push(["tag", record.tag]); + fields.tag = record.tag; } - fields.push(["msg", formatMessage(record.args, { stack: true })]); - for (const [key, value] of Object.entries(record.attributes)) { - if (!BUILT_IN_FIELDS.has(key)) { - fields.push([key, value]); + fields.msg = formatMessage(record.args, { stack: true }); + const { attributes } = record; + for (const key of Object.keys(attributes)) { + if (BUILT_IN_FIELDS.has(key)) { + continue; + } + const value = attributes[key] as LogAttributeValue; + if (key === "__proto__") { + // Assigning `__proto__` would set the prototype instead of a field. + Object.defineProperty(fields, key, { + value, + enumerable: true, + writable: true, + configurable: true, + }); + } else { + fields[key] = value; } } return fields; diff --git a/packages/logger/src/reporters/fancy.ts b/packages/logger/src/reporters/fancy.ts index bad295ac1..b32dcb14c 100644 --- a/packages/logger/src/reporters/fancy.ts +++ b/packages/logger/src/reporters/fancy.ts @@ -98,10 +98,6 @@ const TYPE_ICONS: Record = { 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; @@ -147,11 +143,62 @@ function supportsUnicode(env: LogEnv): boolean { } /** - * Approximate visible width of a line: its length without ANSI escapes. Wide - * characters count as one column, which only shifts the right-aligned tag. + * 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 { - return text.replace(ANSI_PATTERN, "").length; + 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. */ @@ -163,49 +210,42 @@ function formatRecord( ): string { const message = formatMessage(record.args, { stack: true }); if (!useFancyLayout(env)) { - return [`[${record.type}]`, record.tag && `[${record.tag}]`, message] - .filter(Boolean) - .join(" "); + return joinWords( + joinWords(`[${record.type}]`, record.tag && `[${record.tag}]`), + message, + ); } const color = useColor(env, stream); - 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 newline = message.indexOf("\n"); + const firstLine = newline === -1 ? message : message.slice(0, newline); const badge = BADGE_BACKGROUNDS[record.level]; const type = badge - ? paint(badge, paint("black", ` ${record.type.toUpperCase()} `)) + ? 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("gray", record.tag) : ""; - const date = columns ? paint("gray", formatTime(record.date)) : ""; - const left = [type, markup(firstLine)].filter(Boolean).join(" "); - const right = [tag, date].filter(Boolean).join(" "); - const space = columns - visibleWidth(left) - visibleWidth(right) - 2; + 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 && columns >= 80 + space > 0 ? left + " ".repeat(space) + right - : (right ? `${paint("gray", `[${right}]`)} ` : "") + left; - if (additional.length > 0) { - line += markup(`\n${additional.join("\n")}`); + : (right ? `${paint(color, "gray", `[${right}]`)} ` : "") + left; + if (newline !== -1) { + line += markup(color, message.slice(newline)); } return badge ? `\n${line}\n` : line; } diff --git a/packages/logger/src/reporters/json.ts b/packages/logger/src/reporters/json.ts index d2eeefb7a..da44871ca 100644 --- a/packages/logger/src/reporters/json.ts +++ b/packages/logger/src/reporters/json.ts @@ -19,7 +19,7 @@ export type StreamReporterOptions = { export function jsonReporter(options: StreamReporterOptions = {}): Reporter { return { log(record) { - const line = JSON.stringify(Object.fromEntries(recordFields(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 index 0a432e455..6a137c050 100644 --- a/packages/logger/src/reporters/logfmt.ts +++ b/packages/logger/src/reporters/logfmt.ts @@ -26,7 +26,15 @@ const ESCAPES: Record = { * go-logfmt and node-logfmt. */ // oxlint-disable-next-line no-control-regex -- control characters must be replaced -const INVALID_KEY_CHARACTERS = /[\s="\x00-\x1f\x7f]/g; +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: LogAttributeValue): string { const text = String(value); @@ -51,12 +59,11 @@ function formatValue(value: LogAttributeValue): string { export function logfmtReporter(options: StreamReporterOptions = {}): Reporter { return { log(record) { - const line = recordFields(record) - .map( - ([key, value]) => - `${key.replace(INVALID_KEY_CHARACTERS, "_")}=${formatValue(value)}`, - ) - .join(" "); + const fields = recordFields(record); + let line = ""; + for (const key of Object.keys(fields)) { + line += `${line ? " " : ""}${formatKey(key)}=${formatValue(fields[key] as LogAttributeValue)}`; + } (options.stream ?? process.stderr).write(`${line}\n`); }, }; diff --git a/packages/logger/test/logger.test.ts b/packages/logger/test/logger.test.ts index 11932a903..150cfd786 100644 --- a/packages/logger/test/logger.test.ts +++ b/packages/logger/test/logger.test.ts @@ -270,6 +270,53 @@ describe("jsonReporter", () => { '{"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", () => { From d6135284eb49a3e2fd27ade57a588f8b9e051685 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Sun, 1 Nov 2026 15:45:22 +0000 Subject: [PATCH 5/5] feat(logger): Expand nested attributes into dotted keys Attribute values can now be objects. The logfmt and Sentry reporters expand them into dotted keys (`http.status_code=404`); the JSON reporter keeps them nested. Expansion stops five levels down and writes `[Object]` there, which caps the cost and stops reference cycles. Flat attributes cost the same as before: a nested attribute adds about 200 ns to a logfmt line, and the Sentry reporter got slightly faster by dropping `Object.entries`. The benchmark gains a nested-attribute case. --- packages/logger/README.md | 15 ++++-- packages/logger/bench/run.mjs | 10 +++- packages/logger/src/fields.ts | 72 ++++++++++++++++++++----- packages/logger/src/index.ts | 1 + packages/logger/src/reporters/logfmt.ts | 23 ++++++-- packages/logger/src/reporters/sentry.ts | 34 ++++++++---- packages/logger/src/types.ts | 12 ++++- packages/logger/test/logger.test.ts | 65 ++++++++++++++++++++++ 8 files changed, 196 insertions(+), 36 deletions(-) diff --git a/packages/logger/README.md b/packages/logger/README.md index 09ffdb3f6..5811a5245 100644 --- a/packages/logger/README.md +++ b/packages/logger/README.md @@ -60,9 +60,12 @@ const log = logger.withTag("auth").withAttributes({ user: "jane" }); log.withTag("token").info("Refreshed"); // tag "auth:token", user "jane" ``` -Attribute values are strings, numbers, or booleans. The JSON and logfmt -reporters write them as fields, and the Sentry reporter sends them as log -attributes. +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 @@ -164,7 +167,8 @@ 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 plus +`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. @@ -195,7 +199,8 @@ Medians from one run on Node 22, linux-x64: | 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 | -| Sentry structured log | 78 ns (`util.format` + `Sentry.logger.info`) | 314 ns | +| 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 diff --git a/packages/logger/bench/run.mjs b/packages/logger/bench/run.mjs index 9ef87c82f..bfdd529f5 100644 --- a/packages/logger/bench/run.mjs +++ b/packages/logger/bench/run.mjs @@ -24,6 +24,7 @@ const groups = [ ["fancy line, colors", ["formatWrite", "fancyColor"]], ["JSON line", ["jsonWrite", "json"]], ["logfmt line", ["jsonWrite", "logfmt"]], + ["logfmt line, nested attribute", ["jsonWrite", "logfmtNested"]], ["Sentry structured log", ["formatSentry", "sentry"]], ]; @@ -37,6 +38,7 @@ const labels = { jsonWrite: "baseline: toISOString + JSON.stringify + write", json: "jsonReporter", logfmt: "logfmtReporter", + logfmtNested: "logfmtReporter", formatSentry: "baseline: util.format + Sentry.logger.info", sentry: "sentryReporter", }; @@ -112,6 +114,12 @@ async function setUp(name) { 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" }); @@ -194,7 +202,7 @@ if (caseName) { group: "startup", baseline: `${empty.toFixed(1)} ms (import node:util, node:path)`, logger: `${loaded.toFixed(1)} ms (import + createLogger())`, - overhead: `+${(loaded - empty).toFixed(1)} ms`, + overhead: `${loaded >= empty ? "+" : ""}${(loaded - empty).toFixed(1)} ms`, }); console.log(`Node ${process.version}, ${process.platform}-${process.arch}`); diff --git a/packages/logger/src/fields.ts b/packages/logger/src/fields.ts index addf02115..fb2c309a5 100644 --- a/packages/logger/src/fields.ts +++ b/packages/logger/src/fields.ts @@ -1,8 +1,12 @@ -/** The flat fields that the JSON and logfmt reporters write. */ +/** 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 { LogAttributeValue, LogRecord } from "./types.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"]); @@ -54,18 +58,58 @@ export function recordFields( if (BUILT_IN_FIELDS.has(key)) { continue; } - const value = attributes[key] as LogAttributeValue; - if (key === "__proto__") { - // Assigning `__proto__` would set the prototype instead of a field. - Object.defineProperty(fields, key, { - value, - enumerable: true, - writable: true, - configurable: true, - }); - } else { - fields[key] = value; - } + 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 index 3d5624de6..ec1c5e4b8 100644 --- a/packages/logger/src/index.ts +++ b/packages/logger/src/index.ts @@ -25,6 +25,7 @@ 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, diff --git a/packages/logger/src/reporters/logfmt.ts b/packages/logger/src/reporters/logfmt.ts index 6a137c050..1b2b10f62 100644 --- a/packages/logger/src/reporters/logfmt.ts +++ b/packages/logger/src/reporters/logfmt.ts @@ -1,7 +1,11 @@ /** logfmt lines (`key=value key="quoted value"`), one per log call. */ -import { recordFields } from "../fields.js"; -import type { LogAttributeValue, Reporter } from "../types.js"; +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. */ @@ -36,7 +40,7 @@ function formatKey(key: string): string { : key; } -function formatValue(value: LogAttributeValue): string { +function formatValue(value: LogAttributePrimitive): string { const text = String(value); if (!NEEDS_QUOTES.test(text)) { return text; @@ -55,6 +59,8 @@ function formatValue(value: LogAttributeValue): string { * ```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 { @@ -62,7 +68,16 @@ export function logfmtReporter(options: StreamReporterOptions = {}): Reporter { const fields = recordFields(record); let line = ""; for (const key of Object.keys(fields)) { - line += `${line ? " " : ""}${formatKey(key)}=${formatValue(fields[key] as LogAttributeValue)}`; + 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 index df5c6bafa..7df24058b 100644 --- a/packages/logger/src/reporters/sentry.ts +++ b/packages/logger/src/reporters/sentry.ts @@ -4,8 +4,14 @@ * `@sentry/bun`, `@sentry/browser`, ...), and this reporter calls it. */ +import { flattenAttribute } from "../fields.js"; import { formatMessage } from "../message.js"; -import type { LogAttributeValue, LogType, Reporter } from "../types.js"; +import type { + LogAttributePrimitive, + LogAttributeValue, + LogType, + Reporter, +} from "../types.js"; /** Sentry severities the reporter uses. */ type SentrySeverity = "trace" | "debug" | "info" | "warn" | "error"; @@ -13,7 +19,7 @@ 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 + (message: string, attributes?: Record) => void >; /** Sentry severity for each log type. */ @@ -38,8 +44,8 @@ const ORIGIN = "auto.log.sentry_logger"; * * The message is the formatted arguments; errors contribute their message * and causes, not their stacks. The record's attributes are sent as log - * attributes, with `logger.tag` (when set), `logger.type`, and - * `sentry.origin`. + * attributes, objects expanded into dotted keys, with `logger.tag` (when + * set), `logger.type`, and `sentry.origin`. * * ```ts * import * as Sentry from "@sentry/node"; @@ -50,14 +56,22 @@ const ORIGIN = "auto.log.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 }), - { - ...record.attributes, - ...(record.tag && { "logger.tag": record.tag }), - "logger.type": record.type, - "sentry.origin": ORIGIN, - }, + attributes, ); }, }; diff --git a/packages/logger/src/types.ts b/packages/logger/src/types.ts index 25fd53343..d91784ce9 100644 --- a/packages/logger/src/types.ts +++ b/packages/logger/src/types.ts @@ -19,8 +19,16 @@ export const LOG_TYPE_LEVELS = { /** A log method name, such as `info` or `success`. */ export type LogType = keyof typeof LOG_TYPE_LEVELS; -/** A structured attribute value. */ -export type LogAttributeValue = string | number | boolean; +/** 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>; diff --git a/packages/logger/test/logger.test.ts b/packages/logger/test/logger.test.ts index 150cfd786..0813f24c2 100644 --- a/packages/logger/test/logger.test.ts +++ b/packages/logger/test/logger.test.ts @@ -344,6 +344,57 @@ describe("logfmtReporter", () => { }); }); +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][] = []; @@ -393,6 +444,20 @@ describe("sentryReporter", () => { ]); }); + 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(