From 079be5e416494db94141de41b8e77bcf6c35e5ae Mon Sep 17 00:00:00 2001 From: betegon Date: Fri, 6 Feb 2026 14:04:50 +0100 Subject: [PATCH 1/9] docs: add documentation for log command --- docs/src/content/docs/commands/index.md | 1 + docs/src/content/docs/commands/log.md | 103 ++++++++++++++++++++++++ 2 files changed, 104 insertions(+) create mode 100644 docs/src/content/docs/commands/log.md diff --git a/docs/src/content/docs/commands/index.md b/docs/src/content/docs/commands/index.md index 45aae5d5..14576cdd 100644 --- a/docs/src/content/docs/commands/index.md +++ b/docs/src/content/docs/commands/index.md @@ -15,6 +15,7 @@ The Sentry CLI provides commands for interacting with various Sentry resources. | [`project`](./project/) | Project operations | | [`issue`](./issue/) | Issue tracking | | [`event`](./event/) | Event inspection | +| [`log`](./log/) | Log viewing and streaming | | [`api`](./api/) | Direct API access | ## Global Options diff --git a/docs/src/content/docs/commands/log.md b/docs/src/content/docs/commands/log.md new file mode 100644 index 00000000..c19d60e8 --- /dev/null +++ b/docs/src/content/docs/commands/log.md @@ -0,0 +1,103 @@ +--- +title: log +description: Log commands for the Sentry CLI +--- + +View and stream logs from Sentry projects. + +## Commands + +### `sentry log list` + +List and stream logs from a project. + +```bash +# Auto-detect from DSN or config +sentry log list + +# Explicit org and project +sentry log list / + +# Search for project across all accessible orgs +sentry log list +``` + +**Arguments:** + +| Argument | Description | +|----------|-------------| +| `/` | Explicit organization and project (e.g., `my-org/backend`) | +| `` | Search for project by name across all accessible organizations | + +**Options:** + +| Option | Description | +|--------|-------------| +| `-n, --limit ` | Number of log entries to show (1-1000, default: 100) | +| `-q, --query ` | Filter query (Sentry search syntax) | +| `-f, --follow [interval]` | Stream logs in real-time (optional: poll interval in seconds, default: 2) | +| `--json` | Output as JSON | + +**Examples:** + +```bash +# List last 100 logs (default) +sentry log list +``` + +``` +TIMESTAMP LEVEL MESSAGE +2024-01-20 14:22:01 info User login successful +2024-01-20 14:22:03 debug Processing request for /api/users +2024-01-20 14:22:05 error Database connection timeout +2024-01-20 14:22:06 warn Retry attempt 1 of 3 + +Showing 4 logs. +``` + +**Stream logs in real-time:** + +```bash +# Stream with default 2-second poll interval +sentry log list -f + +# Stream with custom 5-second poll interval +sentry log list -f 5 +``` + +**Filter logs:** + +```bash +# Show only error logs +sentry log list -q 'level:error' + +# Filter by message content +sentry log list -q 'database' +``` + +**Limit results:** + +```bash +# Show last 50 logs +sentry log list --limit 50 + +# Show last 500 logs +sentry log list -n 500 +``` + +**Combine options:** + +```bash +# Stream error logs from a specific project +sentry log list my-org/backend -f -q 'level:error' +``` + +## JSON Output + +Use `--json` for machine-readable output: + +```bash +sentry log list --json | jq '.[] | select(.level == "error")' +``` + +In streaming mode with `--json`, each log entry is output as a separate JSON object (newline-delimited JSON), making it suitable for piping to other tools. From 682405f54ed5fd798c609bf51e8ecb64d8f971a8 Mon Sep 17 00:00:00 2001 From: betegon Date: Fri, 6 Feb 2026 14:06:42 +0100 Subject: [PATCH 2/9] chore: regenerate skill with log command examples --- plugins/sentry-cli/skills/sentry-cli/SKILL.md | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/plugins/sentry-cli/skills/sentry-cli/SKILL.md b/plugins/sentry-cli/skills/sentry-cli/SKILL.md index bd285174..61c713d9 100644 --- a/plugins/sentry-cli/skills/sentry-cli/SKILL.md +++ b/plugins/sentry-cli/skills/sentry-cli/SKILL.md @@ -424,6 +424,45 @@ List logs from a project - `-f, --follow - Stream logs (optionally specify poll interval in seconds)` - `--json - Output as JSON` +**Examples:** + +```bash +# Auto-detect from DSN or config +sentry log list + +# Explicit org and project +sentry log list / + +# Search for project across all accessible orgs +sentry log list + +# List last 100 logs (default) +sentry log list + +# Stream with default 2-second poll interval +sentry log list -f + +# Stream with custom 5-second poll interval +sentry log list -f 5 + +# Show only error logs +sentry log list -q 'level:error' + +# Filter by message content +sentry log list -q 'database' + +# Show last 50 logs +sentry log list --limit 50 + +# Show last 500 logs +sentry log list -n 500 + +# Stream error logs from a specific project +sentry log list my-org/backend -f -q 'level:error' + +sentry log list --json | jq '.[] | select(.level == "error")' +``` + ### Issues List issues in a project From 6d9efec1a28fe10ce32f3a3cb319f02da8394a23 Mon Sep 17 00:00:00 2001 From: betegon Date: Fri, 6 Feb 2026 14:38:50 +0100 Subject: [PATCH 3/9] feat(log): add view command to display log entry details Adds 'sentry log view ' to display detailed information about a single log entry. Supports: - Auto-detect org/project from DSN or config - Explicit target: sentry log view / - Project search: sentry log view - JSON output with --json flag - Open in browser with --web flag - Trace link when trace ID is present Output includes: - Core: ID, timestamp, severity, message - Context: project, environment, release - SDK: name, version - Trace: trace ID, span ID, clickable link - Source location: function, file, line (when available) - OpenTelemetry data (when available) --- src/commands/log/index.ts | 5 +- src/commands/log/view.ts | 230 ++++++++++++++++++++++++++++++++++++++ src/lib/api-client.ts | 54 +++++++++ src/lib/formatters/log.ts | 134 +++++++++++++++++++++- src/lib/sentry-urls.ts | 25 +++++ src/types/index.ts | 4 + src/types/sentry.ts | 60 ++++++++++ 7 files changed, 510 insertions(+), 2 deletions(-) create mode 100644 src/commands/log/view.ts diff --git a/src/commands/log/index.ts b/src/commands/log/index.ts index bd2d6844..aa9ad417 100644 --- a/src/commands/log/index.ts +++ b/src/commands/log/index.ts @@ -6,17 +6,20 @@ import { buildRouteMap } from "@stricli/core"; import { listCommand } from "./list.js"; +import { viewCommand } from "./view.js"; export const logRoute = buildRouteMap({ routes: { list: listCommand, + view: viewCommand, }, 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", + " list List or stream logs from a project\n" + + " view View details of a specific log entry", hideRoute: {}, }, }); diff --git a/src/commands/log/view.ts b/src/commands/log/view.ts new file mode 100644 index 00000000..6eac2495 --- /dev/null +++ b/src/commands/log/view.ts @@ -0,0 +1,230 @@ +/** + * sentry log view + * + * View detailed information about a Sentry log entry. + */ + +import { buildCommand } from "@stricli/core"; +import type { SentryContext } from "../../context.js"; +import { findProjectsBySlug, getLog } from "../../lib/api-client.js"; +import { parseOrgProjectArg } from "../../lib/arg-parsing.js"; +import { openInBrowser } from "../../lib/browser.js"; +import { ContextError, ValidationError } from "../../lib/errors.js"; +import { formatLogDetails, writeJson } from "../../lib/formatters/index.js"; +import { resolveOrgAndProject } from "../../lib/resolve-target.js"; +import { buildLogsUrl } from "../../lib/sentry-urls.js"; +import type { DetailedSentryLog, Writer } from "../../types/index.js"; + +type ViewFlags = { + readonly json: boolean; + readonly web: boolean; +}; + +/** Usage hint for ContextError messages */ +const USAGE_HINT = "sentry log view / "; + +/** + * Parse positional arguments for log view. + * Handles: `` or ` ` + * + * @returns Parsed log ID and optional target arg + */ +export function parsePositionalArgs(args: string[]): { + logId: string; + targetArg: string | undefined; +} { + if (args.length === 0) { + throw new ContextError("Log ID", USAGE_HINT); + } + + const first = args[0]; + if (first === undefined) { + throw new ContextError("Log ID", USAGE_HINT); + } + + if (args.length === 1) { + // Single arg - must be log ID + return { logId: first, targetArg: undefined }; + } + + const second = args[1]; + if (second === undefined) { + // Should not happen given length check, but TypeScript needs this + return { logId: first, targetArg: undefined }; + } + + // Two or more args - first is target, second is log ID + return { logId: second, targetArg: first }; +} + +/** + * Resolved target type for log commands. + */ +type ResolvedLogTarget = { + org: string; + project: string; + detectedFrom?: string; +}; + +/** + * Resolve target from a project search result. + * + * Searches for a project by slug across all accessible organizations. + * Throws if no project found or if multiple projects found in different orgs. + * + * @param projectSlug - Project slug to search for + * @param logId - Log ID (used in error messages) + * @returns Resolved target with org and project info + * @throws {ContextError} If no project found + * @throws {ValidationError} If project exists in multiple organizations + */ +async function resolveFromProjectSearch( + projectSlug: string, + logId: string +): Promise { + const found = await findProjectsBySlug(projectSlug); + if (found.length === 0) { + throw new ContextError(`Project "${projectSlug}"`, USAGE_HINT, [ + "Check that you have access to a project with this slug", + ]); + } + if (found.length > 1) { + const orgList = found.map((p) => ` ${p.orgSlug}/${p.slug}`).join("\n"); + throw new ValidationError( + `Project "${projectSlug}" exists in multiple organizations.\n\n` + + `Specify the organization:\n${orgList}\n\n` + + `Example: sentry log view ${projectSlug} ${logId}` + ); + } + // Safe assertion: length is exactly 1 after the checks above + const foundProject = found[0] as (typeof found)[0]; + return { + org: foundProject.orgSlug, + project: foundProject.slug, + }; +} + +/** + * Write human-readable log output to stdout. + */ +function writeHumanOutput( + stdout: Writer, + log: DetailedSentryLog, + orgSlug: string, + detectedFrom?: string +): void { + const lines = formatLogDetails(log, orgSlug); + stdout.write(`${lines.join("\n")}\n`); + + if (detectedFrom) { + stdout.write(`\nDetected from ${detectedFrom}\n`); + } +} + +export const viewCommand = buildCommand({ + docs: { + brief: "View details of a specific log entry", + fullDescription: + "View detailed information about a Sentry log entry by its ID.\n\n" + + "Target specification:\n" + + " sentry log view # auto-detect from DSN or config\n" + + " sentry log view / # explicit org and project\n" + + " sentry log view # find project across all orgs\n\n" + + "The log ID is the 32-character hexadecimal identifier shown in log listings.", + }, + parameters: { + positional: { + kind: "array", + parameter: { + placeholder: "args", + brief: + "[/] - Target (optional) and log ID (required)", + parse: String, + }, + }, + flags: { + json: { + kind: "boolean", + brief: "Output as JSON", + default: false, + }, + web: { + kind: "boolean", + brief: "Open in browser", + default: false, + }, + }, + aliases: { w: "web" }, + }, + async func( + this: SentryContext, + flags: ViewFlags, + ...args: string[] + ): Promise { + const { stdout, cwd, setContext } = this; + + // Parse positional args + const { logId, targetArg } = parsePositionalArgs(args); + const parsed = parseOrgProjectArg(targetArg); + + let target: ResolvedLogTarget | null = null; + + switch (parsed.type) { + case "explicit": + target = { + org: parsed.org, + project: parsed.project, + }; + break; + + case "project-search": + target = await resolveFromProjectSearch(parsed.projectSlug, logId); + break; + + case "org-all": + throw new ContextError("Specific project", USAGE_HINT); + + case "auto-detect": + target = await resolveOrgAndProject({ cwd, usageHint: USAGE_HINT }); + break; + + default: { + // Exhaustive check - should never reach here + const _exhaustiveCheck: never = parsed; + throw new ValidationError( + `Invalid target specification: ${_exhaustiveCheck}` + ); + } + } + + if (!target) { + throw new ContextError("Organization and project", USAGE_HINT); + } + + // Set telemetry context + setContext([target.org], [target.project]); + + if (flags.web) { + await openInBrowser(stdout, buildLogsUrl(target.org, logId), "log"); + return; + } + + // Fetch the log entry + const log = await getLog(target.org, logId); + + if (!log) { + throw new ContextError( + `Log "${logId}"`, + `No log found with ID "${logId}" in ${target.org}/${target.project}.\n\n` + + "Make sure the log ID is correct and the log was sent within the last 90 days." + ); + } + + if (flags.json) { + writeJson(stdout, log); + return; + } + + writeHumanOutput(stdout, log, target.org, target.detectedFrom); + }, +}); diff --git a/src/lib/api-client.ts b/src/lib/api-client.ts index 382f4521..285d732c 100644 --- a/src/lib/api-client.ts +++ b/src/lib/api-client.ts @@ -8,6 +8,9 @@ import kyHttpClient, { type KyInstance } from "ky"; import { z } from "zod"; import { + type DetailedLogsResponse, + DetailedLogsResponseSchema, + type DetailedSentryLog, type LogsResponse, LogsResponseSchema, type ProjectKey, @@ -1071,3 +1074,54 @@ export async function listLogs( return response.data; } + +/** All fields to request for detailed log view */ +const DETAILED_LOG_FIELDS = [ + "sentry.item_id", + "timestamp", + "timestamp_precise", + "message", + "severity", + "trace", + "project", + "environment", + "release", + "sdk.name", + "sdk.version", + "span_id", + "code.function", + "code.file.path", + "code.line.number", + "sentry.otel.kind", + "sentry.otel.status_code", + "sentry.otel.instrumentation_scope.name", +]; + +/** + * Get a single log entry by its item ID. + * Uses the Explore/Events API with dataset=logs and a filter query. + * + * @param orgSlug - Organization slug + * @param logId - The sentry.item_id of the log entry + * @returns The detailed log entry, or null if not found + */ +export async function getLog( + orgSlug: string, + logId: string +): Promise { + const response = await orgScopedRequest( + `/organizations/${orgSlug}/events/`, + { + params: { + dataset: "logs", + field: DETAILED_LOG_FIELDS, + query: `sentry.item_id:${logId}`, + per_page: 1, + statsPeriod: "90d", + }, + schema: DetailedLogsResponseSchema, + } + ); + + return response.data[0] ?? null; +} diff --git a/src/lib/formatters/log.ts b/src/lib/formatters/log.ts index b70e5a64..8ff6ae0d 100644 --- a/src/lib/formatters/log.ts +++ b/src/lib/formatters/log.ts @@ -4,7 +4,8 @@ * Provides formatting utilities for displaying Sentry logs in the CLI. */ -import type { SentryLog } from "../../types/index.js"; +import type { DetailedSentryLog, SentryLog } from "../../types/index.js"; +import { buildTraceUrl } from "../sentry-urls.js"; import { cyan, muted, red, yellow } from "./colors.js"; import { divider } from "./human.js"; @@ -78,3 +79,134 @@ export function formatLogsHeader(): string { const header = muted("TIMESTAMP LEVEL MESSAGE"); return `${header}\n${divider(80)}\n`; } + +/** + * Format severity level with color for detailed view (not padded). + * + * @param severity - The log severity level + * @returns Colored severity string + */ +function formatSeverityLabel(severity: string | null | undefined): string { + const level = (severity ?? "info").toLowerCase(); + const colorFn = SEVERITY_COLORS[level] ?? ((s: string) => s); + return colorFn(level.toUpperCase()); +} + +/** Minimum width for header separator line */ +const MIN_HEADER_WIDTH = 20; + +/** + * Format detailed log entry for display. + * Shows all available fields in a structured format. + * + * @param log - The detailed log entry to format + * @param orgSlug - Organization slug for building trace URLs + * @returns Array of formatted lines + */ +// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: log detail formatting requires multiple conditional sections +export function formatLogDetails( + log: DetailedSentryLog, + orgSlug: string +): string[] { + const lines: string[] = []; + const logId = log["sentry.item_id"]; + + // Header + const headerText = `Log ${logId.slice(0, 12)}...`; + const separatorWidth = Math.max(MIN_HEADER_WIDTH, Math.min(80, 40)); + lines.push(headerText); + lines.push(muted("═".repeat(separatorWidth))); + lines.push(""); + + // Core fields + lines.push(`ID: ${logId}`); + lines.push(`Timestamp: ${formatTimestamp(log.timestamp)}`); + lines.push(`Severity: ${formatSeverityLabel(log.severity)}`); + + // Message (may be multi-line or long) + if (log.message) { + lines.push(""); + lines.push("Message:"); + lines.push(` ${log.message}`); + } + lines.push(""); + + // Context section + if (log.project || log.environment || log.release) { + lines.push(muted("─── Context ───")); + lines.push(""); + if (log.project) { + lines.push(`Project: ${log.project}`); + } + if (log.environment) { + lines.push(`Environment: ${log.environment}`); + } + if (log.release) { + lines.push(`Release: ${log.release}`); + } + lines.push(""); + } + + // SDK section + const sdkName = log["sdk.name"]; + const sdkVersion = log["sdk.version"]; + if (sdkName || sdkVersion) { + lines.push(muted("─── SDK ───")); + lines.push(""); + const sdkInfo = sdkVersion ? `${sdkName} ${sdkVersion}` : sdkName; + lines.push(`SDK: ${sdkInfo}`); + lines.push(""); + } + + // Trace section + if (log.trace) { + lines.push(muted("─── Trace ───")); + lines.push(""); + lines.push(`Trace ID: ${log.trace}`); + if (log.span_id) { + lines.push(`Span ID: ${log.span_id}`); + } + lines.push(`Link: ${buildTraceUrl(orgSlug, log.trace)}`); + lines.push(""); + } + + // Source location section (OTel code attributes) + const codeFunction = log["code.function"]; + const codeFilePath = log["code.file.path"]; + const codeLineNumber = log["code.line.number"]; + if (codeFunction || codeFilePath) { + lines.push(muted("─── Source Location ───")); + lines.push(""); + if (codeFunction) { + lines.push(`Function: ${codeFunction}`); + } + if (codeFilePath) { + const location = codeLineNumber + ? `${codeFilePath}:${codeLineNumber}` + : codeFilePath; + lines.push(`File: ${location}`); + } + lines.push(""); + } + + // OpenTelemetry section (if any OTel fields are present) + const otelKind = log["sentry.otel.kind"]; + const otelStatus = log["sentry.otel.status_code"]; + const otelScope = log["sentry.otel.instrumentation_scope.name"]; + if (otelKind || otelStatus || otelScope) { + lines.push(muted("─── OpenTelemetry ───")); + lines.push(""); + if (otelKind) { + lines.push(`Kind: ${otelKind}`); + } + if (otelStatus) { + lines.push(`Status: ${otelStatus}`); + } + if (otelScope) { + lines.push(`Scope: ${otelScope}`); + } + lines.push(""); + } + + return lines; +} diff --git a/src/lib/sentry-urls.ts b/src/lib/sentry-urls.ts index 7b4f8057..de0e8b0e 100644 --- a/src/lib/sentry-urls.ts +++ b/src/lib/sentry-urls.ts @@ -104,3 +104,28 @@ export function buildBillingUrl(orgSlug: string, product?: string): string { const base = `${getSentryBaseUrl()}/settings/${orgSlug}/billing/overview/`; return product ? `${base}?product=${product}` : base; } + +// Logs URLs + +/** + * Build URL to the Logs explorer, optionally filtered to a specific log entry. + * + * @param orgSlug - Organization slug + * @param logId - Optional log item ID to filter to + * @returns Full URL to the Logs explorer + */ +export function buildLogsUrl(orgSlug: string, logId?: string): string { + const base = `${getSentryBaseUrl()}/organizations/${orgSlug}/explore/logs/`; + return logId ? `${base}?query=sentry.item_id:${logId}` : base; +} + +/** + * Build URL to view a trace in Sentry. + * + * @param orgSlug - Organization slug + * @param traceId - Trace ID (32-character hex string) + * @returns Full URL to the trace view + */ +export function buildTraceUrl(orgSlug: string, traceId: string): string { + return `${getSentryBaseUrl()}/organizations/${orgSlug}/traces/${traceId}/`; +} diff --git a/src/types/index.ts b/src/types/index.ts index 5fc7db40..fc6e4f04 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -49,6 +49,8 @@ export type { Breadcrumb, BreadcrumbsEntry, BrowserContext, + DetailedLogsResponse, + DetailedSentryLog, DeviceContext, ExceptionEntry, ExceptionValue, @@ -84,6 +86,8 @@ export { BreadcrumbSchema, BreadcrumbsEntrySchema, BrowserContextSchema, + DetailedLogsResponseSchema, + DetailedSentryLogSchema, DeviceContextSchema, ExceptionEntrySchema, ExceptionValueSchema, diff --git a/src/types/sentry.ts b/src/types/sentry.ts index 8c3e96d1..8bec36b5 100644 --- a/src/types/sentry.ts +++ b/src/types/sentry.ts @@ -670,3 +670,63 @@ export const LogsResponseSchema = z.object({ }); export type LogsResponse = z.infer; + +/** + * Detailed log entry with all available fields from the logs dataset. + * Used by the `log view` command for comprehensive log display. + */ +export const DetailedSentryLogSchema = 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 */ + 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(), + /** Project slug */ + project: z.string().nullable().optional(), + /** Environment name */ + environment: z.string().nullable().optional(), + /** Release version */ + release: z.string().nullable().optional(), + /** SDK name */ + "sdk.name": z.string().nullable().optional(), + /** SDK version */ + "sdk.version": z.string().nullable().optional(), + /** Span ID for correlation with spans */ + span_id: z.string().nullable().optional(), + /** Function name where log was emitted */ + "code.function": z.string().nullable().optional(), + /** File path where log was emitted */ + "code.file.path": z.string().nullable().optional(), + /** Line number where log was emitted */ + "code.line.number": z.string().nullable().optional(), + /** OpenTelemetry span kind */ + "sentry.otel.kind": z.string().nullable().optional(), + /** OpenTelemetry status code */ + "sentry.otel.status_code": z.string().nullable().optional(), + /** OpenTelemetry instrumentation scope name */ + "sentry.otel.instrumentation_scope.name": z.string().nullable().optional(), + }) + .passthrough(); + +export type DetailedSentryLog = z.infer; + +/** Response from the detailed log query endpoint */ +export const DetailedLogsResponseSchema = z.object({ + data: z.array(DetailedSentryLogSchema), + meta: z + .object({ + fields: z.record(z.string()).optional(), + }) + .passthrough() + .optional(), +}); + +export type DetailedLogsResponse = z.infer; From 82f56fd93b1c157e39e73198f9e27a10ea2afaa0 Mon Sep 17 00:00:00 2001 From: betegon Date: Fri, 6 Feb 2026 14:46:31 +0100 Subject: [PATCH 4/9] docs(log): improve JSDoc on view command functions --- src/commands/log/view.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/commands/log/view.ts b/src/commands/log/view.ts index 6eac2495..68742791 100644 --- a/src/commands/log/view.ts +++ b/src/commands/log/view.ts @@ -27,7 +27,9 @@ const USAGE_HINT = "sentry log view / "; * Parse positional arguments for log view. * Handles: `` or ` ` * + * @param args - Positional arguments from CLI * @returns Parsed log ID and optional target arg + * @throws {ContextError} If no arguments provided */ export function parsePositionalArgs(args: string[]): { logId: string; @@ -49,7 +51,6 @@ export function parsePositionalArgs(args: string[]): { const second = args[1]; if (second === undefined) { - // Should not happen given length check, but TypeScript needs this return { logId: first, targetArg: undefined }; } @@ -106,6 +107,11 @@ async function resolveFromProjectSearch( /** * Write human-readable log output to stdout. + * + * @param stdout - Output stream + * @param log - The log entry to display + * @param orgSlug - Organization slug for trace URLs + * @param detectedFrom - Optional context detection source to display */ function writeHumanOutput( stdout: Writer, From c8e161101bd00a88e01bf4de846e1db2619d33e0 Mon Sep 17 00:00:00 2001 From: betegon Date: Fri, 6 Feb 2026 15:41:59 +0100 Subject: [PATCH 5/9] test(log): add tests for log view command Unit tests: - test/commands/log/view.test.ts: parsePositionalArgs, resolveFromProjectSearch E2E tests: - test/e2e/log.test.ts: auth, context resolution, JSON output, error handling Formatter tests: - test/lib/formatters/log.test.ts: formatLogDetails output sections Supporting changes: - Export resolveFromProjectSearch for testing - Add test/fixtures/log-detail.json fixture - Update test/mocks/routes.ts to handle single log queries --- src/commands/log/view.ts | 7 +- test/commands/log/view.test.ts | 218 ++++++++++++++++++++++++++++++++ test/e2e/log.test.ts | 69 ++++++++++ test/fixtures/log-detail.json | 43 +++++++ test/lib/formatters/log.test.ts | 167 +++++++++++++++++++++++- test/mocks/routes.ts | 15 ++- 6 files changed, 515 insertions(+), 4 deletions(-) create mode 100644 test/commands/log/view.test.ts create mode 100644 test/fixtures/log-detail.json diff --git a/src/commands/log/view.ts b/src/commands/log/view.ts index 68742791..abc49238 100644 --- a/src/commands/log/view.ts +++ b/src/commands/log/view.ts @@ -60,8 +60,9 @@ export function parsePositionalArgs(args: string[]): { /** * Resolved target type for log commands. + * @internal Exported for testing */ -type ResolvedLogTarget = { +export type ResolvedLogTarget = { org: string; project: string; detectedFrom?: string; @@ -78,8 +79,10 @@ type ResolvedLogTarget = { * @returns Resolved target with org and project info * @throws {ContextError} If no project found * @throws {ValidationError} If project exists in multiple organizations + * + * @internal Exported for testing */ -async function resolveFromProjectSearch( +export async function resolveFromProjectSearch( projectSlug: string, logId: string ): Promise { diff --git a/test/commands/log/view.test.ts b/test/commands/log/view.test.ts new file mode 100644 index 00000000..44efd3d5 --- /dev/null +++ b/test/commands/log/view.test.ts @@ -0,0 +1,218 @@ +/** + * Log View Command Tests + * + * Tests for positional argument parsing and project resolution + * in src/commands/log/view.ts + */ + +import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; +import { + parsePositionalArgs, + resolveFromProjectSearch, +} from "../../../src/commands/log/view.js"; +import type { ProjectWithOrg } from "../../../src/lib/api-client.js"; +// biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking +import * as apiClient from "../../../src/lib/api-client.js"; +import { ContextError, ValidationError } from "../../../src/lib/errors.js"; + +describe("parsePositionalArgs", () => { + describe("single argument (log ID only)", () => { + test("parses single arg as log ID", () => { + const result = parsePositionalArgs(["abc123def456"]); + expect(result.logId).toBe("abc123def456"); + expect(result.targetArg).toBeUndefined(); + }); + + test("parses 32-char hex log ID", () => { + const result = parsePositionalArgs(["968c763c740cfda8b6728f27fb9e9b01"]); + expect(result.logId).toBe("968c763c740cfda8b6728f27fb9e9b01"); + expect(result.targetArg).toBeUndefined(); + }); + + test("parses short log ID", () => { + const result = parsePositionalArgs(["abc"]); + expect(result.logId).toBe("abc"); + expect(result.targetArg).toBeUndefined(); + }); + }); + + describe("two arguments (target + log ID)", () => { + test("parses org/project target and log ID", () => { + const result = parsePositionalArgs(["my-org/frontend", "abc123def456"]); + expect(result.targetArg).toBe("my-org/frontend"); + expect(result.logId).toBe("abc123def456"); + }); + + test("parses project-only target and log ID", () => { + const result = parsePositionalArgs(["frontend", "abc123def456"]); + expect(result.targetArg).toBe("frontend"); + expect(result.logId).toBe("abc123def456"); + }); + + test("parses org/ target (all projects) and log ID", () => { + const result = parsePositionalArgs(["my-org/", "abc123def456"]); + expect(result.targetArg).toBe("my-org/"); + expect(result.logId).toBe("abc123def456"); + }); + }); + + describe("error cases", () => { + test("throws ContextError for empty args", () => { + expect(() => parsePositionalArgs([])).toThrow(ContextError); + }); + + test("throws ContextError with usage hint", () => { + try { + parsePositionalArgs([]); + expect.unreachable("Should have thrown"); + } catch (error) { + expect(error).toBeInstanceOf(ContextError); + expect((error as ContextError).message).toContain("Log ID"); + } + }); + }); + + describe("edge cases", () => { + test("handles more than two args (ignores extras)", () => { + const result = parsePositionalArgs([ + "my-org/frontend", + "abc123", + "extra-arg", + ]); + expect(result.targetArg).toBe("my-org/frontend"); + expect(result.logId).toBe("abc123"); + }); + + test("handles empty string log ID in two-arg case", () => { + const result = parsePositionalArgs(["my-org/frontend", ""]); + expect(result.targetArg).toBe("my-org/frontend"); + expect(result.logId).toBe(""); + }); + }); +}); + +describe("resolveFromProjectSearch", () => { + let findProjectsBySlugSpy: ReturnType; + + beforeEach(() => { + findProjectsBySlugSpy = spyOn(apiClient, "findProjectsBySlug"); + }); + + afterEach(() => { + findProjectsBySlugSpy.mockRestore(); + }); + + describe("no projects found", () => { + test("throws ContextError when project not found", async () => { + findProjectsBySlugSpy.mockResolvedValue([]); + + await expect( + resolveFromProjectSearch("my-project", "log-123") + ).rejects.toThrow(ContextError); + }); + + test("includes project name in error message", async () => { + findProjectsBySlugSpy.mockResolvedValue([]); + + try { + await resolveFromProjectSearch("frontend", "log-123"); + expect.unreachable("Should have thrown"); + } catch (error) { + expect(error).toBeInstanceOf(ContextError); + expect((error as ContextError).message).toContain('Project "frontend"'); + expect((error as ContextError).message).toContain( + "Check that you have access" + ); + } + }); + }); + + describe("multiple projects found", () => { + test("throws ValidationError when project exists in multiple orgs", async () => { + findProjectsBySlugSpy.mockResolvedValue([ + { slug: "frontend", orgSlug: "org-a", id: "1", name: "Frontend" }, + { slug: "frontend", orgSlug: "org-b", id: "2", name: "Frontend" }, + ] as ProjectWithOrg[]); + + await expect( + resolveFromProjectSearch("frontend", "log-123") + ).rejects.toThrow(ValidationError); + }); + + test("includes all orgs in error message", async () => { + findProjectsBySlugSpy.mockResolvedValue([ + { slug: "frontend", orgSlug: "acme-corp", id: "1", name: "Frontend" }, + { slug: "frontend", orgSlug: "beta-inc", id: "2", name: "Frontend" }, + ] as ProjectWithOrg[]); + + try { + await resolveFromProjectSearch("frontend", "log-456"); + expect.unreachable("Should have thrown"); + } catch (error) { + expect(error).toBeInstanceOf(ValidationError); + const message = (error as ValidationError).message; + expect(message).toContain("exists in multiple organizations"); + expect(message).toContain("acme-corp/frontend"); + expect(message).toContain("beta-inc/frontend"); + expect(message).toContain("log-456"); // Log ID in example + } + }); + + test("includes usage example in error message", async () => { + findProjectsBySlugSpy.mockResolvedValue([ + { slug: "api", orgSlug: "org-1", id: "1", name: "API" }, + { slug: "api", orgSlug: "org-2", id: "2", name: "API" }, + { slug: "api", orgSlug: "org-3", id: "3", name: "API" }, + ] as ProjectWithOrg[]); + + try { + await resolveFromProjectSearch("api", "abc123"); + expect.unreachable("Should have thrown"); + } catch (error) { + expect(error).toBeInstanceOf(ValidationError); + const message = (error as ValidationError).message; + expect(message).toContain("Example: sentry log view api abc123"); + } + }); + }); + + describe("single project found", () => { + test("returns resolved target for single match", async () => { + findProjectsBySlugSpy.mockResolvedValue([ + { slug: "backend", orgSlug: "my-company", id: "42", name: "Backend" }, + ] as ProjectWithOrg[]); + + const result = await resolveFromProjectSearch("backend", "log-xyz"); + + expect(result).toEqual({ + org: "my-company", + project: "backend", + }); + }); + + test("uses orgSlug from project result", async () => { + findProjectsBySlugSpy.mockResolvedValue([ + { + slug: "mobile-app", + orgSlug: "acme-industries", + id: "100", + name: "Mobile App", + }, + ] as ProjectWithOrg[]); + + const result = await resolveFromProjectSearch("mobile-app", "log-001"); + + expect(result.org).toBe("acme-industries"); + }); + + test("preserves project slug in result", async () => { + findProjectsBySlugSpy.mockResolvedValue([ + { slug: "web-frontend", orgSlug: "org", id: "1", name: "Web Frontend" }, + ] as ProjectWithOrg[]); + + const result = await resolveFromProjectSearch("web-frontend", "log123"); + + expect(result.project).toBe("web-frontend"); + }); + }); +}); diff --git a/test/e2e/log.test.ts b/test/e2e/log.test.ts index 3b53151d..b4586700 100644 --- a/test/e2e/log.test.ts +++ b/test/e2e/log.test.ts @@ -17,6 +17,7 @@ import { createE2EContext, type E2EContext } from "../fixture.js"; import { cleanupTestDir, createTestConfigDir } from "../helpers.js"; import { createSentryMockServer, + TEST_LOG_ID, TEST_ORG, TEST_PROJECT, TEST_TOKEN, @@ -141,3 +142,71 @@ describe("sentry log list", () => { expect(result.stdout).toMatch(/poll interval/i); }); }); + +describe("sentry log view", () => { + test("requires authentication", async () => { + const result = await ctx.run([ + "log", + "view", + `${TEST_ORG}/${TEST_PROJECT}`, + TEST_LOG_ID, + ]); + + expect(result.exitCode).toBe(1); + expect(result.stderr + result.stdout).toMatch(/not authenticated|login/i); + }); + + test("requires org and project without DSN", async () => { + await ctx.setAuthToken(TEST_TOKEN); + + const result = await ctx.run(["log", "view", TEST_LOG_ID]); + + expect(result.exitCode).toBe(1); + expect(result.stderr + result.stdout).toMatch(/organization|project/i); + }); + + test("fetches log with valid auth", async () => { + await ctx.setAuthToken(TEST_TOKEN); + + const result = await ctx.run([ + "log", + "view", + `${TEST_ORG}/${TEST_PROJECT}`, + TEST_LOG_ID, + ]); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("Log"); + expect(result.stdout).toContain(TEST_LOG_ID); + }); + + test("supports --json output", async () => { + await ctx.setAuthToken(TEST_TOKEN); + + const result = await ctx.run([ + "log", + "view", + `${TEST_ORG}/${TEST_PROJECT}`, + TEST_LOG_ID, + "--json", + ]); + + expect(result.exitCode).toBe(0); + const data = JSON.parse(result.stdout); + expect(data["sentry.item_id"]).toBe(TEST_LOG_ID); + }); + + test("handles non-existent log", async () => { + await ctx.setAuthToken(TEST_TOKEN); + + const result = await ctx.run([ + "log", + "view", + `${TEST_ORG}/${TEST_PROJECT}`, + "nonexistent-log-id-12345", + ]); + + expect(result.exitCode).toBe(1); + expect(result.stderr + result.stdout).toMatch(/not found|no log/i); + }); +}); diff --git a/test/fixtures/log-detail.json b/test/fixtures/log-detail.json new file mode 100644 index 00000000..f268be3b --- /dev/null +++ b/test/fixtures/log-detail.json @@ -0,0 +1,43 @@ +{ + "data": [ + { + "sentry.item_id": "log-detail-001", + "timestamp": "2025-01-30T14:32:15+00:00", + "timestamp_precise": 1770060419044800300, + "message": "Detailed test log message", + "severity": "info", + "trace": "abc123def456abc123def456abc12345", + "project": "test-project", + "environment": "production", + "release": "1.0.0", + "sdk.name": "sentry.javascript.node", + "sdk.version": "8.0.0", + "span_id": "span123abc", + "code.function": "handleRequest", + "code.file.path": "src/handlers/api.ts", + "code.line.number": "42", + "sentry.otel.kind": null, + "sentry.otel.status_code": null, + "sentry.otel.instrumentation_scope.name": null + } + ], + "meta": { + "fields": { + "sentry.item_id": "string", + "timestamp": "string", + "timestamp_precise": "number", + "message": "string", + "severity": "string", + "trace": "string", + "project": "string", + "environment": "string", + "release": "string", + "sdk.name": "string", + "sdk.version": "string", + "span_id": "string", + "code.function": "string", + "code.file.path": "string", + "code.line.number": "string" + } + } +} diff --git a/test/lib/formatters/log.test.ts b/test/lib/formatters/log.test.ts index 7b5d2dca..0ccb8cba 100644 --- a/test/lib/formatters/log.test.ts +++ b/test/lib/formatters/log.test.ts @@ -4,10 +4,11 @@ import { describe, expect, test } from "bun:test"; import { + formatLogDetails, formatLogRow, formatLogsHeader, } from "../../../src/lib/formatters/log.js"; -import type { SentryLog } from "../../../src/types/index.js"; +import type { DetailedSentryLog, SentryLog } from "../../../src/types/index.js"; function createTestLog(overrides: Partial = {}): SentryLog { return { @@ -136,3 +137,167 @@ describe("formatLogsHeader", () => { expect(result).toEndWith("\n"); }); }); + +function createDetailedTestLog( + overrides: Partial = {} +): DetailedSentryLog { + return { + "sentry.item_id": "test-log-id-123456789012345678901234", + timestamp: "2025-01-30T14:32:15Z", + timestamp_precise: 1_770_060_419_044_800_300, + message: "Test log message", + severity: "info", + trace: "abc123def456abc123def456abc12345", + project: "test-project", + environment: "production", + release: "1.0.0", + "sdk.name": "sentry.javascript.node", + "sdk.version": "8.0.0", + span_id: null, + "code.function": null, + "code.file.path": null, + "code.line.number": null, + "sentry.otel.kind": null, + "sentry.otel.status_code": null, + "sentry.otel.instrumentation_scope.name": null, + ...overrides, + }; +} + +describe("formatLogDetails", () => { + test("formats basic log entry with header", () => { + const log = createDetailedTestLog(); + const lines = formatLogDetails(log, "test-org"); + const result = lines.join("\n"); + + expect(result).toContain("Log test-log-id"); + expect(result).toContain("═"); // Header separator + }); + + test("includes ID, timestamp, and severity", () => { + const log = createDetailedTestLog(); + const lines = formatLogDetails(log, "test-org"); + const result = stripAnsi(lines.join("\n")); + + expect(result).toContain("ID:"); + expect(result).toContain("test-log-id-123456789012345678901234"); + expect(result).toContain("Timestamp:"); + expect(result).toContain("Severity:"); + expect(result).toContain("INFO"); + }); + + test("includes message when present", () => { + const log = createDetailedTestLog({ message: "Custom error message" }); + const lines = formatLogDetails(log, "test-org"); + const result = lines.join("\n"); + + expect(result).toContain("Message:"); + expect(result).toContain("Custom error message"); + }); + + test("shows Context section when project/environment/release present", () => { + const log = createDetailedTestLog({ + project: "my-project", + environment: "staging", + release: "2.0.0", + }); + const lines = formatLogDetails(log, "test-org"); + const result = stripAnsi(lines.join("\n")); + + expect(result).toContain("Context"); + expect(result).toContain("Project:"); + expect(result).toContain("my-project"); + expect(result).toContain("Environment:"); + expect(result).toContain("staging"); + expect(result).toContain("Release:"); + expect(result).toContain("2.0.0"); + }); + + test("shows SDK section when sdk.name present", () => { + const log = createDetailedTestLog({ + "sdk.name": "sentry.python", + "sdk.version": "2.0.0", + }); + const lines = formatLogDetails(log, "test-org"); + const result = stripAnsi(lines.join("\n")); + + expect(result).toContain("SDK"); + expect(result).toContain("sentry.python"); + expect(result).toContain("2.0.0"); + }); + + test("shows Trace section with URL when trace ID present", () => { + const log = createDetailedTestLog({ + trace: "trace123abc456def789", + span_id: "span-abc-123", + }); + const lines = formatLogDetails(log, "my-org"); + const result = stripAnsi(lines.join("\n")); + + expect(result).toContain("Trace"); + expect(result).toContain("Trace ID:"); + expect(result).toContain("trace123abc456def789"); + expect(result).toContain("Span ID:"); + expect(result).toContain("span-abc-123"); + expect(result).toContain("Link:"); + expect(result).toContain("my-org/traces/trace123abc456def789"); + }); + + test("shows Source Location when code.function present", () => { + const log = createDetailedTestLog({ + "code.function": "handleRequest", + "code.file.path": "src/api/handler.ts", + "code.line.number": "42", + }); + const lines = formatLogDetails(log, "test-org"); + const result = stripAnsi(lines.join("\n")); + + expect(result).toContain("Source Location"); + expect(result).toContain("Function:"); + expect(result).toContain("handleRequest"); + expect(result).toContain("File:"); + expect(result).toContain("src/api/handler.ts:42"); + }); + + test("shows OpenTelemetry section when otel fields present", () => { + const log = createDetailedTestLog({ + "sentry.otel.kind": "server", + "sentry.otel.status_code": "OK", + "sentry.otel.instrumentation_scope.name": "express", + }); + const lines = formatLogDetails(log, "test-org"); + const result = stripAnsi(lines.join("\n")); + + expect(result).toContain("OpenTelemetry"); + expect(result).toContain("Kind:"); + expect(result).toContain("server"); + expect(result).toContain("Status:"); + expect(result).toContain("OK"); + expect(result).toContain("Scope:"); + expect(result).toContain("express"); + }); + + test("handles missing optional fields gracefully", () => { + const log = createDetailedTestLog({ + message: null, + trace: null, + project: null, + environment: null, + release: null, + "sdk.name": null, + "sdk.version": null, + }); + const lines = formatLogDetails(log, "test-org"); + const result = stripAnsi(lines.join("\n")); + + // Should still have basic info + expect(result).toContain("ID:"); + expect(result).toContain("Timestamp:"); + expect(result).toContain("Severity:"); + + // Should not have optional sections + expect(result).not.toContain("Context"); + expect(result).not.toContain("SDK"); + expect(result).not.toContain("Trace"); + }); +}); diff --git a/test/mocks/routes.ts b/test/mocks/routes.ts index a3ad08d5..d2afccbf 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 logDetailFixture from "../fixtures/log-detail.json"; import logsFixture from "../fixtures/logs.json"; import organizationFixture from "../fixtures/organization.json"; import organizationsFixture from "../fixtures/organizations.json"; @@ -26,6 +27,7 @@ export const TEST_ISSUE_ID = "400001"; export const TEST_ISSUE_SHORT_ID = "TEST-PROJECT-1A"; export const TEST_EVENT_ID = "abc123def456abc123def456abc12345"; export const TEST_DSN = "https://abc123@o123.ingest.sentry.io/456789"; +export const TEST_LOG_ID = "log-detail-001"; const projectKeysFixture = [ { @@ -191,8 +193,19 @@ export const apiRoutes: MockRoute[] = [ { method: "GET", path: "/api/0/organizations/:orgSlug/events/", - response: (_req, params) => { + response: (req, params) => { if (params.orgSlug === TEST_ORG) { + const url = new URL(req.url); + const query = url.searchParams.get("query"); + // If query contains sentry.item_id filter, return detailed log + if (query?.includes("sentry.item_id:")) { + const logId = query.replace("sentry.item_id:", "").trim(); + if (logId === TEST_LOG_ID) { + return { body: logDetailFixture }; + } + // Return empty data for non-existent log + return { body: { data: [], meta: { fields: {} } } }; + } return { body: logsFixture }; } return { status: 404, body: notFoundFixture }; From 899d66ffc8b3081c09320b747d21905464ab6ab5 Mon Sep 17 00:00:00 2001 From: betegon Date: Fri, 6 Feb 2026 15:49:02 +0100 Subject: [PATCH 6/9] test(log): add property-based tests for log view command Property tests for: - buildLogsUrl and buildTraceUrl URL builders - parsePositionalArgs argument parsing - formatLogDetails output formatting Tests verify invariants like: - URL validity, determinism, expected patterns - Correct field assignment regardless of input - Conditional sections appear only when data present --- test/commands/log/view.property.test.ts | 144 +++++++++++++++ test/lib/formatters/log.property.test.ts | 220 +++++++++++++++++++++++ test/lib/sentry-urls.property.test.ts | 94 ++++++++++ 3 files changed, 458 insertions(+) create mode 100644 test/commands/log/view.property.test.ts create mode 100644 test/lib/formatters/log.property.test.ts diff --git a/test/commands/log/view.property.test.ts b/test/commands/log/view.property.test.ts new file mode 100644 index 00000000..c0582d42 --- /dev/null +++ b/test/commands/log/view.property.test.ts @@ -0,0 +1,144 @@ +/** + * Property-Based Tests for Log View Command + * + * Uses fast-check to verify invariants of parsePositionalArgs() + * that should hold for any valid input. + */ + +import { describe, expect, test } from "bun:test"; +import { + array, + assert as fcAssert, + property, + string, + stringMatching, + tuple, +} from "fast-check"; +import { parsePositionalArgs } from "../../../src/commands/log/view.js"; +import { ContextError } from "../../../src/lib/errors.js"; +import { DEFAULT_NUM_RUNS } from "../../model-based/helpers.js"; + +/** Valid log IDs (32-char hex) */ +const logIdArb = stringMatching(/^[a-f0-9]{32}$/); + +/** Valid org/project slugs */ +const slugArb = stringMatching(/^[a-z][a-z0-9-]{1,20}[a-z0-9]$/); + +/** Non-empty strings for general args */ +const nonEmptyStringArb = string({ minLength: 1, maxLength: 50 }); + +describe("parsePositionalArgs properties", () => { + test("single arg: always returns it as logId with undefined targetArg", async () => { + await fcAssert( + property(nonEmptyStringArb, (input) => { + const result = parsePositionalArgs([input]); + expect(result.logId).toBe(input); + expect(result.targetArg).toBeUndefined(); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("two args: first is always targetArg, second is always logId", async () => { + await fcAssert( + property( + tuple(nonEmptyStringArb, nonEmptyStringArb), + ([first, second]) => { + const result = parsePositionalArgs([first, second]); + expect(result.targetArg).toBe(first); + expect(result.logId).toBe(second); + } + ), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("org/project target format: correctly splits target and logId", async () => { + await fcAssert( + property(tuple(slugArb, slugArb, logIdArb), ([org, project, logId]) => { + const target = `${org}/${project}`; + const result = parsePositionalArgs([target, logId]); + + expect(result.targetArg).toBe(target); + expect(result.logId).toBe(logId); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("extra args are ignored: only first two matter", async () => { + await fcAssert( + property( + tuple( + nonEmptyStringArb, + nonEmptyStringArb, + array(nonEmptyStringArb, { minLength: 1, maxLength: 5 }) + ), + ([first, second, extras]) => { + const args = [first, second, ...extras]; + const result = parsePositionalArgs(args); + + expect(result.targetArg).toBe(first); + expect(result.logId).toBe(second); + } + ), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("parsing is deterministic: same input always produces same output", async () => { + await fcAssert( + property( + array(nonEmptyStringArb, { minLength: 1, maxLength: 3 }), + (args) => { + const result1 = parsePositionalArgs(args); + const result2 = parsePositionalArgs(args); + expect(result1).toEqual(result2); + } + ), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("empty args always throws ContextError", () => { + expect(() => parsePositionalArgs([])).toThrow(ContextError); + }); + + test("result always has logId property defined", async () => { + await fcAssert( + property( + array(nonEmptyStringArb, { minLength: 1, maxLength: 3 }), + (args) => { + const result = parsePositionalArgs(args); + expect(result.logId).toBeDefined(); + expect(typeof result.logId).toBe("string"); + } + ), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("result targetArg is undefined for single arg, defined for multiple", async () => { + // Single arg case + await fcAssert( + property(nonEmptyStringArb, (input) => { + const result = parsePositionalArgs([input]); + expect(result.targetArg).toBeUndefined(); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + + // Two+ args case + await fcAssert( + property( + array(nonEmptyStringArb, { minLength: 2, maxLength: 4 }), + (args) => { + const result = parsePositionalArgs(args); + expect(result.targetArg).toBeDefined(); + expect(typeof result.targetArg).toBe("string"); + } + ), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); +}); diff --git a/test/lib/formatters/log.property.test.ts b/test/lib/formatters/log.property.test.ts new file mode 100644 index 00000000..42e73f6e --- /dev/null +++ b/test/lib/formatters/log.property.test.ts @@ -0,0 +1,220 @@ +/** + * Property-Based Tests for Log Formatters + * + * Uses fast-check to verify invariants of formatLogDetails() + * that should hold for any valid input. + */ + +import { describe, expect, test } from "bun:test"; +import { + constant, + assert as fcAssert, + oneof, + option, + property, + record, + stringMatching, +} from "fast-check"; +import { formatLogDetails } from "../../../src/lib/formatters/log.js"; +import type { DetailedSentryLog } from "../../../src/types/index.js"; +import { DEFAULT_NUM_RUNS } from "../../model-based/helpers.js"; + +/** Valid log IDs (32-char hex) */ +const logIdArb = stringMatching(/^[a-f0-9]{32}$/); + +/** Valid org slugs */ +const orgSlugArb = stringMatching(/^[a-z][a-z0-9-]{1,20}[a-z0-9]$/); + +/** Valid trace IDs (32-char hex) */ +const traceIdArb = stringMatching(/^[a-f0-9]{32}$/); + +/** ISO timestamp */ +const timestampArb = constant("2025-01-30T14:32:15Z"); + +/** Timestamp precise (nanoseconds) */ +const timestampPreciseArb = constant(1_770_060_419_044_800_300); + +/** Log severity levels */ +const severityArb = oneof( + constant("info"), + constant("warning"), + constant("error"), + constant("debug"), + constant("fatal") +); + +/** Optional string (string or null) */ +const optionalStringArb = option(stringMatching(/^[a-zA-Z0-9_.-]{1,50}$/), { + nil: null, +}); + +/** Generate DetailedSentryLog objects with various field combinations */ +function createDetailedLogArb() { + return record({ + "sentry.item_id": logIdArb, + timestamp: timestampArb, + timestamp_precise: timestampPreciseArb, + message: optionalStringArb, + severity: option(severityArb, { nil: null }), + trace: option(traceIdArb, { nil: null }), + project: optionalStringArb, + environment: optionalStringArb, + release: optionalStringArb, + "sdk.name": optionalStringArb, + "sdk.version": optionalStringArb, + span_id: optionalStringArb, + "code.function": optionalStringArb, + "code.file.path": optionalStringArb, + "code.line.number": optionalStringArb, + "sentry.otel.kind": optionalStringArb, + "sentry.otel.status_code": optionalStringArb, + "sentry.otel.instrumentation_scope.name": optionalStringArb, + }); +} + +const detailedLogArb = createDetailedLogArb(); + +describe("formatLogDetails properties", () => { + test("always returns non-empty array", async () => { + await fcAssert( + property( + detailedLogArb, + orgSlugArb, + (log: DetailedSentryLog, orgSlug: string) => { + const result = formatLogDetails(log, orgSlug); + expect(Array.isArray(result)).toBe(true); + expect(result.length).toBeGreaterThan(0); + } + ), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("always contains the log ID", async () => { + await fcAssert( + property( + detailedLogArb, + orgSlugArb, + (log: DetailedSentryLog, orgSlug: string) => { + const result = formatLogDetails(log, orgSlug).join("\n"); + expect(result).toContain(log["sentry.item_id"]); + } + ), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("always contains timestamp info", async () => { + await fcAssert( + property( + detailedLogArb, + orgSlugArb, + (log: DetailedSentryLog, orgSlug: string) => { + const result = formatLogDetails(log, orgSlug).join("\n"); + expect(result).toContain("Timestamp:"); + } + ), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("always contains severity info", async () => { + await fcAssert( + property( + detailedLogArb, + orgSlugArb, + (log: DetailedSentryLog, orgSlug: string) => { + const result = formatLogDetails(log, orgSlug).join("\n"); + expect(result).toContain("Severity:"); + } + ), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("trace URL only appears when trace ID is present", async () => { + await fcAssert( + property( + detailedLogArb, + orgSlugArb, + (log: DetailedSentryLog, orgSlug: string) => { + const result = formatLogDetails(log, orgSlug).join("\n"); + if (log.trace) { + expect(result).toContain("/traces/"); + expect(result).toContain(log.trace); + } else { + expect(result).not.toContain("/traces/"); + } + } + ), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("SDK section only appears when sdk.name is present", async () => { + await fcAssert( + property( + detailedLogArb, + orgSlugArb, + (log: DetailedSentryLog, orgSlug: string) => { + const result = formatLogDetails(log, orgSlug).join("\n"); + if (log["sdk.name"]) { + expect(result).toContain("SDK"); + expect(result).toContain(log["sdk.name"]); + } + } + ), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("Source Location section only appears when code fields are present", async () => { + await fcAssert( + property( + detailedLogArb, + orgSlugArb, + (log: DetailedSentryLog, orgSlug: string) => { + const result = formatLogDetails(log, orgSlug).join("\n"); + const hasCodeFields = log["code.function"] || log["code.file.path"]; + if (hasCodeFields) { + expect(result).toContain("Source Location"); + } else { + expect(result).not.toContain("Source Location"); + } + } + ), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("formatting is deterministic: same input always produces same output", async () => { + await fcAssert( + property( + detailedLogArb, + orgSlugArb, + (log: DetailedSentryLog, orgSlug: string) => { + const result1 = formatLogDetails(log, orgSlug); + const result2 = formatLogDetails(log, orgSlug); + expect(result1).toEqual(result2); + } + ), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("output lines are all strings", async () => { + await fcAssert( + property( + detailedLogArb, + orgSlugArb, + (log: DetailedSentryLog, orgSlug: string) => { + const result = formatLogDetails(log, orgSlug); + for (const line of result) { + expect(typeof line).toBe("string"); + } + } + ), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); +}); diff --git a/test/lib/sentry-urls.property.test.ts b/test/lib/sentry-urls.property.test.ts index 39696b88..ac645104 100644 --- a/test/lib/sentry-urls.property.test.ts +++ b/test/lib/sentry-urls.property.test.ts @@ -17,10 +17,12 @@ import { import { buildBillingUrl, buildEventSearchUrl, + buildLogsUrl, buildOrgSettingsUrl, buildOrgUrl, buildProjectUrl, buildSeerSettingsUrl, + buildTraceUrl, getSentryBaseUrl, isSentrySaasUrl, } from "../../src/lib/sentry-urls.js"; @@ -56,6 +58,12 @@ const slugArb = stringMatching(/^[a-z][a-z0-9-]{1,30}[a-z0-9]$/); /** Valid event IDs (32-char hex) */ const eventIdArb = stringMatching(/^[a-f0-9]{32}$/); +/** Valid log IDs (32-char hex, same format as event IDs) */ +const logIdArb = stringMatching(/^[a-f0-9]{32}$/); + +/** Valid trace IDs (32-char hex) */ +const traceIdArb = stringMatching(/^[a-f0-9]{32}$/); + /** Common Sentry regions */ const sentryRegionArb = constantFrom("us", "de", "eu", "staging"); @@ -362,6 +370,92 @@ describe("buildBillingUrl properties", () => { }); }); +describe("buildLogsUrl properties", () => { + test("without logId, output has no query string", async () => { + await fcAssert( + property(slugArb, (orgSlug) => { + const result = buildLogsUrl(orgSlug); + expect(result.includes("?")).toBe(false); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("with logId, output contains query parameter with log ID", async () => { + await fcAssert( + property(tuple(slugArb, logIdArb), ([orgSlug, logId]) => { + const result = buildLogsUrl(orgSlug, logId); + expect(result).toContain(`?query=sentry.item_id:${logId}`); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("output contains /explore/logs/ path", async () => { + await fcAssert( + property(slugArb, (orgSlug) => { + const result = buildLogsUrl(orgSlug); + expect(result).toContain("/explore/logs/"); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("output is a valid URL", async () => { + await fcAssert( + property(tuple(slugArb, logIdArb), ([orgSlug, logId]) => { + expect(() => new URL(buildLogsUrl(orgSlug))).not.toThrow(); + expect(() => new URL(buildLogsUrl(orgSlug, logId))).not.toThrow(); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); +}); + +describe("buildTraceUrl properties", () => { + test("output contains /traces/ path with trace ID", async () => { + await fcAssert( + property(tuple(slugArb, traceIdArb), ([orgSlug, traceId]) => { + const result = buildTraceUrl(orgSlug, traceId); + expect(result).toContain(`/traces/${traceId}/`); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("output contains the org slug", async () => { + await fcAssert( + property(tuple(slugArb, traceIdArb), ([orgSlug, traceId]) => { + const result = buildTraceUrl(orgSlug, traceId); + expect(result).toContain(orgSlug); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("output is a valid URL", async () => { + await fcAssert( + property(tuple(slugArb, traceIdArb), ([orgSlug, traceId]) => { + const result = buildTraceUrl(orgSlug, traceId); + expect(() => new URL(result)).not.toThrow(); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("output follows expected pattern", async () => { + await fcAssert( + property(tuple(slugArb, traceIdArb), ([orgSlug, traceId]) => { + const result = buildTraceUrl(orgSlug, traceId); + expect(result).toBe( + `${getSentryBaseUrl()}/organizations/${orgSlug}/traces/${traceId}/` + ); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); +}); + describe("URL building cross-function properties", () => { test("all URL builders produce valid URLs", async () => { await fcAssert( From b9b9fa08dca4db47eba7a15c5ece6fd8caa8b14d Mon Sep 17 00:00:00 2001 From: betegon Date: Fri, 6 Feb 2026 16:03:48 +0100 Subject: [PATCH 7/9] chore: regenerate skill with log view command --- plugins/sentry-cli/skills/sentry-cli/SKILL.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/plugins/sentry-cli/skills/sentry-cli/SKILL.md b/plugins/sentry-cli/skills/sentry-cli/SKILL.md index 61c713d9..27ba91c3 100644 --- a/plugins/sentry-cli/skills/sentry-cli/SKILL.md +++ b/plugins/sentry-cli/skills/sentry-cli/SKILL.md @@ -463,6 +463,14 @@ sentry log list my-org/backend -f -q 'level:error' sentry log list --json | jq '.[] | select(.level == "error")' ``` +#### `sentry log view ` + +View details of a specific log entry + +**Flags:** +- `--json - Output as JSON` +- `-w, --web - Open in browser` + ### Issues List issues in a project From d28ea90b948b0106484d72b27679a5a0e505ada2 Mon Sep 17 00:00:00 2001 From: betegon Date: Fri, 6 Feb 2026 16:16:00 +0100 Subject: [PATCH 8/9] fix(log): address review comments - Include org placeholder in multi-project error example message - Fix SDK formatting when only version exists (no name) - Use dynamic header length for separator width instead of hardcoded value --- src/commands/log/view.ts | 2 +- src/lib/formatters/log.ts | 10 ++++++++-- test/commands/log/view.test.ts | 2 +- 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/src/commands/log/view.ts b/src/commands/log/view.ts index abc49238..0aef0ac5 100644 --- a/src/commands/log/view.ts +++ b/src/commands/log/view.ts @@ -97,7 +97,7 @@ export async function resolveFromProjectSearch( throw new ValidationError( `Project "${projectSlug}" exists in multiple organizations.\n\n` + `Specify the organization:\n${orgList}\n\n` + - `Example: sentry log view ${projectSlug} ${logId}` + `Example: sentry log view /${projectSlug} ${logId}` ); } // Safe assertion: length is exactly 1 after the checks above diff --git a/src/lib/formatters/log.ts b/src/lib/formatters/log.ts index 8ff6ae0d..7473cc5b 100644 --- a/src/lib/formatters/log.ts +++ b/src/lib/formatters/log.ts @@ -113,7 +113,10 @@ export function formatLogDetails( // Header const headerText = `Log ${logId.slice(0, 12)}...`; - const separatorWidth = Math.max(MIN_HEADER_WIDTH, Math.min(80, 40)); + const separatorWidth = Math.max( + MIN_HEADER_WIDTH, + Math.min(80, headerText.length) + ); lines.push(headerText); lines.push(muted("═".repeat(separatorWidth))); lines.push(""); @@ -153,7 +156,10 @@ export function formatLogDetails( if (sdkName || sdkVersion) { lines.push(muted("─── SDK ───")); lines.push(""); - const sdkInfo = sdkVersion ? `${sdkName} ${sdkVersion}` : sdkName; + const sdkInfo = + sdkName && sdkVersion + ? `${sdkName} ${sdkVersion}` + : sdkName || sdkVersion; lines.push(`SDK: ${sdkInfo}`); lines.push(""); } diff --git a/test/commands/log/view.test.ts b/test/commands/log/view.test.ts index 44efd3d5..92d4ac4d 100644 --- a/test/commands/log/view.test.ts +++ b/test/commands/log/view.test.ts @@ -171,7 +171,7 @@ describe("resolveFromProjectSearch", () => { } catch (error) { expect(error).toBeInstanceOf(ValidationError); const message = (error as ValidationError).message; - expect(message).toContain("Example: sentry log view api abc123"); + expect(message).toContain("Example: sentry log view /api abc123"); } }); }); From 78fe982d2566375c1714d7624dc1b417d43f96e8 Mon Sep 17 00:00:00 2001 From: betegon Date: Fri, 6 Feb 2026 17:52:29 +0100 Subject: [PATCH 9/9] fix(log): filter by project and use ValidationError for not found - Add projectSlug parameter to getLog() API function for proper filtering - Use project:slug filter in query to ensure logs match the resolved project - Change 'log not found' error from ContextError to ValidationError (the log ID was provided, it just doesn't exist - not a missing context issue) - Update mock server to parse project filter from query string --- src/commands/log/view.ts | 5 ++--- src/lib/api-client.ts | 6 +++++- test/mocks/routes.ts | 4 +++- 3 files changed, 10 insertions(+), 5 deletions(-) diff --git a/src/commands/log/view.ts b/src/commands/log/view.ts index 0aef0ac5..3720a804 100644 --- a/src/commands/log/view.ts +++ b/src/commands/log/view.ts @@ -219,11 +219,10 @@ export const viewCommand = buildCommand({ } // Fetch the log entry - const log = await getLog(target.org, logId); + const log = await getLog(target.org, target.project, logId); if (!log) { - throw new ContextError( - `Log "${logId}"`, + throw new ValidationError( `No log found with ID "${logId}" in ${target.org}/${target.project}.\n\n` + "Make sure the log ID is correct and the log was sent within the last 90 days." ); diff --git a/src/lib/api-client.ts b/src/lib/api-client.ts index 285d732c..e21a94da 100644 --- a/src/lib/api-client.ts +++ b/src/lib/api-client.ts @@ -1102,20 +1102,24 @@ const DETAILED_LOG_FIELDS = [ * Uses the Explore/Events API with dataset=logs and a filter query. * * @param orgSlug - Organization slug + * @param projectSlug - Project slug for filtering * @param logId - The sentry.item_id of the log entry * @returns The detailed log entry, or null if not found */ export async function getLog( orgSlug: string, + projectSlug: string, logId: string ): Promise { + const query = `project:${projectSlug} sentry.item_id:${logId}`; + const response = await orgScopedRequest( `/organizations/${orgSlug}/events/`, { params: { dataset: "logs", field: DETAILED_LOG_FIELDS, - query: `sentry.item_id:${logId}`, + query, per_page: 1, statsPeriod: "90d", }, diff --git a/test/mocks/routes.ts b/test/mocks/routes.ts index d2afccbf..80ca3715 100644 --- a/test/mocks/routes.ts +++ b/test/mocks/routes.ts @@ -198,8 +198,10 @@ export const apiRoutes: MockRoute[] = [ const url = new URL(req.url); const query = url.searchParams.get("query"); // If query contains sentry.item_id filter, return detailed log + // Query format: "project:${projectSlug} sentry.item_id:${logId}" if (query?.includes("sentry.item_id:")) { - const logId = query.replace("sentry.item_id:", "").trim(); + const logIdMatch = query.match(/sentry\.item_id:(\S+)/); + const logId = logIdMatch?.[1]; if (logId === TEST_LOG_ID) { return { body: logDetailFixture }; }