Skip to content

Commit ee0613a

Browse files
betegonclaude
andcommitted
feat(log): auto-show all custom attributes in log view
Replace the --fields-only approach with the trace-items detail endpoint (/projects/{org}/{project}/trace-items/{itemId}/?item_type=logs) which returns every attribute on the log without requiring field enumeration — the same call the Sentry UI makes when expanding a log row. After getLogs fetches standard fields (including trace), getLogItemDetail is called in parallel for each log that has a trace ID. formatLogDetails renders all non-standard attributes in a Custom Attributes section. --fields acts as a filter on that section rather than an adder. The endpoint is EXPERIMENTAL and not yet in @sentry/api (getsentry/ sentry-api-schema), so it uses apiRequestToRegion directly, matching the listTraceLogs pattern. Types mirror TraceItemResponseAttribute: https://github.com/getsentry/sentry/blob/8a4f150b21b/static/app/views/explore/hooks/useTraceItemDetails.tsx#L85-L89 Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
1 parent 121fc10 commit ee0613a

7 files changed

Lines changed: 177 additions & 9 deletions

File tree

src/commands/log/view.ts

Lines changed: 32 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
import { isatty } from "node:tty";
88

99
import type { SentryContext } from "../../context.js";
10-
import { getLogs } from "../../lib/api-client.js";
10+
import { getLogItemDetail, getLogs } from "../../lib/api-client.js";
1111
import {
1212
detectSwappedViewArgs,
1313
looksLikeIssueShortId,
@@ -44,7 +44,7 @@ import { RETENTION_DAYS } from "../../lib/retention.js";
4444
import { buildLogsUrl } from "../../lib/sentry-urls.js";
4545
import { setOrgProjectContext } from "../../lib/telemetry.js";
4646
import { isAllDigits } from "../../lib/utils.js";
47-
import type { DetailedSentryLog } from "../../types/index.js";
47+
import type { DetailedSentryLog, TraceItemDetail } from "../../types/index.js";
4848

4949
const log = logger.withTag("log-view");
5050

@@ -399,7 +399,9 @@ type LogViewData = {
399399
logs: DetailedSentryLog[];
400400
/** Org slug — needed by human formatter for trace URLs, also useful context in JSON */
401401
orgSlug: string;
402-
/** Custom fields requested via --fields, passed to the detail formatter */
402+
/** Full attribute sets from the trace-items detail endpoint (index matches logs) */
403+
details?: (TraceItemDetail | undefined)[];
404+
/** --fields filter: limits which custom attributes are shown in human output */
403405
extraFields?: string[];
404406
};
405407

@@ -414,11 +416,19 @@ type LogViewData = {
414416
*/
415417
function formatLogViewHuman(data: LogViewData): string {
416418
const parts: string[] = [];
417-
for (const entry of data.logs) {
419+
for (let i = 0; i < data.logs.length; i++) {
418420
if (parts.length > 0) {
419421
parts.push("\n---\n");
420422
}
421-
parts.push(formatLogDetails(entry, data.orgSlug, data.extraFields));
423+
parts.push(
424+
formatLogDetails(
425+
// biome-ignore lint/style/noNonNullAssertion: index is bounded by data.logs.length
426+
data.logs[i]!,
427+
data.orgSlug,
428+
data.details?.[i]?.attributes,
429+
data.extraFields
430+
)
431+
);
422432
}
423433
return parts.join("\n");
424434
}
@@ -511,13 +521,30 @@ export const viewCommand = buildCommand({
511521

512522
warnMissingIds(logIds, logs);
513523

524+
// Fetch full attribute sets in parallel for logs that have a trace ID.
525+
// Graceful degradation: if the endpoint is unavailable the detail is undefined
526+
// and the formatter falls back to showing only the standard fields + --fields.
527+
const details = await Promise.all(
528+
logs.map((entry) =>
529+
entry.trace
530+
? getLogItemDetail(
531+
target.org,
532+
target.project,
533+
entry["sentry.item_id"],
534+
entry.trace
535+
).catch(() => {})
536+
: Promise.resolve(undefined)
537+
)
538+
);
539+
514540
const hint = target.detectedFrom
515541
? `Detected from ${target.detectedFrom}`
516542
: undefined;
517543

518544
yield new CommandOutput({
519545
logs,
520546
orgSlug: target.org,
547+
details,
521548
extraFields: flags.fields,
522549
});
523550
return { hint };

src/lib/api-client.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@ export {
7474
updateIssueStatus,
7575
} from "./api/issues.js";
7676
export {
77+
getLogItemDetail,
7778
getLogs,
7879
type LogSortDirection,
7980
listLogs,

src/lib/api/logs.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ import {
1212
type DetailedSentryLog,
1313
LogsResponseSchema,
1414
type SentryLog,
15+
type TraceItemDetail,
16+
TraceItemDetailSchema,
1517
type TraceLog,
1618
TraceLogsResponseSchema,
1719
} from "../../types/index.js";
@@ -296,3 +298,36 @@ export async function listTraceLogs(
296298

297299
return response.data;
298300
}
301+
302+
/**
303+
* Fetch all attributes for a single log entry via the trace-items detail endpoint.
304+
*
305+
* Returns every attribute on the log — standard and custom alike — without needing
306+
* to enumerate field names. This is the same endpoint the Sentry UI uses when
307+
* expanding a log row to show its full attribute set.
308+
*
309+
* The endpoint is EXPERIMENTAL and not yet in @sentry/api; called directly via
310+
* apiRequestToRegion following the same pattern as listTraceLogs.
311+
*
312+
* @param orgSlug - Organization slug
313+
* @param projectSlug - Project slug
314+
* @param logId - The sentry.item_id of the log entry
315+
* @param traceId - The trace ID (required by the endpoint)
316+
*/
317+
export async function getLogItemDetail(
318+
orgSlug: string,
319+
projectSlug: string,
320+
logId: string,
321+
traceId: string
322+
): Promise<TraceItemDetail> {
323+
const regionUrl = await resolveOrgRegion(orgSlug);
324+
const { data } = await apiRequestToRegion<TraceItemDetail>(
325+
regionUrl,
326+
`/projects/${orgSlug}/${projectSlug}/trace-items/${logId}/`,
327+
{
328+
params: { item_type: "logs", trace_id: traceId },
329+
schema: TraceItemDetailSchema,
330+
}
331+
);
332+
return data;
333+
}

src/lib/formatters/log.ts

Lines changed: 71 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,11 @@
44
* Provides formatting utilities for displaying Sentry logs in the CLI.
55
*/
66

7-
import type { DetailedSentryLog, SentryLog } from "../../types/index.js";
7+
import type {
8+
DetailedSentryLog,
9+
SentryLog,
10+
TraceItemAttribute,
11+
} from "../../types/index.js";
812
import { buildTraceUrl } from "../sentry-urls.js";
913
import {
1014
colorTag,
@@ -281,19 +285,64 @@ function formatSeverityLabel(severity: string | null | undefined): string {
281285
return tag ? colorTag(tag, label) : label;
282286
}
283287

288+
/**
289+
* Attribute names rendered by the fixed sections in formatLogDetails.
290+
* Used to deduplicate against the trace-items detail response so we don't show
291+
* attributes in Custom Attributes that are already displayed above.
292+
* Also includes internal/noisy fields mirroring Sentry UI's HiddenLogDetailFields.
293+
*/
294+
const SHOWN_IN_STANDARD_SECTIONS = new Set([
295+
// Core section
296+
"sentry.item_id",
297+
"id",
298+
"timestamp",
299+
"timestamp_precise",
300+
"message",
301+
"severity",
302+
// Context section
303+
"trace",
304+
"project",
305+
"environment",
306+
"release",
307+
// SDK section
308+
"sdk.name",
309+
"sdk.version",
310+
// Trace section
311+
"span_id",
312+
// Source location section
313+
"code.function",
314+
"code.file.path",
315+
"code.line.number",
316+
// OTel section
317+
"sentry.otel.kind",
318+
"sentry.otel.status_code",
319+
"sentry.otel.instrumentation_scope.name",
320+
// Internal / always-hidden noise (mirrors Sentry UI HiddenLogDetailFields)
321+
"severity_number",
322+
"item_type",
323+
"organization_id",
324+
"project.id",
325+
"project_id",
326+
"sentry.timestamp_nanos",
327+
"sentry.observed_timestamp_nanos",
328+
"tags[sentry.trace_flags,number]",
329+
]);
330+
284331
/**
285332
* Format detailed log entry for display as rendered markdown.
286333
* Shows all available fields in a structured format.
287334
*
288335
* @param log - The detailed log entry to format
289336
* @param orgSlug - Organization slug for building trace URLs
290-
* @param extraFields - Custom fields requested via --fields, shown in Custom Attributes section
337+
* @param allAttributes - All attributes from the trace-items detail endpoint (shows custom attrs)
338+
* @param extraFields - Optional --fields filter: limits which custom attributes are shown
291339
* @returns Rendered terminal string
292340
*/
293341
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: log detail formatting requires multiple conditional sections
294342
export function formatLogDetails(
295343
log: DetailedSentryLog,
296344
orgSlug: string,
345+
allAttributes?: TraceItemAttribute[],
297346
extraFields?: string[]
298347
): string {
299348
const logId = log["sentry.item_id"];
@@ -396,8 +445,26 @@ export function formatLogDetails(
396445
lines.push(mdKvTable(otelRows, "OpenTelemetry"));
397446
}
398447

399-
// Custom Attributes — fields explicitly requested via --fields
400-
if (extraFields?.length) {
448+
// Custom Attributes — from trace-items detail endpoint (all non-standard attributes)
449+
if (allAttributes?.length) {
450+
let customAttrs = allAttributes.filter(
451+
(a) => !SHOWN_IN_STANDARD_SECTIONS.has(a.name)
452+
);
453+
if (extraFields?.length) {
454+
const wanted = new Set(extraFields);
455+
customAttrs = customAttrs.filter((a) => wanted.has(a.name));
456+
}
457+
if (customAttrs.length > 0) {
458+
lines.push("");
459+
lines.push(
460+
mdKvTable(
461+
customAttrs.map((a) => [a.name, String(a.value)]),
462+
"Custom Attributes"
463+
)
464+
);
465+
}
466+
} else if (extraFields?.length) {
467+
// Fallback: no trace-items detail available, show only explicitly requested fields
401468
const customRows: [string, string][] = extraFields
402469
.filter((f) => log[f] !== null && log[f] !== undefined)
403470
.map((f) => [f, String(log[f])]);

src/types/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,8 @@ export type {
138138
StackFrame,
139139
Stacktrace,
140140
TraceContext,
141+
TraceItemAttribute,
142+
TraceItemDetail,
141143
TraceLog,
142144
TraceLogsResponse,
143145
TraceMeta,
@@ -164,6 +166,8 @@ export {
164166
SentryUserSchema,
165167
SpanListItemSchema,
166168
SpansResponseSchema,
169+
TraceItemAttributeSchema,
170+
TraceItemDetailSchema,
167171
TraceLogSchema,
168172
TraceLogsResponseSchema,
169173
TraceMetaSchema,

src/types/sentry.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -840,6 +840,32 @@ export const DetailedLogsResponseSchema = z.object({
840840

841841
export type DetailedLogsResponse = z.infer<typeof DetailedLogsResponseSchema>;
842842

843+
// Trace-item detail types (from /projects/{org}/{project}/trace-items/{itemId}/ endpoint)
844+
845+
/**
846+
* A single attribute on a trace item (log, span, etc.).
847+
*
848+
* Mirrors Sentry's TraceItemResponseAttribute:
849+
* https://github.com/getsentry/sentry/blob/8a4f150b21b/static/app/views/explore/hooks/useTraceItemDetails.tsx#L85-L89
850+
*
851+
* The endpoint is EXPERIMENTAL and not yet in @sentry/api (getsentry/sentry-api-schema).
852+
*/
853+
export const TraceItemAttributeSchema = z.discriminatedUnion("type", [
854+
z.object({ name: z.string(), type: z.literal("str"), value: z.string() }),
855+
z.object({ name: z.string(), type: z.literal("int"), value: z.number() }),
856+
z.object({ name: z.string(), type: z.literal("float"), value: z.number() }),
857+
z.object({ name: z.string(), type: z.literal("bool"), value: z.boolean() }),
858+
]);
859+
export type TraceItemAttribute = z.infer<typeof TraceItemAttributeSchema>;
860+
861+
/** Response from GET /projects/{org}/{project}/trace-items/{itemId}/?item_type=logs */
862+
export const TraceItemDetailSchema = z.object({
863+
itemId: z.string(),
864+
timestamp: z.string(),
865+
attributes: z.array(TraceItemAttributeSchema),
866+
});
867+
export type TraceItemDetail = z.infer<typeof TraceItemDetailSchema>;
868+
843869
// Trace-log types (from /organizations/{org}/trace-logs/ endpoint)
844870

845871
/**

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

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,19 +66,27 @@ function createMockContext() {
6666

6767
describe("viewCommand.func", () => {
6868
let getLogsSpy: ReturnType<typeof spyOn>;
69+
let getLogItemDetailSpy: ReturnType<typeof spyOn>;
6970
let resolveOrgAndProjectSpy: ReturnType<typeof spyOn>;
7071
let resolveProjectBySlugSpy: ReturnType<typeof spyOn>;
7172
let openInBrowserSpy: ReturnType<typeof spyOn>;
7273

7374
beforeEach(() => {
7475
getLogsSpy = spyOn(apiClient, "getLogs");
76+
getLogItemDetailSpy = spyOn(apiClient, "getLogItemDetail");
77+
getLogItemDetailSpy.mockResolvedValue({
78+
itemId: "",
79+
timestamp: "",
80+
attributes: [],
81+
});
7582
resolveOrgAndProjectSpy = spyOn(resolveTarget, "resolveOrgAndProject");
7683
resolveProjectBySlugSpy = spyOn(resolveTarget, "resolveProjectBySlug");
7784
openInBrowserSpy = spyOn(browser, "openInBrowser");
7885
});
7986

8087
afterEach(() => {
8188
getLogsSpy.mockRestore();
89+
getLogItemDetailSpy.mockRestore();
8290
resolveOrgAndProjectSpy.mockRestore();
8391
resolveProjectBySlugSpy.mockRestore();
8492
openInBrowserSpy.mockRestore();

0 commit comments

Comments
 (0)