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 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..a0d4b88e --- /dev/null +++ b/src/commands/log/list.ts @@ -0,0 +1,386 @@ +/** + * sentry log list + * + * List and stream logs from Sentry projects. + * 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"; +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 { getUpdateNotification } from "../../lib/version-check.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; + } + + // Reverse for chronological order (API returns newest first, tail shows oldest first) + const chronological = [...logs].reverse(); + + stdout.write(formatLogsHeader()); + for (const log of chronological) { + 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. + */ +// 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; + + if (!flags.json) { + stderr.write(`Streaming logs... (poll interval: ${flags.pollInterval}s)\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"); + } + + // 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, + limit: flags.tail, + statsPeriod: "1m", + }); + + // 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) + const chronologicalInitial = [...initialLogs].reverse(); + writeLogs(stdout, chronologicalInitial, flags.json); + + // Track newest timestamp (logs are sorted -timestamp, so first is newest) + // 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) { + 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) { + // 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); + + // Update timestamp AFTER successful write to avoid losing logs on write failure + lastTimestamp = newestLog.timestamp_precise; + } + } catch (error) { + // 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 + } + } +} + +/** 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..d85f3c1a --- /dev/null +++ b/src/lib/formatters/log.ts @@ -0,0 +1,86 @@ +/** + * 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, 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"); + 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/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 new file mode 100644 index 00000000..7b5d2dca --- /dev/null +++ b/test/lib/formatters/log.test.ts @@ -0,0 +1,138 @@ +/** + * 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}/); + }); + + 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", () => { + 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"); + }); +}); 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 {