From df104b221e0bbd78a722986298fb3c947659b245 Mon Sep 17 00:00:00 2001 From: betegon Date: Tue, 3 Feb 2026 14:09:55 +0100 Subject: [PATCH 01/11] feat(commands): add sentry log list command Add new command to list and stream logs from Sentry projects: - Auto-detects org/project from DSN or accepts explicit target - Supports --follow flag for real-time streaming - Uses timestamp-based filtering for efficient follow mode - Includes --tail, --query, --json flags The log list command follows existing patterns from issue list, using parseOrgProjectArg for flexible target specification. --- src/app.ts | 2 + src/commands/log/index.ts | 27 +++ src/commands/log/list.ts | 346 ++++++++++++++++++++++++++++++++ src/lib/api-client.ts | 81 ++++++++ src/lib/formatters/index.ts | 1 + src/lib/formatters/log.ts | 80 ++++++++ src/types/index.ts | 6 + src/types/sentry.ts | 52 +++++ test/lib/formatters/log.test.ts | 129 ++++++++++++ 9 files changed, 724 insertions(+) create mode 100644 src/commands/log/index.ts create mode 100644 src/commands/log/list.ts create mode 100644 src/lib/formatters/log.ts create mode 100644 test/lib/formatters/log.test.ts diff --git a/src/app.ts b/src/app.ts index 0200ccc8..9cdec32b 100644 --- a/src/app.ts +++ b/src/app.ts @@ -12,6 +12,7 @@ import { cliRoute } from "./commands/cli/index.js"; import { eventRoute } from "./commands/event/index.js"; import { helpCommand } from "./commands/help.js"; import { issueRoute } from "./commands/issue/index.js"; +import { logRoute } from "./commands/log/index.js"; import { orgRoute } from "./commands/org/index.js"; import { projectRoute } from "./commands/project/index.js"; import { CLI_VERSION } from "./lib/constants.js"; @@ -28,6 +29,7 @@ export const routes = buildRouteMap({ project: projectRoute, issue: issueRoute, event: eventRoute, + log: logRoute, api: apiCommand, }, defaultCommand: "help", diff --git a/src/commands/log/index.ts b/src/commands/log/index.ts new file mode 100644 index 00000000..03786417 --- /dev/null +++ b/src/commands/log/index.ts @@ -0,0 +1,27 @@ +/** + * sentry log + * + * View and stream logs from Sentry projects. + */ + +import { buildRouteMap } from "@stricli/core"; +import { listCommand } from "./list.js"; + +export const logRoute = buildRouteMap({ + routes: { + list: listCommand, + }, + docs: { + brief: "View Sentry logs", + fullDescription: + "View and stream logs from your Sentry projects.\n\n" + + "Commands:\n" + + " list List or stream logs from a project\n\n" + + "Examples:\n" + + " sentry log list # Auto-detect from DSN\n" + + " sentry log list myorg/myproject # Explicit org/project\n" + + " sentry log list -f # Stream logs in real-time\n" + + " sentry log list -q 'level:error' # Filter to error level only", + hideRoute: {}, + }, +}); diff --git a/src/commands/log/list.ts b/src/commands/log/list.ts new file mode 100644 index 00000000..82a453cb --- /dev/null +++ b/src/commands/log/list.ts @@ -0,0 +1,346 @@ +/** + * sentry log list + * + * List and stream logs from Sentry projects. + * Supports real-time streaming with --follow flag. + */ + +import { buildCommand } from "@stricli/core"; +import type { SentryContext } from "../../context.js"; +import { findProjectsBySlug, listLogs } from "../../lib/api-client.js"; +import { ContextError } from "../../lib/errors.js"; +import { + formatLogRow, + formatLogsHeader, + writeFooter, + writeJson, +} from "../../lib/formatters/index.js"; +import { + parseOrgProjectArg, + resolveOrgAndProject, +} from "../../lib/resolve-target.js"; +import type { SentryLog, Writer } from "../../types/index.js"; + +type ListFlags = { + readonly tail: number; + readonly query?: string; + readonly follow: boolean; + readonly pollInterval: number; + readonly json: boolean; +}; + +/** Usage hint for ContextError messages */ +const USAGE_HINT = "sentry log list /"; + +/** Maximum allowed value for --tail flag */ +const MAX_ROWS = 1000; + +/** Minimum allowed value for --tail flag */ +const MIN_ROWS = 1; + +/** Default number of log entries to show */ +const DEFAULT_TAIL = 100; + +/** Default poll interval in seconds for --follow mode */ +const DEFAULT_POLL_INTERVAL = 2; + +/** + * Validate that --tail value is within allowed range. + * + * @throws Error if value is outside MIN_ROWS..MAX_ROWS range + */ +function validateTail(value: string): number { + const num = Number.parseInt(value, 10); + if (Number.isNaN(num) || num < MIN_ROWS || num > MAX_ROWS) { + throw new Error(`--tail must be between ${MIN_ROWS} and ${MAX_ROWS}`); + } + return num; +} + +/** + * Validate that --poll-interval is a positive number. + * + * @throws Error if value is not a positive number + */ +function validatePollInterval(value: string): number { + const num = Number.parseInt(value, 10); + if (Number.isNaN(num) || num < 1) { + throw new Error("--poll-interval must be a positive integer"); + } + return num; +} + +/** + * Write logs to output in the appropriate format. + */ +function writeLogs(stdout: Writer, logs: SentryLog[], asJson: boolean): void { + if (asJson) { + for (const log of logs) { + writeJson(stdout, log); + } + } else { + for (const log of logs) { + stdout.write(formatLogRow(log)); + } + } +} + +/** + * Execute a single fetch of logs (non-streaming mode). + */ +async function executeSingleFetch( + stdout: Writer, + org: string, + project: string, + flags: ListFlags +): Promise { + const logs = await listLogs(org, project, { + query: flags.query, + limit: flags.tail, + statsPeriod: "90d", + }); + + if (flags.json) { + writeJson(stdout, logs); + return; + } + + if (logs.length === 0) { + stdout.write("No logs found.\n"); + return; + } + + stdout.write(formatLogsHeader()); + for (const log of logs) { + stdout.write(formatLogRow(log)); + } + + // Show footer with tip if we hit the limit + const hasMore = logs.length >= flags.tail; + const countText = `Showing ${logs.length} log${logs.length === 1 ? "" : "s"}.`; + const tip = hasMore ? " Use --tail to show more, or -f to follow." : ""; + writeFooter(stdout, `${countText}${tip}`); +} + +type FollowModeOptions = { + stdout: Writer; + stderr: Writer; + org: string; + project: string; + flags: ListFlags; +}; + +/** + * Execute streaming mode (--follow flag). + * + * Uses timestamp-based filtering to efficiently fetch only new logs. + * Each poll requests logs with timestamp_precise > last seen timestamp, + * ensuring no duplicates and no missed logs. + */ +async function executeFollowMode(options: FollowModeOptions): Promise { + const { stdout, stderr, org, project, flags } = options; + const pollIntervalMs = flags.pollInterval * 1000; + + if (!flags.json) { + stderr.write(`Streaming logs... (poll interval: ${flags.pollInterval}s)\n`); + stderr.write("Press Ctrl+C to stop.\n\n"); + } + + // Initial fetch to get starting point and show recent logs + const initialLogs = await listLogs(org, project, { + query: flags.query, + limit: flags.tail, + statsPeriod: "90d", + }); + + writeLogs(stdout, initialLogs, flags.json); + + // Track newest timestamp (logs are sorted -timestamp, so first is newest) + let lastTimestamp = initialLogs[0]?.timestamp_precise ?? 0; + + // Poll for new logs indefinitely + while (true) { + await Bun.sleep(pollIntervalMs); + + try { + const newLogs = await listLogs(org, project, { + query: flags.query, + limit: flags.tail, + statsPeriod: "10m", + afterTimestamp: lastTimestamp, + }); + + const newestLog = newLogs[0]; + if (newestLog) { + lastTimestamp = newestLog.timestamp_precise; + writeLogs(stdout, newLogs, flags.json); + } + } catch (error) { + if (!flags.json) { + const message = error instanceof Error ? error.message : String(error); + stderr.write(`Error fetching logs: ${message}\n`); + } + // Continue polling even on errors + } + } +} + +/** Resolved org and project for log commands */ +type ResolvedLogTarget = { + org: string; + project: string; +}; + +/** + * Resolve org/project from parsed argument or auto-detection. + * + * Handles: + * - explicit: "org/project" → use directly + * - project-search: "project" → find project across all orgs + * - auto-detect: no input → use DSN detection or config defaults + * + * @throws {ContextError} When target cannot be resolved + */ +async function resolveLogTarget( + target: string | undefined, + cwd: string +): Promise { + const parsed = parseOrgProjectArg(target); + + switch (parsed.type) { + case "explicit": + return { org: parsed.org, project: parsed.project }; + + case "org-all": + throw new ContextError( + "Project", + `Please specify a project: sentry log list ${parsed.org}/` + ); + + case "project-search": { + // Find project across all orgs + const matches = await findProjectsBySlug(parsed.projectSlug); + + if (matches.length === 0) { + throw new ContextError( + "Project", + `No project '${parsed.projectSlug}' found in any accessible organization.\n\n` + + `Try: sentry log list /${parsed.projectSlug}` + ); + } + + if (matches.length > 1) { + const options = matches + .map((m) => ` sentry log list ${m.orgSlug}/${m.slug}`) + .join("\n"); + throw new ContextError( + "Project", + `Found '${parsed.projectSlug}' in ${matches.length} organizations. Please specify:\n${options}` + ); + } + + // Safe: we checked matches.length === 1 above, so first element exists + const match = matches[0] as (typeof matches)[number]; + return { org: match.orgSlug, project: match.slug }; + } + + case "auto-detect": { + const resolved = await resolveOrgAndProject({ + cwd, + usageHint: USAGE_HINT, + }); + if (!resolved) { + throw new ContextError("Organization and project", USAGE_HINT); + } + return { org: resolved.org, project: resolved.project }; + } + + default: { + const _exhaustiveCheck: never = parsed; + throw new Error(`Unexpected parsed type: ${_exhaustiveCheck}`); + } + } +} + +export const listCommand = buildCommand({ + docs: { + brief: "List logs from a project", + fullDescription: + "List and stream logs from Sentry projects.\n\n" + + "Target specification:\n" + + " sentry log list # auto-detect from DSN or config\n" + + " sentry log list / # explicit org and project\n" + + " sentry log list # find project across all orgs\n\n" + + "Examples:\n" + + " sentry log list # List last 100 logs\n" + + " sentry log list -f # Stream logs in real-time\n" + + " sentry log list --tail 50 # Show last 50 logs\n" + + " sentry log list -q 'level:error' # Filter to errors only\n" + + " sentry log list -f --tail 200 # Show last 200, then stream", + }, + parameters: { + positional: { + kind: "tuple", + parameters: [ + { + placeholder: "target", + brief: "Target: / or ", + parse: String, + optional: true, + }, + ], + }, + flags: { + tail: { + kind: "parsed", + parse: validateTail, + brief: `Number of log entries (${MIN_ROWS}-${MAX_ROWS})`, + default: String(DEFAULT_TAIL), + }, + query: { + kind: "parsed", + parse: String, + brief: "Filter query (Sentry search syntax)", + optional: true, + }, + follow: { + kind: "boolean", + brief: "Stream logs in real-time", + default: false, + }, + pollInterval: { + kind: "parsed", + parse: validatePollInterval, + brief: "Poll interval in seconds (only with --follow)", + default: String(DEFAULT_POLL_INTERVAL), + }, + json: { + kind: "boolean", + brief: "Output as JSON", + default: false, + }, + }, + aliases: { + n: "tail", + q: "query", + f: "follow", + }, + }, + async func( + this: SentryContext, + flags: ListFlags, + target?: string + ): Promise { + const { stdout, stderr, cwd, setContext } = this; + + // Resolve org/project from positional arg, config, or DSN auto-detection + const { org, project } = await resolveLogTarget(target, cwd); + setContext([org], [project]); + + if (flags.follow) { + await executeFollowMode({ stdout, stderr, org, project, flags }); + } else { + await executeSingleFetch(stdout, org, project, flags); + } + }, +}); diff --git a/src/lib/api-client.ts b/src/lib/api-client.ts index a0fc0fb0..0eeec846 100644 --- a/src/lib/api-client.ts +++ b/src/lib/api-client.ts @@ -8,6 +8,8 @@ import kyHttpClient, { type KyInstance } from "ky"; import { z } from "zod"; import { + type LogsResponse, + LogsResponseSchema, type ProjectKey, ProjectKeySchema, type Region, @@ -15,6 +17,7 @@ import { SentryEventSchema, type SentryIssue, SentryIssueSchema, + type SentryLog, type SentryOrganization, SentryOrganizationSchema, type SentryProject, @@ -943,3 +946,81 @@ export function getCurrentUser(): Promise { schema: SentryUserSchema, }); } + +// ───────────────────────────────────────────────────────────────────────────── +// Logs +// ───────────────────────────────────────────────────────────────────────────── + +/** Fields to request from the logs API */ +const LOG_FIELDS = [ + "sentry.item_id", + "trace", + "severity", + "timestamp", + "timestamp_precise", + "message", +]; + +/** Regex to check if a project identifier is a numeric ID */ +const NUMERIC_PROJECT_REGEX = /^\d+$/; + +type ListLogsOptions = { + /** Search query using Sentry query syntax */ + query?: string; + /** Maximum number of log entries to return */ + limit?: number; + /** Time period for logs (e.g., "90d", "10m") */ + statsPeriod?: string; + /** Only return logs after this timestamp_precise value (for streaming) */ + afterTimestamp?: number; +}; + +/** + * List logs for an organization/project. + * Uses the Explore/Events API with dataset=logs. + * + * Handles project slug vs numeric ID automatically: + * - Numeric IDs are passed as the `project` parameter + * - Slugs are added to the query string as `project:{slug}` + * + * @param orgSlug - Organization slug + * @param projectSlug - Project slug or numeric ID + * @param options - Query options (query, limit, statsPeriod) + * @returns Array of log entries + */ +export async function listLogs( + orgSlug: string, + projectSlug: string, + options: ListLogsOptions = {} +): Promise { + // API only accepts numeric project IDs as param, slugs go in query + const isNumericProject = NUMERIC_PROJECT_REGEX.test(projectSlug); + + // Build query parts + const projectFilter = isNumericProject ? "" : `project:${projectSlug}`; + const timestampFilter = options.afterTimestamp + ? `timestamp_precise:>${options.afterTimestamp}` + : ""; + + const fullQuery = [projectFilter, options.query, timestampFilter] + .filter(Boolean) + .join(" "); + + const response = await orgScopedRequest( + `/organizations/${orgSlug}/events/`, + { + params: { + dataset: "logs", + field: LOG_FIELDS, + project: isNumericProject ? projectSlug : undefined, + query: fullQuery || undefined, + per_page: options.limit ?? 100, + statsPeriod: options.statsPeriod ?? "90d", + sort: "-timestamp", + }, + schema: LogsResponseSchema, + } + ); + + return response.data; +} diff --git a/src/lib/formatters/index.ts b/src/lib/formatters/index.ts index 1109687e..f9fec70d 100644 --- a/src/lib/formatters/index.ts +++ b/src/lib/formatters/index.ts @@ -8,5 +8,6 @@ export * from "./colors.js"; export * from "./human.js"; export * from "./json.js"; +export * from "./log.js"; export * from "./output.js"; export * from "./seer.js"; diff --git a/src/lib/formatters/log.ts b/src/lib/formatters/log.ts new file mode 100644 index 00000000..fcbdb5a4 --- /dev/null +++ b/src/lib/formatters/log.ts @@ -0,0 +1,80 @@ +/** + * Log-specific formatters + * + * Provides formatting utilities for displaying Sentry logs in the CLI. + */ + +import type { SentryLog } from "../../types/index.js"; +import { cyan, muted, red, yellow } from "./colors.js"; +import { divider } from "./human.js"; + +/** Color functions for log severity levels */ +const SEVERITY_COLORS: Record string> = { + fatal: red, + error: red, + warning: yellow, + warn: yellow, + info: cyan, + debug: muted, + trace: muted, +}; + +/** + * Format severity level with appropriate color. + * Pads to 7 characters for alignment (longest: "warning"). + * + * @param severity - The log severity level + * @returns Colored and padded severity string + */ +function formatSeverity(severity: string | null | undefined): string { + const level = (severity ?? "info").toLowerCase(); + const colorFn = SEVERITY_COLORS[level] ?? ((s: string) => s); + return colorFn(level.toUpperCase().padEnd(7)); +} + +/** + * Format ISO timestamp for display. + * Converts to local time in "YYYY-MM-DD HH:MM:SS" format. + * + * @param timestamp - ISO 8601 timestamp string + * @returns Formatted local timestamp + */ +function formatTimestamp(timestamp: string): string { + const date = new Date(timestamp); + // Format as local time: YYYY-MM-DD HH:MM:SS + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, "0"); + const day = String(date.getDate()).padStart(2, "0"); + const hours = String(date.getHours()).padStart(2, "0"); + const minutes = String(date.getMinutes()).padStart(2, "0"); + const seconds = String(date.getSeconds()).padStart(2, "0"); + return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`; +} + +/** + * Format a single log entry for human-readable output. + * + * Format: "TIMESTAMP SEVERITY MESSAGE [trace_id]" + * Example: "2024-01-30 14:32:15 ERROR Failed to connect [abc12345]" + * + * @param log - The log entry to format + * @returns Formatted log line with newline + */ +export function formatLogRow(log: SentryLog): string { + const timestamp = formatTimestamp(log.timestamp); + const severity = formatSeverity(log.severity); + const message = log.message ?? ""; + const trace = log.trace ? muted(` [${log.trace.slice(0, 8)}]`) : ""; + + return `${timestamp} ${severity} ${message}${trace}\n`; +} + +/** + * Format column header for logs list. + * + * @returns Header line with column titles and divider + */ +export function formatLogsHeader(): string { + const header = muted("TIMESTAMP LEVEL MESSAGE"); + return `${header}\n${divider(80)}\n`; +} diff --git a/src/types/index.ts b/src/types/index.ts index 5ff197f0..8a19d424 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -56,6 +56,8 @@ export type { IssuePriority, IssueStatus, IssueSubstatus, + LogSeverity, + LogsResponse, Mechanism, OrganizationLinks, OsContext, @@ -65,6 +67,7 @@ export type { RequestEntry, SentryEvent, SentryIssue, + SentryLog, SentryOrganization, SentryProject, SentryUser, @@ -89,6 +92,8 @@ export { ISSUE_LEVELS, ISSUE_PRIORITIES, ISSUE_STATUSES, + LOG_SEVERITIES, + LogsResponseSchema, MechanismSchema, OrganizationLinksSchema, OsContextSchema, @@ -98,6 +103,7 @@ export { RequestEntrySchema, SentryEventSchema, SentryIssueSchema, + SentryLogSchema, SentryOrganizationSchema, SentryProjectSchema, SentryUserSchema, diff --git a/src/types/sentry.ts b/src/types/sentry.ts index 523ef84c..a32b40f6 100644 --- a/src/types/sentry.ts +++ b/src/types/sentry.ts @@ -678,3 +678,55 @@ export const ProjectKeySchema = z .passthrough(); export type ProjectKey = z.infer; + +// ───────────────────────────────────────────────────────────────────────────── +// Logs +// ───────────────────────────────────────────────────────────────────────────── + +/** Log severity levels (similar to issue levels but includes trace) */ +export const LOG_SEVERITIES = [ + "fatal", + "error", + "warning", + "warn", + "info", + "debug", + "trace", +] as const; +export type LogSeverity = (typeof LOG_SEVERITIES)[number]; + +/** + * Individual log entry from the logs dataset. + * Fields match the Sentry Explore/Events API response for dataset=logs. + */ +export const SentryLogSchema = z + .object({ + /** Unique identifier for deduplication */ + "sentry.item_id": z.string(), + /** ISO timestamp of the log entry */ + timestamp: z.string(), + /** Nanosecond-precision timestamp for accurate ordering and filtering */ + timestamp_precise: z.number(), + /** Log message content */ + message: z.string().nullable().optional(), + /** Log severity level (error, warning, info, debug, etc.) */ + severity: z.string().nullable().optional(), + /** Trace ID for correlation with traces */ + trace: z.string().nullable().optional(), + }) + .passthrough(); + +export type SentryLog = z.infer; + +/** Response from the logs events endpoint */ +export const LogsResponseSchema = z.object({ + data: z.array(SentryLogSchema), + meta: z + .object({ + fields: z.record(z.string()).optional(), + }) + .passthrough() + .optional(), +}); + +export type LogsResponse = z.infer; diff --git a/test/lib/formatters/log.test.ts b/test/lib/formatters/log.test.ts new file mode 100644 index 00000000..5a34eb68 --- /dev/null +++ b/test/lib/formatters/log.test.ts @@ -0,0 +1,129 @@ +/** + * Tests for log formatters + */ + +import { describe, expect, test } from "bun:test"; +import { + formatLogRow, + formatLogsHeader, +} from "../../../src/lib/formatters/log.js"; +import type { SentryLog } from "../../../src/types/index.js"; + +function createTestLog(overrides: Partial = {}): SentryLog { + return { + "sentry.item_id": "test-id-123", + timestamp: "2025-01-30T14:32:15Z", + timestamp_precise: 1_770_060_419_044_800_300, + message: "Test log message", + severity: "info", + trace: "abc123def456", + ...overrides, + }; +} + +// Strip ANSI color codes for easier testing +function stripAnsi(str: string): string { + // biome-ignore lint/suspicious/noControlCharactersInRegex: intentional ANSI stripping + return str.replace(/\x1b\[[0-9;]*m/g, ""); +} + +describe("formatLogRow", () => { + test("formats basic log entry", () => { + const log = createTestLog(); + const result = formatLogRow(log); + + // Should contain timestamp, severity, message, and trace + expect(result).toContain("Test log message"); + expect(result).toContain("[abc123de]"); // First 8 chars of trace + expect(result).toEndWith("\n"); + }); + + test("handles missing message", () => { + const log = createTestLog({ message: null }); + const result = formatLogRow(log); + + // Should not throw, just show empty message area + expect(result).toContain("INFO"); + expect(result).toEndWith("\n"); + }); + + test("handles missing severity", () => { + const log = createTestLog({ severity: null }); + const result = stripAnsi(formatLogRow(log)); + + // Should default to INFO + expect(result).toContain("INFO"); + }); + + test("handles missing trace", () => { + const log = createTestLog({ trace: null }); + const result = formatLogRow(log); + + // Should not include trace bracket + expect(result).not.toContain("["); + expect(result).toContain("Test log message"); + }); + + test("formats different severity levels", () => { + const levels = [ + "fatal", + "error", + "warning", + "warn", + "info", + "debug", + "trace", + ]; + + for (const level of levels) { + const log = createTestLog({ severity: level }); + const result = stripAnsi(formatLogRow(log)); + expect(result).toContain(level.toUpperCase().slice(0, 7)); // Max 7 chars + } + }); + + test("pads severity to consistent width", () => { + const shortLevel = createTestLog({ severity: "info" }); + const longLevel = createTestLog({ severity: "warning" }); + + const shortResult = stripAnsi(formatLogRow(shortLevel)); + const longResult = stripAnsi(formatLogRow(longLevel)); + + // Both should have severity at same position + const shortPos = shortResult.indexOf("INFO"); + const longPos = longResult.indexOf("WARNING"); + + // The position after timestamp should be consistent + expect(shortPos).toBe(longPos); + }); + + test("formats timestamp in local format", () => { + const log = createTestLog({ timestamp: "2025-01-30T14:32:15Z" }); + const result = formatLogRow(log); + + // Should have date and time format (actual values depend on timezone) + expect(result).toMatch(/\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}/); + }); +}); + +describe("formatLogsHeader", () => { + test("contains column titles", () => { + const result = stripAnsi(formatLogsHeader()); + + expect(result).toContain("TIMESTAMP"); + expect(result).toContain("LEVEL"); + expect(result).toContain("MESSAGE"); + }); + + test("contains divider line", () => { + const result = formatLogsHeader(); + + // Should have divider characters + expect(result).toContain("─"); + }); + + test("ends with newline", () => { + const result = formatLogsHeader(); + expect(result).toEndWith("\n"); + }); +}); From 537d35a3f5920f2c6040f9cb1441720d5c9dcc0e Mon Sep 17 00:00:00 2001 From: betegon Date: Tue, 3 Feb 2026 14:17:12 +0100 Subject: [PATCH 02/11] fix(log): add header in follow mode and handle invalid timestamps --- src/commands/log/list.ts | 6 ++++++ src/lib/formatters/log.ts | 8 +++++++- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/src/commands/log/list.ts b/src/commands/log/list.ts index 82a453cb..0e78ad49 100644 --- a/src/commands/log/list.ts +++ b/src/commands/log/list.ts @@ -137,6 +137,7 @@ type FollowModeOptions = { * Each poll requests logs with timestamp_precise > last seen timestamp, * ensuring no duplicates and no missed logs. */ +// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: streaming loop with error handling async function executeFollowMode(options: FollowModeOptions): Promise { const { stdout, stderr, org, project, flags } = options; const pollIntervalMs = flags.pollInterval * 1000; @@ -153,6 +154,11 @@ async function executeFollowMode(options: FollowModeOptions): Promise { statsPeriod: "90d", }); + // Print header before initial logs (human mode only) + if (!flags.json && initialLogs.length > 0) { + stdout.write(formatLogsHeader()); + } + writeLogs(stdout, initialLogs, flags.json); // Track newest timestamp (logs are sorted -timestamp, so first is newest) diff --git a/src/lib/formatters/log.ts b/src/lib/formatters/log.ts index fcbdb5a4..d85f3c1a 100644 --- a/src/lib/formatters/log.ts +++ b/src/lib/formatters/log.ts @@ -37,10 +37,16 @@ function formatSeverity(severity: string | null | undefined): string { * Converts to local time in "YYYY-MM-DD HH:MM:SS" format. * * @param timestamp - ISO 8601 timestamp string - * @returns Formatted local timestamp + * @returns Formatted local timestamp, or original string if invalid */ function formatTimestamp(timestamp: string): string { const date = new Date(timestamp); + + // Handle invalid dates - return original string + if (Number.isNaN(date.getTime())) { + return timestamp; + } + // Format as local time: YYYY-MM-DD HH:MM:SS const year = date.getFullYear(); const month = String(date.getMonth() + 1).padStart(2, "0"); From a5a779b86e2e8cf0b6da18d3be4f15586027ed27 Mon Sep 17 00:00:00 2001 From: betegon Date: Tue, 3 Feb 2026 14:34:44 +0100 Subject: [PATCH 03/11] chore: regenerate SKILL.md --- plugins/sentry-cli/skills/sentry-cli/SKILL.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/plugins/sentry-cli/skills/sentry-cli/SKILL.md b/plugins/sentry-cli/skills/sentry-cli/SKILL.md index 9df868bb..3188c7fc 100644 --- a/plugins/sentry-cli/skills/sentry-cli/SKILL.md +++ b/plugins/sentry-cli/skills/sentry-cli/SKILL.md @@ -403,6 +403,21 @@ Update the Sentry CLI to the latest version - `--check - Check for updates without installing` - `--method - Installation method to use (curl, npm, pnpm, bun, yarn)` +### Log + +View Sentry logs + +#### `sentry log list ` + +List logs from a project + +**Flags:** +- `-n, --tail - Number of log entries (1-1000) - (default: "100")` +- `-q, --query - Filter query (Sentry search syntax)` +- `-f, --follow - Stream logs in real-time` +- `--pollInterval - Poll interval in seconds (only with --follow) - (default: "2")` +- `--json - Output as JSON` + ## Output Formats ### JSON Output From 9ccc6ef42ba2ecb5fdd6ed374b5814fa6d736a4b Mon Sep 17 00:00:00 2001 From: betegon Date: Tue, 3 Feb 2026 15:10:02 +0100 Subject: [PATCH 04/11] test(log): add e2e tests and fixtures for log list command --- test/e2e/log.test.ts | 117 ++++++++++++++++++++++++++++++++ test/fixtures/logs.json | 23 +++++++ test/lib/formatters/log.test.ts | 9 +++ test/mocks/routes.ts | 13 ++++ 4 files changed, 162 insertions(+) create mode 100644 test/e2e/log.test.ts create mode 100644 test/fixtures/logs.json diff --git a/test/e2e/log.test.ts b/test/e2e/log.test.ts new file mode 100644 index 00000000..00b01e9a --- /dev/null +++ b/test/e2e/log.test.ts @@ -0,0 +1,117 @@ +/** + * Log Command E2E Tests + * + * Tests for sentry log list command. + */ + +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + describe, + expect, + test, +} from "bun:test"; +import { createE2EContext, type E2EContext } from "../fixture.js"; +import { cleanupTestDir, createTestConfigDir } from "../helpers.js"; +import { + createSentryMockServer, + TEST_ORG, + TEST_PROJECT, + TEST_TOKEN, +} from "../mocks/routes.js"; +import type { MockServer } from "../mocks/server.js"; + +let testConfigDir: string; +let mockServer: MockServer; +let ctx: E2EContext; + +beforeAll(async () => { + mockServer = createSentryMockServer(); + await mockServer.start(); +}); + +afterAll(() => { + mockServer.stop(); +}); + +beforeEach(async () => { + testConfigDir = await createTestConfigDir("e2e-log-"); + ctx = createE2EContext(testConfigDir, mockServer.url); +}); + +afterEach(async () => { + await cleanupTestDir(testConfigDir); +}); + +describe("sentry log list", () => { + test("requires authentication", async () => { + const result = await ctx.run([ + "log", + "list", + `${TEST_ORG}/${TEST_PROJECT}`, + ]); + + expect(result.exitCode).toBe(1); + expect(result.stderr + result.stdout).toMatch(/not authenticated|login/i); + }); + + test("lists logs with valid auth using positional arg", async () => { + await ctx.setAuthToken(TEST_TOKEN); + + const result = await ctx.run([ + "log", + "list", + `${TEST_ORG}/${TEST_PROJECT}`, + ]); + + expect(result.exitCode).toBe(0); + }); + + test("supports --json output", async () => { + await ctx.setAuthToken(TEST_TOKEN); + + const result = await ctx.run([ + "log", + "list", + `${TEST_ORG}/${TEST_PROJECT}`, + "--json", + ]); + + expect(result.exitCode).toBe(0); + // Should be valid JSON array + const data = JSON.parse(result.stdout); + expect(Array.isArray(data)).toBe(true); + }); + + test("supports --tail flag", async () => { + await ctx.setAuthToken(TEST_TOKEN); + + const result = await ctx.run([ + "log", + "list", + `${TEST_ORG}/${TEST_PROJECT}`, + "--tail", + "5", + ]); + + expect(result.exitCode).toBe(0); + }); + + test("validates --tail range", async () => { + await ctx.setAuthToken(TEST_TOKEN); + + const result = await ctx.run([ + "log", + "list", + `${TEST_ORG}/${TEST_PROJECT}`, + "--tail", + "9999", + ]); + + // Stricli uses exit code 252 for parse errors + expect(result.exitCode).not.toBe(0); + expect(result.stderr + result.stdout).toMatch(/must be between|1.*1000/i); + }); +}); diff --git a/test/fixtures/logs.json b/test/fixtures/logs.json new file mode 100644 index 00000000..6428d969 --- /dev/null +++ b/test/fixtures/logs.json @@ -0,0 +1,23 @@ +{ + "data": [ + { + "sentry.item_id": "log-001", + "timestamp": "2025-01-30T14:32:15+00:00", + "timestamp_precise": 1770060419044800300, + "message": "Test log message", + "severity": "info", + "trace": "abc123def456abc123def456abc12345" + }, + { + "sentry.item_id": "log-002", + "timestamp": "2025-01-30T14:32:10+00:00", + "timestamp_precise": 1770060414044800300, + "message": "Error occurred in payment processing", + "severity": "error", + "trace": "def456abc123def456abc123def45678" + } + ], + "meta": { + "fields": {} + } +} diff --git a/test/lib/formatters/log.test.ts b/test/lib/formatters/log.test.ts index 5a34eb68..7b5d2dca 100644 --- a/test/lib/formatters/log.test.ts +++ b/test/lib/formatters/log.test.ts @@ -104,6 +104,15 @@ describe("formatLogRow", () => { // Should have date and time format (actual values depend on timezone) expect(result).toMatch(/\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}/); }); + + test("handles invalid timestamp gracefully", () => { + const log = createTestLog({ timestamp: "invalid-date" }); + const result = formatLogRow(log); + + // Should return original string instead of NaN + expect(result).toContain("invalid-date"); + expect(result).not.toContain("NaN"); + }); }); describe("formatLogsHeader", () => { diff --git a/test/mocks/routes.ts b/test/mocks/routes.ts index 379018d9..a3ad08d5 100644 --- a/test/mocks/routes.ts +++ b/test/mocks/routes.ts @@ -10,6 +10,7 @@ import notFoundFixture from "../fixtures/errors/not-found.json"; import eventFixture from "../fixtures/event.json"; import issueFixture from "../fixtures/issue.json"; import issuesFixture from "../fixtures/issues.json"; +import logsFixture from "../fixtures/logs.json"; import organizationFixture from "../fixtures/organization.json"; import organizationsFixture from "../fixtures/organizations.json"; import projectFixture from "../fixtures/project.json"; @@ -185,6 +186,18 @@ export const apiRoutes: MockRoute[] = [ return { status: 404, body: notFoundFixture }; }, }, + + // Logs (Events API with dataset=logs) + { + method: "GET", + path: "/api/0/organizations/:orgSlug/events/", + response: (_req, params) => { + if (params.orgSlug === TEST_ORG) { + return { body: logsFixture }; + } + return { status: 404, body: notFoundFixture }; + }, + }, ]; export function createSentryMockServer(): MockServer { From cda3dbf6ba6b15c57adc258d6b497b9106bfd062 Mon Sep 17 00:00:00 2001 From: betegon Date: Tue, 3 Feb 2026 16:20:54 +0100 Subject: [PATCH 05/11] fix(log): reverse logs for chronological order and update timestamp after write - Reverse logs before printing in follow mode for tail -f style (oldest first) - Move timestamp update after successful writeLogs() to prevent silent log loss --- src/commands/log/list.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/commands/log/list.ts b/src/commands/log/list.ts index 0e78ad49..8ffb8e21 100644 --- a/src/commands/log/list.ts +++ b/src/commands/log/list.ts @@ -159,7 +159,9 @@ async function executeFollowMode(options: FollowModeOptions): Promise { stdout.write(formatLogsHeader()); } - writeLogs(stdout, initialLogs, flags.json); + // Reverse for chronological order (API returns newest first, tail -f shows oldest first) + const chronologicalInitial = [...initialLogs].reverse(); + writeLogs(stdout, chronologicalInitial, flags.json); // Track newest timestamp (logs are sorted -timestamp, so first is newest) let lastTimestamp = initialLogs[0]?.timestamp_precise ?? 0; @@ -178,8 +180,12 @@ async function executeFollowMode(options: FollowModeOptions): Promise { const newestLog = newLogs[0]; if (newestLog) { + // Reverse for chronological order (oldest first for tail -f style) + const chronologicalNew = [...newLogs].reverse(); + writeLogs(stdout, chronologicalNew, flags.json); + + // Update timestamp AFTER successful write to avoid losing logs on write failure lastTimestamp = newestLog.timestamp_precise; - writeLogs(stdout, newLogs, flags.json); } } catch (error) { if (!flags.json) { From fb14910a85a0aed9cba9d022f71b2d61702ba1c0 Mon Sep 17 00:00:00 2001 From: betegon Date: Tue, 3 Feb 2026 16:58:49 +0100 Subject: [PATCH 06/11] fix(log): show logs in chronological order for single fetch mode Both single fetch and follow mode now display logs consistently: oldest at top, newest at bottom (like tail command) --- src/commands/log/list.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/commands/log/list.ts b/src/commands/log/list.ts index 8ffb8e21..b8f06fa8 100644 --- a/src/commands/log/list.ts +++ b/src/commands/log/list.ts @@ -110,8 +110,11 @@ async function executeSingleFetch( return; } + // Reverse for chronological order (API returns newest first, tail shows oldest first) + const chronological = [...logs].reverse(); + stdout.write(formatLogsHeader()); - for (const log of logs) { + for (const log of chronological) { stdout.write(formatLogRow(log)); } From c17de6c93532f3e8f3811712d4d19bbdeee66745 Mon Sep 17 00:00:00 2001 From: betegon Date: Tue, 3 Feb 2026 17:14:08 +0100 Subject: [PATCH 07/11] fix(log): use 1-minute window for follow mode initial fetch Follow mode now only shows logs from the last minute before streaming, instead of pulling from 90 days of history. This matches the expected tail -f behavior where you see recent logs then stream new ones. --- src/commands/log/list.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/commands/log/list.ts b/src/commands/log/list.ts index b8f06fa8..df6c328c 100644 --- a/src/commands/log/list.ts +++ b/src/commands/log/list.ts @@ -150,11 +150,11 @@ async function executeFollowMode(options: FollowModeOptions): Promise { stderr.write("Press Ctrl+C to stop.\n\n"); } - // Initial fetch to get starting point and show recent logs + // Initial fetch: only last minute for follow mode (we want recent logs, not historical) const initialLogs = await listLogs(org, project, { query: flags.query, limit: flags.tail, - statsPeriod: "90d", + statsPeriod: "1m", }); // Print header before initial logs (human mode only) From 39ae9714c4174e57989868ca0ef9903a53ae8361 Mon Sep 17 00:00:00 2001 From: betegon Date: Tue, 3 Feb 2026 17:51:08 +0100 Subject: [PATCH 08/11] fix(log): show update notification before follow mode streaming Since follow mode runs indefinitely and never reaches the normal exit where notifications are shown, display the update notification right after 'Press Ctrl+C to stop' and before streaming begins. --- src/commands/log/list.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/commands/log/list.ts b/src/commands/log/list.ts index df6c328c..28a03195 100644 --- a/src/commands/log/list.ts +++ b/src/commands/log/list.ts @@ -19,6 +19,7 @@ import { parseOrgProjectArg, resolveOrgAndProject, } from "../../lib/resolve-target.js"; +import { getUpdateNotification } from "../../lib/version-check.js"; import type { SentryLog, Writer } from "../../types/index.js"; type ListFlags = { @@ -147,7 +148,14 @@ async function executeFollowMode(options: FollowModeOptions): Promise { if (!flags.json) { stderr.write(`Streaming logs... (poll interval: ${flags.pollInterval}s)\n`); - stderr.write("Press Ctrl+C to stop.\n\n"); + stderr.write("Press Ctrl+C to stop.\n"); + + // Show update notification before streaming (since we'll never reach the normal exit) + const notification = getUpdateNotification(); + if (notification) { + stderr.write(notification); + } + stderr.write("\n"); } // Initial fetch: only last minute for follow mode (we want recent logs, not historical) From 02717db9aed8faca385f82384e1e401691ae0be9 Mon Sep 17 00:00:00 2001 From: betegon Date: Tue, 3 Feb 2026 17:55:09 +0100 Subject: [PATCH 09/11] fix(log): print header on first logs even if initial fetch was empty Track headerPrinted state to ensure the column header (TIMESTAMP LEVEL MESSAGE) is shown before the first logs, whether they come from the initial fetch or from a subsequent poll in follow mode. --- src/commands/log/list.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/commands/log/list.ts b/src/commands/log/list.ts index 28a03195..25f74358 100644 --- a/src/commands/log/list.ts +++ b/src/commands/log/list.ts @@ -158,6 +158,9 @@ async function executeFollowMode(options: FollowModeOptions): Promise { stderr.write("\n"); } + // Track if header has been printed (for human mode) + let headerPrinted = false; + // Initial fetch: only last minute for follow mode (we want recent logs, not historical) const initialLogs = await listLogs(org, project, { query: flags.query, @@ -168,6 +171,7 @@ async function executeFollowMode(options: FollowModeOptions): Promise { // Print header before initial logs (human mode only) if (!flags.json && initialLogs.length > 0) { stdout.write(formatLogsHeader()); + headerPrinted = true; } // Reverse for chronological order (API returns newest first, tail -f shows oldest first) @@ -191,6 +195,12 @@ async function executeFollowMode(options: FollowModeOptions): Promise { const newestLog = newLogs[0]; if (newestLog) { + // Print header before first logs if not already printed + if (!(flags.json || headerPrinted)) { + stdout.write(formatLogsHeader()); + headerPrinted = true; + } + // Reverse for chronological order (oldest first for tail -f style) const chronologicalNew = [...newLogs].reverse(); writeLogs(stdout, chronologicalNew, flags.json); From 0ae02db0072c256a7833621386b262f99ac0126f Mon Sep 17 00:00:00 2001 From: betegon Date: Tue, 3 Feb 2026 20:38:04 +0100 Subject: [PATCH 10/11] fix(log): use current time as fallback when no initial logs in follow mode When follow mode starts with an empty initial fetch (no logs in the last minute), lastTimestamp was set to 0. Since 0 is falsy in JavaScript, the timestamp filter in api-client.ts was omitted, causing the first poll to fetch ALL logs from the last 10 minutes instead of only new ones. Fix: Use Date.now() * 1_000_000 (nanoseconds) as the fallback, so polling starts from the current time when there are no initial logs. --- src/commands/log/list.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/commands/log/list.ts b/src/commands/log/list.ts index 25f74358..1af8385c 100644 --- a/src/commands/log/list.ts +++ b/src/commands/log/list.ts @@ -179,7 +179,10 @@ async function executeFollowMode(options: FollowModeOptions): Promise { writeLogs(stdout, chronologicalInitial, flags.json); // Track newest timestamp (logs are sorted -timestamp, so first is newest) - let lastTimestamp = initialLogs[0]?.timestamp_precise ?? 0; + // Use current time as fallback to avoid fetching old logs when initial fetch is empty + // (timestamp_precise is in nanoseconds, Date.now() is milliseconds) + let lastTimestamp = + initialLogs[0]?.timestamp_precise ?? Date.now() * 1_000_000; // Poll for new logs indefinitely while (true) { From b0f3b17d36e2f9b50e71863aace21953eae43990 Mon Sep 17 00:00:00 2001 From: betegon Date: Tue, 3 Feb 2026 20:59:38 +0100 Subject: [PATCH 11/11] fix(log): report polling errors to Sentry and always show in stderr Previously, errors during follow mode polling were: 1. Not reported to Sentry (swallowed locally) 2. Silently suppressed in JSON mode Now errors are always: 1. Captured to Sentry for visibility into polling failures 2. Written to stderr (which doesn't interfere with JSON output on stdout) This follows the established pattern used in version-check.ts and auth/login.ts for local catch blocks that continue operation. --- src/commands/log/list.ts | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/src/commands/log/list.ts b/src/commands/log/list.ts index 1af8385c..a0d4b88e 100644 --- a/src/commands/log/list.ts +++ b/src/commands/log/list.ts @@ -5,6 +5,8 @@ * Supports real-time streaming with --follow flag. */ +// biome-ignore lint/performance/noNamespaceImport: Sentry SDK recommends namespace import +import * as Sentry from "@sentry/bun"; import { buildCommand } from "@stricli/core"; import type { SentryContext } from "../../context.js"; import { findProjectsBySlug, listLogs } from "../../lib/api-client.js"; @@ -212,10 +214,12 @@ async function executeFollowMode(options: FollowModeOptions): Promise { lastTimestamp = newestLog.timestamp_precise; } } catch (error) { - if (!flags.json) { - const message = error instanceof Error ? error.message : String(error); - stderr.write(`Error fetching logs: ${message}\n`); - } + // Report to Sentry for visibility into polling failures + Sentry.captureException(error); + + // Always write to stderr (doesn't interfere with JSON on stdout) + const message = error instanceof Error ? error.message : String(error); + stderr.write(`Error fetching logs: ${message}\n`); // Continue polling even on errors } }