Skip to content

Commit 7344933

Browse files
betegonclaude
andauthored
feat(log): show custom attributes in log view (#914)
`sentry log view` showed a fixed set of fields. Custom attributes users attach to their logs — visible in the Sentry UI — were never fetched or displayed. `sentry log list` already supported custom attributes via `--fields` before this work. No changes there. **How it works** The Sentry UI calls a trace-items detail endpoint when expanding a log row: ``` GET /projects/{org}/{project}/trace-items/{itemId}/?item_type=logs&trace_id={traceId} ``` It returns every attribute on the log without needing to enumerate field names. `log view` now does the same: after `getLogs` fetches standard fields (including `trace`), `getLogItemDetail` is called in parallel for each log. `formatLogDetails` renders all non-standard attributes in a **Custom Attributes** section automatically. `--fields` filters that section when you only want specific attributes. The endpoint is `EXPERIMENTAL` in Sentry and not yet in `@sentry/api` (generated from `getsentry/sentry-api-schema`), so it uses `apiRequestToRegion` directly — same pattern as `listTraceLogs`. Attribute types mirror `TraceItemResponseAttribute`: https://github.com/getsentry/sentry/blob/8a4f150b21b/static/app/views/explore/hooks/useTraceItemDetails.tsx#L85-L89 If the endpoint is unavailable (no trace ID on the log, or request fails), the formatter degrades gracefully and shows only standard fields. **Type consolidation** PR #623 defined `TraceItemAttribute` and `TraceItemDetail` as plain TS types in `traces.ts`. This PR moves them to `src/types/sentry.ts` with Zod schemas (`.passthrough()` so `meta`/`links` from the spans endpoint are preserved), exports them via the types barrel, and re-exports from `traces.ts` for existing callers. `getSpanDetails` also gets schema validation for parity with `getLogItemDetail`. **Usage** ```bash # Shows ALL custom attributes automatically — no config needed sentry log view myorg/myproject <id> # Limits the Custom Attributes section to specific fields sentry log view myorg/myproject <id> --fields order.id,user.tier # JSON output: unchanged sentry log view myorg/myproject <id> --json ``` --------- Co-authored-by: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
1 parent 9601526 commit 7344933

10 files changed

Lines changed: 567 additions & 55 deletions

File tree

src/commands/log/view.ts

Lines changed: 55 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,10 @@
66

77
import { isatty } from "node:tty";
88

9+
import pLimit from "p-limit";
10+
911
import type { SentryContext } from "../../context.js";
10-
import { getLogs } from "../../lib/api-client.js";
12+
import { getLogItemDetail, getLogs } from "../../lib/api-client.js";
1113
import {
1214
detectSwappedViewArgs,
1315
looksLikeIssueShortId,
@@ -44,10 +46,13 @@ import { RETENTION_DAYS } from "../../lib/retention.js";
4446
import { buildLogsUrl } from "../../lib/sentry-urls.js";
4547
import { setOrgProjectContext } from "../../lib/telemetry.js";
4648
import { isAllDigits } from "../../lib/utils.js";
47-
import type { DetailedSentryLog } from "../../types/index.js";
49+
import type { DetailedSentryLog, TraceItemDetail } from "../../types/index.js";
4850

4951
const log = logger.withTag("log-view");
5052

53+
/** Matches SPAN_DETAIL_CONCURRENCY in traces.ts */
54+
const LOG_DETAIL_CONCURRENCY = 15;
55+
5156
type ViewFlags = {
5257
readonly json: boolean;
5358
readonly web: boolean;
@@ -399,6 +404,10 @@ type LogViewData = {
399404
logs: DetailedSentryLog[];
400405
/** Org slug — needed by human formatter for trace URLs, also useful context in JSON */
401406
orgSlug: string;
407+
/** Full attribute sets from the trace-items detail endpoint (index matches logs) */
408+
details?: (TraceItemDetail | undefined)[];
409+
/** --fields filter: limits which custom attributes are shown in human output */
410+
extraFields?: string[];
402411
};
403412

404413
/**
@@ -412,11 +421,19 @@ type LogViewData = {
412421
*/
413422
function formatLogViewHuman(data: LogViewData): string {
414423
const parts: string[] = [];
415-
for (const entry of data.logs) {
424+
for (let i = 0; i < data.logs.length; i++) {
416425
if (parts.length > 0) {
417426
parts.push("\n---\n");
418427
}
419-
parts.push(formatLogDetails(entry, data.orgSlug));
428+
parts.push(
429+
formatLogDetails(
430+
// biome-ignore lint/style/noNonNullAssertion: index is bounded by data.logs.length
431+
data.logs[i]!,
432+
data.orgSlug,
433+
data.details?.[i]?.attributes,
434+
data.extraFields
435+
)
436+
);
420437
}
421438
return parts.join("\n");
422439
}
@@ -496,19 +513,51 @@ export const viewCommand = buildCommand({
496513
}
497514

498515
// Fetch all requested log entries
499-
const logs = await getLogs(target.org, target.project, logIds);
516+
const logs = await getLogs(
517+
target.org,
518+
target.project,
519+
logIds,
520+
flags.fields
521+
);
500522

501523
if (logs.length === 0) {
502524
throwNotFoundError(logIds, target.org, target.project);
503525
}
504526

505527
warnMissingIds(logIds, logs);
506528

529+
// Skip detail fetching in JSON mode — jsonTransform only uses data.logs,
530+
// not data.details, so the extra round-trips would be wasted.
531+
// Mirrors the shouldFetchDetails pattern in trace/view.ts.
532+
const detailLimit = pLimit(LOG_DETAIL_CONCURRENCY);
533+
const details = flags.json
534+
? undefined
535+
: await detailLimit.map(logs, async (entry) => {
536+
if (!entry.trace) {
537+
return;
538+
}
539+
try {
540+
return await getLogItemDetail(
541+
target.org,
542+
target.project,
543+
entry["sentry.item_id"],
544+
entry.trace
545+
);
546+
} catch {
547+
return;
548+
}
549+
});
550+
507551
const hint = target.detectedFrom
508552
? `Detected from ${target.detectedFrom}`
509553
: undefined;
510554

511-
yield new CommandOutput({ logs, orgSlug: target.org });
555+
yield new CommandOutput({
556+
logs,
557+
orgSlug: target.org,
558+
details,
559+
extraFields: flags.fields,
560+
});
512561
return { hint };
513562
},
514563
});

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: 53 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -12,20 +12,20 @@ import {
1212
type DetailedSentryLog,
1313
LogsResponseSchema,
1414
type SentryLog,
15+
type TraceItemDetail,
1516
type TraceLog,
1617
TraceLogsResponseSchema,
1718
} from "../../types/index.js";
18-
1919
import { resolveOrgRegion } from "../region.js";
2020
import { LOG_RETENTION_PERIOD } from "../retention.js";
2121
import { isAllDigits } from "../utils.js";
22-
2322
import {
2423
API_MAX_PER_PAGE,
2524
apiRequestToRegion,
2625
getOrgSdkConfig,
2726
unwrapResult,
2827
} from "./infrastructure.js";
28+
import { getTraceItemDetail } from "./traces.js";
2929

3030
/** Sort direction for log queries: newest-first or oldest-first. */
3131
export type LogSortDirection = "newest" | "oldest";
@@ -158,20 +158,32 @@ const DETAILED_LOG_FIELDS = [
158158
* Fetch a single batch of log entries by their item IDs.
159159
* Batch size must not exceed {@link API_MAX_PER_PAGE}.
160160
*/
161+
type GetLogsBatchOptions = {
162+
config: Awaited<ReturnType<typeof getOrgSdkConfig>>;
163+
extraFields?: string[];
164+
};
165+
161166
async function getLogsBatch(
162167
orgSlug: string,
163168
projectSlug: string,
164169
batchIds: string[],
165-
config: Awaited<ReturnType<typeof getOrgSdkConfig>>
170+
{ config, extraFields }: GetLogsBatchOptions
166171
): Promise<DetailedSentryLog[]> {
167172
const query = `project:${projectSlug} sentry.item_id:[${batchIds.join(",")}]`;
168173

174+
const fields = extraFields?.length
175+
? [
176+
...DETAILED_LOG_FIELDS,
177+
...extraFields.filter((f) => !DETAILED_LOG_FIELDS.includes(f)),
178+
]
179+
: DETAILED_LOG_FIELDS;
180+
169181
const result = await queryExploreEventsInTableFormat({
170182
...config,
171183
path: { organization_id_or_slug: orgSlug },
172184
query: {
173185
dataset: "logs",
174-
field: DETAILED_LOG_FIELDS,
186+
field: fields,
175187
query,
176188
per_page: batchIds.length,
177189
statsPeriod: LOG_RETENTION_PERIOD,
@@ -199,13 +211,14 @@ async function getLogsBatch(
199211
export async function getLogs(
200212
orgSlug: string,
201213
projectSlug: string,
202-
logIds: string[]
214+
logIds: string[],
215+
extraFields?: string[]
203216
): Promise<DetailedSentryLog[]> {
204217
const config = await getOrgSdkConfig(orgSlug);
205218

206219
// Single batch — no splitting needed
207220
if (logIds.length <= API_MAX_PER_PAGE) {
208-
return getLogsBatch(orgSlug, projectSlug, logIds, config);
221+
return getLogsBatch(orgSlug, projectSlug, logIds, { config, extraFields });
209222
}
210223

211224
// Split into batches of API_MAX_PER_PAGE and fetch in parallel
@@ -215,7 +228,9 @@ export async function getLogs(
215228
}
216229

217230
const results = await Promise.all(
218-
batches.map((batch) => getLogsBatch(orgSlug, projectSlug, batch, config))
231+
batches.map((batch) =>
232+
getLogsBatch(orgSlug, projectSlug, batch, { config, extraFields })
233+
)
219234
);
220235

221236
return results.flat();
@@ -285,3 +300,34 @@ export async function listTraceLogs(
285300

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

src/lib/api/traces.ts

Lines changed: 46 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ import {
1010
type SpanListItem,
1111
type SpansResponse,
1212
SpansResponseSchema,
13+
type TraceItemDetail,
14+
TraceItemDetailSchema,
1315
type TraceMeta,
1416
TraceMetaSchema,
1517
type TraceSpan,
@@ -18,6 +20,9 @@ import {
1820
TransactionsResponseSchema,
1921
} from "../../types/index.js";
2022

23+
// Re-export so existing callers (api-client.ts, formatters/trace.ts) don't need to change.
24+
export type { TraceItemAttribute, TraceItemDetail } from "../../types/index.js";
25+
2126
import { logger } from "../logger.js";
2227
import { resolveOrgRegion } from "../region.js";
2328
import { isAllDigits } from "../utils.js";
@@ -76,21 +81,10 @@ export const REDUNDANT_DETAIL_ATTRS = new Set([
7681
"environment",
7782
]);
7883

79-
/** A single attribute returned by the trace-items detail endpoint */
80-
export type TraceItemAttribute = {
81-
name: string;
82-
type: "str" | "int" | "float" | "bool";
83-
value: string | number | boolean;
84-
};
85-
86-
/** Response from GET /projects/{org}/{project}/trace-items/{itemId}/ */
87-
export type TraceItemDetail = {
88-
itemId: string;
89-
timestamp: string;
90-
attributes: TraceItemAttribute[];
91-
meta: Record<string, unknown>;
92-
links: unknown;
93-
};
84+
// TraceItemAttribute and TraceItemDetail are defined with Zod schemas in
85+
// src/types/sentry.ts and re-exported via the types barrel (src/types/index.ts).
86+
// They are also re-exported from this module (see top of file) for callers
87+
// that already import from traces.ts.
9488

9589
/** Options for {@link getDetailedTrace}. */
9690
type GetDetailedTraceOptions = {
@@ -137,40 +131,61 @@ export async function getDetailedTrace(
137131
return data.map(normalizeTraceSpan);
138132
}
139133

134+
type GetTraceItemDetailOptions = {
135+
traceId: string;
136+
itemType: "spans" | "logs";
137+
};
138+
140139
/**
141-
* Fetch full attribute details for a single span.
140+
* Fetch full attribute details for a single trace item via the experimental
141+
* /projects/{org}/{project}/trace-items/{itemId}/ endpoint.
142142
*
143-
* Uses the trace-items detail endpoint which returns ALL span attributes
144-
* without requiring the caller to enumerate them. This is the same endpoint
145-
* the Sentry frontend uses in the span detail sidebar.
143+
* This endpoint is not yet in @sentry/api (getsentry/sentry-api-schema) because
144+
* it is marked EXPERIMENTAL in Sentry. Both span and log detail views use it.
146145
*
147146
* @param orgSlug - Organization slug
148147
* @param projectSlug - Project slug
149-
* @param spanId - The 16-char hex span ID
150-
* @param traceId - The parent trace ID (required for lookup)
151-
* @returns Full span detail with all attributes
148+
* @param itemId - The item ID (span ID or log sentry.item_id)
149+
* @param options - traceId (required by endpoint) and itemType ("spans" | "logs")
152150
*/
153-
export async function getSpanDetails(
151+
export async function getTraceItemDetail(
154152
orgSlug: string,
155153
projectSlug: string,
156-
spanId: string,
157-
traceId: string
154+
itemId: string,
155+
{ traceId, itemType }: GetTraceItemDetailOptions
158156
): Promise<TraceItemDetail> {
159157
const regionUrl = await resolveOrgRegion(orgSlug);
160-
161158
const { data } = await apiRequestToRegion<TraceItemDetail>(
162159
regionUrl,
163-
`/projects/${orgSlug}/${projectSlug}/trace-items/${spanId}/`,
160+
`/projects/${orgSlug}/${projectSlug}/trace-items/${itemId}/`,
164161
{
165-
params: {
166-
trace_id: traceId,
167-
item_type: "spans",
168-
},
162+
params: { trace_id: traceId, item_type: itemType },
163+
schema: TraceItemDetailSchema,
169164
}
170165
);
171166
return data;
172167
}
173168

169+
/**
170+
* Fetch full attribute details for a single span.
171+
*
172+
* @param orgSlug - Organization slug
173+
* @param projectSlug - Project slug
174+
* @param spanId - The 16-char hex span ID
175+
* @param traceId - The parent trace ID (required for lookup)
176+
*/
177+
export function getSpanDetails(
178+
orgSlug: string,
179+
projectSlug: string,
180+
spanId: string,
181+
traceId: string
182+
): Promise<TraceItemDetail> {
183+
return getTraceItemDetail(orgSlug, projectSlug, spanId, {
184+
traceId,
185+
itemType: "spans",
186+
});
187+
}
188+
174189
/**
175190
* Fetch high-level metadata for a trace.
176191
*

0 commit comments

Comments
 (0)