Skip to content

Commit eac96ec

Browse files
committed
fix(log-view): address PR #362 review comments
- Normalize hex IDs to lowercase in validateHexId for consistent API comparison - Remove dead getLog convenience wrapper (unused) - Simplify getLogs to always use bracket syntax for ID filter - Add per_page comment explaining why it's needed - Open all IDs in browser with --web (warn about multiple tabs) - Use markdown list format for error messages (- `<id>`) - Use consola logger for missing IDs warning instead of stderr.write - Use logIds.join(' ') in usage hints - Extract resolveTarget, warnMissingIds, formatIdList helpers for complexity - Update trace-id tests for lowercase normalization
1 parent f43222e commit eac96ec

File tree

7 files changed

+173
-157
lines changed

7 files changed

+173
-157
lines changed

AGENTS.md

Lines changed: 40 additions & 43 deletions
Large diffs are not rendered by default.

src/commands/log/view.ts

Lines changed: 92 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -15,13 +15,16 @@ import { buildCommand } from "../../lib/command.js";
1515
import { ContextError, ValidationError } from "../../lib/errors.js";
1616
import { formatLogDetails, writeJson } from "../../lib/formatters/index.js";
1717
import { validateHexId } from "../../lib/hex-id.js";
18+
import { logger } from "../../lib/logger.js";
1819
import {
1920
resolveOrgAndProject,
2021
resolveProjectBySlug,
2122
} from "../../lib/resolve-target.js";
2223
import { buildLogsUrl } from "../../lib/sentry-urls.js";
2324
import type { DetailedSentryLog, Writer } from "../../types/index.js";
2425

26+
const log = logger.withTag("log-view");
27+
2528
type ViewFlags = {
2629
readonly json: boolean;
2730
readonly web: boolean;
@@ -108,6 +111,79 @@ export type ResolvedLogTarget = {
108111
detectedFrom?: string;
109112
};
110113

114+
/**
115+
* Resolve the target org/project from the parsed arg.
116+
*
117+
* @param parsed - Result of `parseOrgProjectArg`
118+
* @param logIds - Parsed log IDs (used for usage hints)
119+
* @param cwd - Current working directory
120+
* @param stderr - Stderr stream for diagnostics
121+
* @returns Resolved target, or null if resolution produced nothing
122+
* @throws {ContextError} If org-all mode is used (requires specific project)
123+
*/
124+
function resolveTarget(
125+
parsed: ReturnType<typeof parseOrgProjectArg>,
126+
logIds: string[],
127+
cwd: string,
128+
stderr: Writer
129+
): Promise<ResolvedLogTarget | null> | ResolvedLogTarget {
130+
switch (parsed.type) {
131+
case "explicit":
132+
return { org: parsed.org, project: parsed.project };
133+
134+
case "project-search":
135+
return resolveProjectBySlug(
136+
parsed.projectSlug,
137+
USAGE_HINT,
138+
`sentry log view <org>/${parsed.projectSlug} ${logIds.join(" ")}`,
139+
stderr
140+
);
141+
142+
case "org-all":
143+
throw new ContextError("Specific project", USAGE_HINT);
144+
145+
case "auto-detect":
146+
return resolveOrgAndProject({ cwd, usageHint: USAGE_HINT });
147+
148+
default: {
149+
const _exhaustiveCheck: never = parsed;
150+
throw new ValidationError(
151+
`Invalid target specification: ${_exhaustiveCheck}`
152+
);
153+
}
154+
}
155+
}
156+
157+
/**
158+
* Format a list of log IDs as a markdown bullet list.
159+
*
160+
* @param ids - Log IDs to format
161+
* @returns Markdown list string with each ID on its own line
162+
*/
163+
function formatIdList(ids: string[]): string {
164+
return ids.map((id) => ` - \`${id}\``).join("\n");
165+
}
166+
167+
/**
168+
* Warn about IDs that weren't found in the API response.
169+
* Uses the consola logger for structured output to stderr.
170+
*
171+
* @param logIds - All requested IDs
172+
* @param logs - Logs actually returned by the API
173+
*/
174+
function warnMissingIds(logIds: string[], logs: DetailedSentryLog[]): void {
175+
if (logs.length >= logIds.length) {
176+
return;
177+
}
178+
const foundIds = new Set(logs.map((l) => l["sentry.item_id"]));
179+
const missing = logIds.filter((id) => !foundIds.has(id));
180+
if (missing.length > 0) {
181+
log.warn(
182+
`${missing.length} of ${logIds.length} log(s) not found:\n${formatIdList(missing)}`
183+
);
184+
}
185+
}
186+
111187
/**
112188
* Write human-readable output for one or more logs to stdout.
113189
*
@@ -123,11 +199,11 @@ function writeHumanOutput(
123199
detectedFrom?: string
124200
): void {
125201
let first = true;
126-
for (const log of logs) {
202+
for (const entry of logs) {
127203
if (!first) {
128204
stdout.write("\n---\n\n");
129205
}
130-
stdout.write(`${formatLogDetails(log, orgSlug)}\n`);
206+
stdout.write(`${formatLogDetails(entry, orgSlug)}\n`);
131207
first = false;
132208
}
133209

@@ -184,40 +260,7 @@ export const viewCommand = buildCommand({
184260
const { logIds, targetArg } = parsePositionalArgs(args);
185261
const parsed = parseOrgProjectArg(targetArg);
186262

187-
let target: ResolvedLogTarget | null = null;
188-
189-
switch (parsed.type) {
190-
case "explicit":
191-
target = {
192-
org: parsed.org,
193-
project: parsed.project,
194-
};
195-
break;
196-
197-
case "project-search":
198-
target = await resolveProjectBySlug(
199-
parsed.projectSlug,
200-
USAGE_HINT,
201-
`sentry log view <org>/${parsed.projectSlug} ${logIds[0]}`,
202-
this.stderr
203-
);
204-
break;
205-
206-
case "org-all":
207-
throw new ContextError("Specific project", USAGE_HINT);
208-
209-
case "auto-detect":
210-
target = await resolveOrgAndProject({ cwd, usageHint: USAGE_HINT });
211-
break;
212-
213-
default: {
214-
// Exhaustive check - should never reach here
215-
const _exhaustiveCheck: never = parsed;
216-
throw new ValidationError(
217-
`Invalid target specification: ${_exhaustiveCheck}`
218-
);
219-
}
220-
}
263+
const target = await resolveTarget(parsed, logIds, cwd, this.stderr);
221264

222265
if (!target) {
223266
throw new ContextError("Organization and project", USAGE_HINT);
@@ -227,36 +270,30 @@ export const viewCommand = buildCommand({
227270
setContext([target.org], [target.project]);
228271

229272
if (flags.web) {
230-
// --web only opens the first log (browser can only open one meaningfully)
231-
await openInBrowser(stdout, buildLogsUrl(target.org, logIds[0]), "log");
273+
if (logIds.length > 1) {
274+
log.warn(`Opening ${logIds.length} browser tabs…`);
275+
}
276+
for (const id of logIds) {
277+
await openInBrowser(stdout, buildLogsUrl(target.org, id), "log");
278+
}
232279
return;
233280
}
234281

235282
// Fetch all requested log entries
236283
const logs = await getLogs(target.org, target.project, logIds);
237284

238285
if (logs.length === 0) {
239-
const idDisplay =
240-
logIds.length === 1
241-
? `ID "${logIds[0]}"`
242-
: `IDs ${logIds.map((id) => `"${id}"`).join(", ")}`;
286+
const idList = formatIdList(logIds);
243287
throw new ValidationError(
244-
`No logs found with ${idDisplay} in ${target.org}/${target.project}.\n\n` +
245-
"Make sure the log IDs are correct and the logs were sent within the last 90 days."
288+
logIds.length === 1
289+
? `No log found with ID "${logIds[0]}" in ${target.org}/${target.project}.\n\n` +
290+
"Make sure the log ID is correct and the log was sent within the last 90 days."
291+
: `No logs found with any of the following IDs in ${target.org}/${target.project}:\n${idList}\n\n` +
292+
"Make sure the log IDs are correct and the logs were sent within the last 90 days."
246293
);
247294
}
248295

249-
// Warn about any IDs that weren't found
250-
if (logs.length < logIds.length) {
251-
const foundIds = new Set(logs.map((l) => l["sentry.item_id"]));
252-
const missing = logIds.filter((id) => !foundIds.has(id));
253-
if (missing.length > 0) {
254-
const stderr = this.stderr;
255-
stderr.write(
256-
`Warning: ${missing.length} of ${logIds.length} log(s) not found: ${missing.join(", ")}\n`
257-
);
258-
}
259-
}
296+
warnMissingIds(logIds, logs);
260297

261298
if (flags.json) {
262299
// Single ID: output single object for backward compatibility

src/lib/api-client.ts

Lines changed: 3 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1780,7 +1780,7 @@ const DETAILED_LOG_FIELDS = [
17801780
/**
17811781
* Get one or more log entries by their item IDs.
17821782
* Uses the Explore/Events API with dataset=logs and a filter query.
1783-
* For multiple IDs, uses bracket syntax: `sentry.item_id:[id1,id2,...]`.
1783+
* Bracket syntax (`sentry.item_id:[id1,id2,...]`) works for any count including one.
17841784
*
17851785
* @param orgSlug - Organization slug
17861786
* @param projectSlug - Project slug for filtering
@@ -1792,11 +1792,7 @@ export async function getLogs(
17921792
projectSlug: string,
17931793
logIds: string[]
17941794
): Promise<DetailedSentryLog[]> {
1795-
const idFilter =
1796-
logIds.length === 1
1797-
? `sentry.item_id:${logIds[0]}`
1798-
: `sentry.item_id:[${logIds.join(",")}]`;
1799-
const query = `project:${projectSlug} ${idFilter}`;
1795+
const query = `project:${projectSlug} sentry.item_id:[${logIds.join(",")}]`;
18001796
const config = await getOrgSdkConfig(orgSlug);
18011797

18021798
const result = await queryExploreEventsInTableFormat({
@@ -1806,6 +1802,7 @@ export async function getLogs(
18061802
dataset: "logs",
18071803
field: DETAILED_LOG_FIELDS,
18081804
query,
1805+
// per_page must match the number of IDs so the API returns all matches
18091806
per_page: logIds.length,
18101807
statsPeriod: "90d",
18111808
},
@@ -1816,24 +1813,6 @@ export async function getLogs(
18161813
return logsResponse.data;
18171814
}
18181815

1819-
/**
1820-
* Get a single log entry by its item ID.
1821-
* Convenience wrapper around {@link getLogs} for single-ID lookups.
1822-
*
1823-
* @param orgSlug - Organization slug
1824-
* @param projectSlug - Project slug for filtering
1825-
* @param logId - The sentry.item_id of the log entry
1826-
* @returns The detailed log entry, or null if not found
1827-
*/
1828-
export async function getLog(
1829-
orgSlug: string,
1830-
projectSlug: string,
1831-
logId: string
1832-
): Promise<DetailedSentryLog | null> {
1833-
const logs = await getLogs(orgSlug, projectSlug, [logId]);
1834-
return logs[0] ?? null;
1835-
}
1836-
18371816
// Trace-log functions
18381817

18391818
type ListTraceLogsOptions = {

src/lib/hex-id.ts

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,18 +15,21 @@ const MAX_DISPLAY_LENGTH = 40;
1515

1616
/**
1717
* Validate that a string is a 32-character hexadecimal ID.
18-
* Trims whitespace before validation.
18+
* Trims whitespace and normalizes to lowercase before validation.
1919
*
20-
* Returns the trimmed, validated ID so it can be used as a Stricli `parse`
21-
* function directly.
20+
* Normalization to lowercase ensures consistent comparison with API responses,
21+
* which return lowercase hex IDs regardless of input casing.
22+
*
23+
* Returns the trimmed, lowercased, validated ID so it can be used as a Stricli
24+
* `parse` function directly.
2225
*
2326
* @param value - The string to validate
2427
* @param label - Human-readable name for error messages (e.g., "log ID", "trace ID")
25-
* @returns The trimmed, validated ID
28+
* @returns The trimmed, lowercased, validated ID
2629
* @throws {ValidationError} If the format is invalid
2730
*/
2831
export function validateHexId(value: string, label: string): string {
29-
const trimmed = value.trim();
32+
const trimmed = value.trim().toLowerCase();
3033

3134
if (!HEX_ID_RE.test(trimmed)) {
3235
const display =

test/commands/log/view.func.test.ts

Lines changed: 17 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -54,18 +54,16 @@ function makeSampleLog(id: string, message = "Test log"): DetailedSentryLog {
5454

5555
function createMockContext() {
5656
const stdoutWrite = mock(() => true);
57-
const stderrWrite = mock(() => true);
5857
return {
5958
context: {
6059
stdout: { write: stdoutWrite },
61-
stderr: { write: stderrWrite },
60+
stderr: { write: mock(() => true) },
6261
cwd: "/tmp",
6362
setContext: mock(() => {
6463
// no-op for test
6564
}),
6665
},
6766
stdoutWrite,
68-
stderrWrite,
6967
};
7068
}
7169

@@ -134,7 +132,7 @@ describe("viewCommand.func", () => {
134132
} catch (error) {
135133
expect(error).toBeInstanceOf(ValidationError);
136134
expect((error as ValidationError).message).toContain(ID1);
137-
expect((error as ValidationError).message).toContain("No logs found");
135+
expect((error as ValidationError).message).toContain("No log found");
138136
}
139137
});
140138
});
@@ -204,11 +202,11 @@ describe("viewCommand.func", () => {
204202
expect(parsed).toHaveLength(2);
205203
});
206204

207-
test("warns to stderr when some IDs are not found", async () => {
208-
// Only ID1 found, ID2 and ID3 missing
205+
test("still outputs found logs when some IDs are missing", async () => {
206+
// Only ID1 found, ID2 and ID3 missing — warning goes through consola
209207
getLogsSpy.mockResolvedValue([makeSampleLog(ID1)]);
210208

211-
const { context, stderrWrite, stdoutWrite } = createMockContext();
209+
const { context, stdoutWrite } = createMockContext();
212210
const func = await viewCommand.loader();
213211
await func.call(
214212
context,
@@ -219,17 +217,13 @@ describe("viewCommand.func", () => {
219217
ID3
220218
);
221219

222-
const stderrOutput = stderrWrite.mock.calls.map((c) => c[0]).join("");
223-
expect(stderrOutput).toContain("Warning");
224-
expect(stderrOutput).toContain("2 of 3");
225-
expect(stderrOutput).toContain(ID2);
226-
expect(stderrOutput).toContain(ID3);
227-
228-
// Should still output the found log
220+
// Should still output the found log as JSON
229221
const stdoutOutput = stdoutWrite.mock.calls.map((c) => c[0]).join("");
230222
const parsed = JSON.parse(stdoutOutput);
231223
// Multiple IDs requested → array output even if only one found
232224
expect(Array.isArray(parsed)).toBe(true);
225+
expect(parsed).toHaveLength(1);
226+
expect(parsed[0]["sentry.item_id"]).toBe(ID1);
233227
});
234228

235229
test("throws ValidationError when no logs found for multiple IDs", async () => {
@@ -251,8 +245,9 @@ describe("viewCommand.func", () => {
251245
expect(error).toBeInstanceOf(ValidationError);
252246
const msg = (error as ValidationError).message;
253247
expect(msg).toContain("No logs found");
254-
expect(msg).toContain(ID1);
255-
expect(msg).toContain(ID2);
248+
// Each ID should appear in a markdown list item
249+
expect(msg).toContain(` - \`${ID1}\``);
250+
expect(msg).toContain(` - \`${ID2}\``);
256251
}
257252
});
258253
});
@@ -272,7 +267,7 @@ describe("viewCommand.func", () => {
272267
expect(getLogsSpy).not.toHaveBeenCalled();
273268
});
274269

275-
test("opens browser for first log when multiple IDs given", async () => {
270+
test("opens browser for all IDs when multiple given", async () => {
276271
openInBrowserSpy.mockResolvedValue(undefined);
277272

278273
const { context } = createMockContext();
@@ -285,9 +280,11 @@ describe("viewCommand.func", () => {
285280
ID2
286281
);
287282

288-
expect(openInBrowserSpy).toHaveBeenCalled();
289-
const url = openInBrowserSpy.mock.calls[0][1] as string;
290-
expect(url).toContain(ID1);
283+
expect(openInBrowserSpy).toHaveBeenCalledTimes(2);
284+
const url1 = openInBrowserSpy.mock.calls[0][1] as string;
285+
const url2 = openInBrowserSpy.mock.calls[1][1] as string;
286+
expect(url1).toContain(ID1);
287+
expect(url2).toContain(ID2);
291288
});
292289
});
293290

0 commit comments

Comments
 (0)