Skip to content

Commit 8c98a84

Browse files
authored
feat(log): add view command to display log entry details (#212)
## Summary Adds `sentry log view <log-id>` command to display detailed information about a single Sentry log entry. Complements the existing `sentry log list` command. ## Features - **Auto-detection**: Works with DSN or config defaults when org/project is omitted - **Flexible targeting**: `sentry log view <org>/<project> <log-id>` or `sentry log view <project> <log-id>` - **JSON output**: `--json` flag for machine-readable output - **Browser**: `--web` flag opens the log in Sentry's Logs explorer - **Trace integration**: Shows clickable trace URL when trace ID is present ## Output Human-readable output includes: - Core info: ID, timestamp, severity, message - Context: project, environment, release - SDK: name, version - Trace: trace ID, span ID, link to trace view - Source location: function, file, line (when available) - OpenTelemetry data (when available) ## Test Plan ```bash # Get a log ID from list sentry log list <org>/<project> --limit 1 --json # View the log sentry log view <log-id> sentry log view <org>/<project> <log-id> sentry log view <log-id> --json sentry log view <log-id> --web ```
1 parent fe8631b commit 8c98a84

16 files changed

Lines changed: 1506 additions & 4 deletions

File tree

plugins/sentry-cli/skills/sentry-cli/SKILL.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -463,6 +463,14 @@ sentry log list my-org/backend -f -q 'level:error'
463463
sentry log list --json | jq '.[] | select(.level == "error")'
464464
```
465465

466+
#### `sentry log view <args...>`
467+
468+
View details of a specific log entry
469+
470+
**Flags:**
471+
- `--json - Output as JSON`
472+
- `-w, --web - Open in browser`
473+
466474
### Issues
467475

468476
List issues in a project

src/commands/log/index.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,17 +6,20 @@
66

77
import { buildRouteMap } from "@stricli/core";
88
import { listCommand } from "./list.js";
9+
import { viewCommand } from "./view.js";
910

1011
export const logRoute = buildRouteMap({
1112
routes: {
1213
list: listCommand,
14+
view: viewCommand,
1315
},
1416
docs: {
1517
brief: "View Sentry logs",
1618
fullDescription:
1719
"View and stream logs from your Sentry projects.\n\n" +
1820
"Commands:\n" +
19-
" list List or stream logs from a project",
21+
" list List or stream logs from a project\n" +
22+
" view View details of a specific log entry",
2023
hideRoute: {},
2124
},
2225
});

src/commands/log/view.ts

Lines changed: 238 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,238 @@
1+
/**
2+
* sentry log view
3+
*
4+
* View detailed information about a Sentry log entry.
5+
*/
6+
7+
import { buildCommand } from "@stricli/core";
8+
import type { SentryContext } from "../../context.js";
9+
import { findProjectsBySlug, getLog } from "../../lib/api-client.js";
10+
import { parseOrgProjectArg } from "../../lib/arg-parsing.js";
11+
import { openInBrowser } from "../../lib/browser.js";
12+
import { ContextError, ValidationError } from "../../lib/errors.js";
13+
import { formatLogDetails, writeJson } from "../../lib/formatters/index.js";
14+
import { resolveOrgAndProject } from "../../lib/resolve-target.js";
15+
import { buildLogsUrl } from "../../lib/sentry-urls.js";
16+
import type { DetailedSentryLog, Writer } from "../../types/index.js";
17+
18+
type ViewFlags = {
19+
readonly json: boolean;
20+
readonly web: boolean;
21+
};
22+
23+
/** Usage hint for ContextError messages */
24+
const USAGE_HINT = "sentry log view <org>/<project> <log-id>";
25+
26+
/**
27+
* Parse positional arguments for log view.
28+
* Handles: `<log-id>` or `<target> <log-id>`
29+
*
30+
* @param args - Positional arguments from CLI
31+
* @returns Parsed log ID and optional target arg
32+
* @throws {ContextError} If no arguments provided
33+
*/
34+
export function parsePositionalArgs(args: string[]): {
35+
logId: string;
36+
targetArg: string | undefined;
37+
} {
38+
if (args.length === 0) {
39+
throw new ContextError("Log ID", USAGE_HINT);
40+
}
41+
42+
const first = args[0];
43+
if (first === undefined) {
44+
throw new ContextError("Log ID", USAGE_HINT);
45+
}
46+
47+
if (args.length === 1) {
48+
// Single arg - must be log ID
49+
return { logId: first, targetArg: undefined };
50+
}
51+
52+
const second = args[1];
53+
if (second === undefined) {
54+
return { logId: first, targetArg: undefined };
55+
}
56+
57+
// Two or more args - first is target, second is log ID
58+
return { logId: second, targetArg: first };
59+
}
60+
61+
/**
62+
* Resolved target type for log commands.
63+
* @internal Exported for testing
64+
*/
65+
export type ResolvedLogTarget = {
66+
org: string;
67+
project: string;
68+
detectedFrom?: string;
69+
};
70+
71+
/**
72+
* Resolve target from a project search result.
73+
*
74+
* Searches for a project by slug across all accessible organizations.
75+
* Throws if no project found or if multiple projects found in different orgs.
76+
*
77+
* @param projectSlug - Project slug to search for
78+
* @param logId - Log ID (used in error messages)
79+
* @returns Resolved target with org and project info
80+
* @throws {ContextError} If no project found
81+
* @throws {ValidationError} If project exists in multiple organizations
82+
*
83+
* @internal Exported for testing
84+
*/
85+
export async function resolveFromProjectSearch(
86+
projectSlug: string,
87+
logId: string
88+
): Promise<ResolvedLogTarget> {
89+
const found = await findProjectsBySlug(projectSlug);
90+
if (found.length === 0) {
91+
throw new ContextError(`Project "${projectSlug}"`, USAGE_HINT, [
92+
"Check that you have access to a project with this slug",
93+
]);
94+
}
95+
if (found.length > 1) {
96+
const orgList = found.map((p) => ` ${p.orgSlug}/${p.slug}`).join("\n");
97+
throw new ValidationError(
98+
`Project "${projectSlug}" exists in multiple organizations.\n\n` +
99+
`Specify the organization:\n${orgList}\n\n` +
100+
`Example: sentry log view <org>/${projectSlug} ${logId}`
101+
);
102+
}
103+
// Safe assertion: length is exactly 1 after the checks above
104+
const foundProject = found[0] as (typeof found)[0];
105+
return {
106+
org: foundProject.orgSlug,
107+
project: foundProject.slug,
108+
};
109+
}
110+
111+
/**
112+
* Write human-readable log output to stdout.
113+
*
114+
* @param stdout - Output stream
115+
* @param log - The log entry to display
116+
* @param orgSlug - Organization slug for trace URLs
117+
* @param detectedFrom - Optional context detection source to display
118+
*/
119+
function writeHumanOutput(
120+
stdout: Writer,
121+
log: DetailedSentryLog,
122+
orgSlug: string,
123+
detectedFrom?: string
124+
): void {
125+
const lines = formatLogDetails(log, orgSlug);
126+
stdout.write(`${lines.join("\n")}\n`);
127+
128+
if (detectedFrom) {
129+
stdout.write(`\nDetected from ${detectedFrom}\n`);
130+
}
131+
}
132+
133+
export const viewCommand = buildCommand({
134+
docs: {
135+
brief: "View details of a specific log entry",
136+
fullDescription:
137+
"View detailed information about a Sentry log entry by its ID.\n\n" +
138+
"Target specification:\n" +
139+
" sentry log view <log-id> # auto-detect from DSN or config\n" +
140+
" sentry log view <org>/<proj> <log-id> # explicit org and project\n" +
141+
" sentry log view <project> <log-id> # find project across all orgs\n\n" +
142+
"The log ID is the 32-character hexadecimal identifier shown in log listings.",
143+
},
144+
parameters: {
145+
positional: {
146+
kind: "array",
147+
parameter: {
148+
placeholder: "args",
149+
brief:
150+
"[<org>/<project>] <log-id> - Target (optional) and log ID (required)",
151+
parse: String,
152+
},
153+
},
154+
flags: {
155+
json: {
156+
kind: "boolean",
157+
brief: "Output as JSON",
158+
default: false,
159+
},
160+
web: {
161+
kind: "boolean",
162+
brief: "Open in browser",
163+
default: false,
164+
},
165+
},
166+
aliases: { w: "web" },
167+
},
168+
async func(
169+
this: SentryContext,
170+
flags: ViewFlags,
171+
...args: string[]
172+
): Promise<void> {
173+
const { stdout, cwd, setContext } = this;
174+
175+
// Parse positional args
176+
const { logId, targetArg } = parsePositionalArgs(args);
177+
const parsed = parseOrgProjectArg(targetArg);
178+
179+
let target: ResolvedLogTarget | null = null;
180+
181+
switch (parsed.type) {
182+
case "explicit":
183+
target = {
184+
org: parsed.org,
185+
project: parsed.project,
186+
};
187+
break;
188+
189+
case "project-search":
190+
target = await resolveFromProjectSearch(parsed.projectSlug, logId);
191+
break;
192+
193+
case "org-all":
194+
throw new ContextError("Specific project", USAGE_HINT);
195+
196+
case "auto-detect":
197+
target = await resolveOrgAndProject({ cwd, usageHint: USAGE_HINT });
198+
break;
199+
200+
default: {
201+
// Exhaustive check - should never reach here
202+
const _exhaustiveCheck: never = parsed;
203+
throw new ValidationError(
204+
`Invalid target specification: ${_exhaustiveCheck}`
205+
);
206+
}
207+
}
208+
209+
if (!target) {
210+
throw new ContextError("Organization and project", USAGE_HINT);
211+
}
212+
213+
// Set telemetry context
214+
setContext([target.org], [target.project]);
215+
216+
if (flags.web) {
217+
await openInBrowser(stdout, buildLogsUrl(target.org, logId), "log");
218+
return;
219+
}
220+
221+
// Fetch the log entry
222+
const log = await getLog(target.org, target.project, logId);
223+
224+
if (!log) {
225+
throw new ValidationError(
226+
`No log found with ID "${logId}" in ${target.org}/${target.project}.\n\n` +
227+
"Make sure the log ID is correct and the log was sent within the last 90 days."
228+
);
229+
}
230+
231+
if (flags.json) {
232+
writeJson(stdout, log);
233+
return;
234+
}
235+
236+
writeHumanOutput(stdout, log, target.org, target.detectedFrom);
237+
},
238+
});

src/lib/api-client.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,9 @@
88
import kyHttpClient, { type KyInstance } from "ky";
99
import { z } from "zod";
1010
import {
11+
type DetailedLogsResponse,
12+
DetailedLogsResponseSchema,
13+
type DetailedSentryLog,
1114
type LogsResponse,
1215
LogsResponseSchema,
1316
type ProjectKey,
@@ -1071,3 +1074,58 @@ export async function listLogs(
10711074

10721075
return response.data;
10731076
}
1077+
1078+
/** All fields to request for detailed log view */
1079+
const DETAILED_LOG_FIELDS = [
1080+
"sentry.item_id",
1081+
"timestamp",
1082+
"timestamp_precise",
1083+
"message",
1084+
"severity",
1085+
"trace",
1086+
"project",
1087+
"environment",
1088+
"release",
1089+
"sdk.name",
1090+
"sdk.version",
1091+
"span_id",
1092+
"code.function",
1093+
"code.file.path",
1094+
"code.line.number",
1095+
"sentry.otel.kind",
1096+
"sentry.otel.status_code",
1097+
"sentry.otel.instrumentation_scope.name",
1098+
];
1099+
1100+
/**
1101+
* Get a single log entry by its item ID.
1102+
* Uses the Explore/Events API with dataset=logs and a filter query.
1103+
*
1104+
* @param orgSlug - Organization slug
1105+
* @param projectSlug - Project slug for filtering
1106+
* @param logId - The sentry.item_id of the log entry
1107+
* @returns The detailed log entry, or null if not found
1108+
*/
1109+
export async function getLog(
1110+
orgSlug: string,
1111+
projectSlug: string,
1112+
logId: string
1113+
): Promise<DetailedSentryLog | null> {
1114+
const query = `project:${projectSlug} sentry.item_id:${logId}`;
1115+
1116+
const response = await orgScopedRequest<DetailedLogsResponse>(
1117+
`/organizations/${orgSlug}/events/`,
1118+
{
1119+
params: {
1120+
dataset: "logs",
1121+
field: DETAILED_LOG_FIELDS,
1122+
query,
1123+
per_page: 1,
1124+
statsPeriod: "90d",
1125+
},
1126+
schema: DetailedLogsResponseSchema,
1127+
}
1128+
);
1129+
1130+
return response.data[0] ?? null;
1131+
}

0 commit comments

Comments
 (0)