Skip to content
Merged
8 changes: 8 additions & 0 deletions plugins/sentry-cli/skills/sentry-cli/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <args...>`

View details of a specific log entry

**Flags:**
- `--json - Output as JSON`
- `-w, --web - Open in browser`

### Issues

List issues in a project
Expand Down
5 changes: 4 additions & 1 deletion src/commands/log/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {},
},
});
238 changes: 238 additions & 0 deletions src/commands/log/view.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,238 @@
/**
* 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 <org>/<project> <log-id>";

/**
* Parse positional arguments for log view.
* Handles: `<log-id>` or `<target> <log-id>`
*
* @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;
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) {
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.
* @internal Exported for testing
*/
export 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
*
* @internal Exported for testing
*/
export async function resolveFromProjectSearch(
projectSlug: string,
logId: string
): Promise<ResolvedLogTarget> {
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 <org>/${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.
*
* @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,
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 <log-id> # auto-detect from DSN or config\n" +
" sentry log view <org>/<proj> <log-id> # explicit org and project\n" +
" sentry log view <project> <log-id> # 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:
"[<org>/<project>] <log-id> - 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<void> {
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, target.project, logId);

if (!log) {
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."
);
}

if (flags.json) {
writeJson(stdout, log);
return;
}

writeHumanOutput(stdout, log, target.org, target.detectedFrom);
},
});
58 changes: 58 additions & 0 deletions src/lib/api-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -1071,3 +1074,58 @@ 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 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<DetailedSentryLog | null> {
const query = `project:${projectSlug} sentry.item_id:${logId}`;

const response = await orgScopedRequest<DetailedLogsResponse>(
`/organizations/${orgSlug}/events/`,
{
params: {
dataset: "logs",
field: DETAILED_LOG_FIELDS,
query,
per_page: 1,
statsPeriod: "90d",
},
schema: DetailedLogsResponseSchema,
}
);

return response.data[0] ?? null;
}
Loading
Loading