diff --git a/src/commands/log/view.ts b/src/commands/log/view.ts index 8d2e9d4d2..628de3d09 100644 --- a/src/commands/log/view.ts +++ b/src/commands/log/view.ts @@ -6,8 +6,10 @@ import { isatty } from "node:tty"; +import pLimit from "p-limit"; + import type { SentryContext } from "../../context.js"; -import { getLogs } from "../../lib/api-client.js"; +import { getLogItemDetail, getLogs } from "../../lib/api-client.js"; import { detectSwappedViewArgs, looksLikeIssueShortId, @@ -44,10 +46,13 @@ import { RETENTION_DAYS } from "../../lib/retention.js"; import { buildLogsUrl } from "../../lib/sentry-urls.js"; import { setOrgProjectContext } from "../../lib/telemetry.js"; import { isAllDigits } from "../../lib/utils.js"; -import type { DetailedSentryLog } from "../../types/index.js"; +import type { DetailedSentryLog, TraceItemDetail } from "../../types/index.js"; const log = logger.withTag("log-view"); +/** Matches SPAN_DETAIL_CONCURRENCY in traces.ts */ +const LOG_DETAIL_CONCURRENCY = 15; + type ViewFlags = { readonly json: boolean; readonly web: boolean; @@ -399,6 +404,10 @@ type LogViewData = { logs: DetailedSentryLog[]; /** Org slug — needed by human formatter for trace URLs, also useful context in JSON */ orgSlug: string; + /** Full attribute sets from the trace-items detail endpoint (index matches logs) */ + details?: (TraceItemDetail | undefined)[]; + /** --fields filter: limits which custom attributes are shown in human output */ + extraFields?: string[]; }; /** @@ -412,11 +421,19 @@ type LogViewData = { */ function formatLogViewHuman(data: LogViewData): string { const parts: string[] = []; - for (const entry of data.logs) { + for (let i = 0; i < data.logs.length; i++) { if (parts.length > 0) { parts.push("\n---\n"); } - parts.push(formatLogDetails(entry, data.orgSlug)); + parts.push( + formatLogDetails( + // biome-ignore lint/style/noNonNullAssertion: index is bounded by data.logs.length + data.logs[i]!, + data.orgSlug, + data.details?.[i]?.attributes, + data.extraFields + ) + ); } return parts.join("\n"); } @@ -496,7 +513,12 @@ export const viewCommand = buildCommand({ } // Fetch all requested log entries - const logs = await getLogs(target.org, target.project, logIds); + const logs = await getLogs( + target.org, + target.project, + logIds, + flags.fields + ); if (logs.length === 0) { throwNotFoundError(logIds, target.org, target.project); @@ -504,11 +526,38 @@ export const viewCommand = buildCommand({ warnMissingIds(logIds, logs); + // Skip detail fetching in JSON mode — jsonTransform only uses data.logs, + // not data.details, so the extra round-trips would be wasted. + // Mirrors the shouldFetchDetails pattern in trace/view.ts. + const detailLimit = pLimit(LOG_DETAIL_CONCURRENCY); + const details = flags.json + ? undefined + : await detailLimit.map(logs, async (entry) => { + if (!entry.trace) { + return; + } + try { + return await getLogItemDetail( + target.org, + target.project, + entry["sentry.item_id"], + entry.trace + ); + } catch { + return; + } + }); + const hint = target.detectedFrom ? `Detected from ${target.detectedFrom}` : undefined; - yield new CommandOutput({ logs, orgSlug: target.org }); + yield new CommandOutput({ + logs, + orgSlug: target.org, + details, + extraFields: flags.fields, + }); return { hint }; }, }); diff --git a/src/lib/api-client.ts b/src/lib/api-client.ts index 6b4d1e78f..fa59e9d67 100644 --- a/src/lib/api-client.ts +++ b/src/lib/api-client.ts @@ -74,6 +74,7 @@ export { updateIssueStatus, } from "./api/issues.js"; export { + getLogItemDetail, getLogs, type LogSortDirection, listLogs, diff --git a/src/lib/api/logs.ts b/src/lib/api/logs.ts index c282327e6..35085962d 100644 --- a/src/lib/api/logs.ts +++ b/src/lib/api/logs.ts @@ -12,20 +12,20 @@ import { type DetailedSentryLog, LogsResponseSchema, type SentryLog, + type TraceItemDetail, type TraceLog, TraceLogsResponseSchema, } from "../../types/index.js"; - import { resolveOrgRegion } from "../region.js"; import { LOG_RETENTION_PERIOD } from "../retention.js"; import { isAllDigits } from "../utils.js"; - import { API_MAX_PER_PAGE, apiRequestToRegion, getOrgSdkConfig, unwrapResult, } from "./infrastructure.js"; +import { getTraceItemDetail } from "./traces.js"; /** Sort direction for log queries: newest-first or oldest-first. */ export type LogSortDirection = "newest" | "oldest"; @@ -158,20 +158,32 @@ const DETAILED_LOG_FIELDS = [ * Fetch a single batch of log entries by their item IDs. * Batch size must not exceed {@link API_MAX_PER_PAGE}. */ +type GetLogsBatchOptions = { + config: Awaited>; + extraFields?: string[]; +}; + async function getLogsBatch( orgSlug: string, projectSlug: string, batchIds: string[], - config: Awaited> + { config, extraFields }: GetLogsBatchOptions ): Promise { const query = `project:${projectSlug} sentry.item_id:[${batchIds.join(",")}]`; + const fields = extraFields?.length + ? [ + ...DETAILED_LOG_FIELDS, + ...extraFields.filter((f) => !DETAILED_LOG_FIELDS.includes(f)), + ] + : DETAILED_LOG_FIELDS; + const result = await queryExploreEventsInTableFormat({ ...config, path: { organization_id_or_slug: orgSlug }, query: { dataset: "logs", - field: DETAILED_LOG_FIELDS, + field: fields, query, per_page: batchIds.length, statsPeriod: LOG_RETENTION_PERIOD, @@ -199,13 +211,14 @@ async function getLogsBatch( export async function getLogs( orgSlug: string, projectSlug: string, - logIds: string[] + logIds: string[], + extraFields?: string[] ): Promise { const config = await getOrgSdkConfig(orgSlug); // Single batch — no splitting needed if (logIds.length <= API_MAX_PER_PAGE) { - return getLogsBatch(orgSlug, projectSlug, logIds, config); + return getLogsBatch(orgSlug, projectSlug, logIds, { config, extraFields }); } // Split into batches of API_MAX_PER_PAGE and fetch in parallel @@ -215,7 +228,9 @@ export async function getLogs( } const results = await Promise.all( - batches.map((batch) => getLogsBatch(orgSlug, projectSlug, batch, config)) + batches.map((batch) => + getLogsBatch(orgSlug, projectSlug, batch, { config, extraFields }) + ) ); return results.flat(); @@ -285,3 +300,34 @@ export async function listTraceLogs( return response.data; } + +/** + * Fetch all attributes for a single log entry via the trace-items detail endpoint. + * + * Returns every attribute on the log — standard and custom alike — without needing + * to enumerate field names. This is the same endpoint the Sentry UI uses when + * expanding a log row to show its full attribute set. + * + * The endpoint is EXPERIMENTAL and not yet in @sentry/api; called directly via + * apiRequestToRegion following the same pattern as listTraceLogs. + * + * @param orgSlug - Organization slug + * @param projectSlug - Project slug + * @param logId - The sentry.item_id of the log entry + * @param traceId - The trace ID (required by the endpoint) + * + * Uses the experimental /projects/{org}/{project}/trace-items/ endpoint directly via + * apiRequestToRegion — it is not yet available in @sentry/api (generated from + * getsentry/sentry-api-schema) because the endpoint is marked EXPERIMENTAL in Sentry. + */ +export function getLogItemDetail( + orgSlug: string, + projectSlug: string, + logId: string, + traceId: string +): Promise { + return getTraceItemDetail(orgSlug, projectSlug, logId, { + traceId, + itemType: "logs", + }); +} diff --git a/src/lib/api/traces.ts b/src/lib/api/traces.ts index d50f3dc77..67c7d4459 100644 --- a/src/lib/api/traces.ts +++ b/src/lib/api/traces.ts @@ -10,6 +10,8 @@ import { type SpanListItem, type SpansResponse, SpansResponseSchema, + type TraceItemDetail, + TraceItemDetailSchema, type TraceMeta, TraceMetaSchema, type TraceSpan, @@ -18,6 +20,9 @@ import { TransactionsResponseSchema, } from "../../types/index.js"; +// Re-export so existing callers (api-client.ts, formatters/trace.ts) don't need to change. +export type { TraceItemAttribute, TraceItemDetail } from "../../types/index.js"; + import { logger } from "../logger.js"; import { resolveOrgRegion } from "../region.js"; import { isAllDigits } from "../utils.js"; @@ -76,21 +81,10 @@ export const REDUNDANT_DETAIL_ATTRS = new Set([ "environment", ]); -/** A single attribute returned by the trace-items detail endpoint */ -export type TraceItemAttribute = { - name: string; - type: "str" | "int" | "float" | "bool"; - value: string | number | boolean; -}; - -/** Response from GET /projects/{org}/{project}/trace-items/{itemId}/ */ -export type TraceItemDetail = { - itemId: string; - timestamp: string; - attributes: TraceItemAttribute[]; - meta: Record; - links: unknown; -}; +// TraceItemAttribute and TraceItemDetail are defined with Zod schemas in +// src/types/sentry.ts and re-exported via the types barrel (src/types/index.ts). +// They are also re-exported from this module (see top of file) for callers +// that already import from traces.ts. /** Options for {@link getDetailedTrace}. */ type GetDetailedTraceOptions = { @@ -137,40 +131,61 @@ export async function getDetailedTrace( return data.map(normalizeTraceSpan); } +type GetTraceItemDetailOptions = { + traceId: string; + itemType: "spans" | "logs"; +}; + /** - * Fetch full attribute details for a single span. + * Fetch full attribute details for a single trace item via the experimental + * /projects/{org}/{project}/trace-items/{itemId}/ endpoint. * - * Uses the trace-items detail endpoint which returns ALL span attributes - * without requiring the caller to enumerate them. This is the same endpoint - * the Sentry frontend uses in the span detail sidebar. + * This endpoint is not yet in @sentry/api (getsentry/sentry-api-schema) because + * it is marked EXPERIMENTAL in Sentry. Both span and log detail views use it. * * @param orgSlug - Organization slug * @param projectSlug - Project slug - * @param spanId - The 16-char hex span ID - * @param traceId - The parent trace ID (required for lookup) - * @returns Full span detail with all attributes + * @param itemId - The item ID (span ID or log sentry.item_id) + * @param options - traceId (required by endpoint) and itemType ("spans" | "logs") */ -export async function getSpanDetails( +export async function getTraceItemDetail( orgSlug: string, projectSlug: string, - spanId: string, - traceId: string + itemId: string, + { traceId, itemType }: GetTraceItemDetailOptions ): Promise { const regionUrl = await resolveOrgRegion(orgSlug); - const { data } = await apiRequestToRegion( regionUrl, - `/projects/${orgSlug}/${projectSlug}/trace-items/${spanId}/`, + `/projects/${orgSlug}/${projectSlug}/trace-items/${itemId}/`, { - params: { - trace_id: traceId, - item_type: "spans", - }, + params: { trace_id: traceId, item_type: itemType }, + schema: TraceItemDetailSchema, } ); return data; } +/** + * Fetch full attribute details for a single span. + * + * @param orgSlug - Organization slug + * @param projectSlug - Project slug + * @param spanId - The 16-char hex span ID + * @param traceId - The parent trace ID (required for lookup) + */ +export function getSpanDetails( + orgSlug: string, + projectSlug: string, + spanId: string, + traceId: string +): Promise { + return getTraceItemDetail(orgSlug, projectSlug, spanId, { + traceId, + itemType: "spans", + }); +} + /** * Fetch high-level metadata for a trace. * diff --git a/src/lib/formatters/log.ts b/src/lib/formatters/log.ts index 76b07f077..529629745 100644 --- a/src/lib/formatters/log.ts +++ b/src/lib/formatters/log.ts @@ -4,7 +4,11 @@ * Provides formatting utilities for displaying Sentry logs in the CLI. */ -import type { DetailedSentryLog, SentryLog } from "../../types/index.js"; +import type { + DetailedSentryLog, + SentryLog, + TraceItemAttribute, +} from "../../types/index.js"; import { buildTraceUrl } from "../sentry-urls.js"; import { colorTag, @@ -281,18 +285,65 @@ function formatSeverityLabel(severity: string | null | undefined): string { return tag ? colorTag(tag, label) : label; } +/** + * Attribute names to exclude from the Custom Attributes section in formatLogDetails. + * Mirrors REDUNDANT_DETAIL_ATTRS in traces.ts (the span equivalent). + * Covers attributes already shown in the fixed sections above, plus internal/noisy + * fields that mirror Sentry UI's HiddenLogDetailFields. + */ +const REDUNDANT_LOG_DETAIL_ATTRS = new Set([ + // Core section + "sentry.item_id", + "id", + "timestamp", + "timestamp_precise", + "message", + "severity", + // Context section + "trace", + "project", + "environment", + "release", + // SDK section + "sdk.name", + "sdk.version", + // Trace section + "span_id", + // Source location section + "code.function", + "code.file.path", + "code.line.number", + // OTel section + "sentry.otel.kind", + "sentry.otel.status_code", + "sentry.otel.instrumentation_scope.name", + // Internal / always-hidden noise (mirrors Sentry UI HiddenLogDetailFields) + "severity_number", + "item_type", + "organization_id", + "project.id", + "project_id", + "sentry.timestamp_nanos", + "sentry.observed_timestamp_nanos", + "tags[sentry.trace_flags,number]", +]); + /** * Format detailed log entry for display as rendered markdown. * Shows all available fields in a structured format. * * @param log - The detailed log entry to format * @param orgSlug - Organization slug for building trace URLs + * @param allAttributes - All attributes from the trace-items detail endpoint (shows custom attrs) + * @param extraFields - Optional --fields filter: limits which custom attributes are shown * @returns Rendered terminal string */ // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: log detail formatting requires multiple conditional sections export function formatLogDetails( log: DetailedSentryLog, - orgSlug: string + orgSlug: string, + allAttributes?: TraceItemAttribute[], + extraFields?: string[] ): string { const logId = log["sentry.item_id"]; const lines: string[] = []; @@ -394,5 +445,37 @@ export function formatLogDetails( lines.push(mdKvTable(otelRows, "OpenTelemetry")); } + // Custom Attributes — from trace-items detail endpoint (all non-standard attributes) + if (allAttributes?.length) { + let customAttrs = allAttributes.filter( + (a) => !REDUNDANT_LOG_DETAIL_ATTRS.has(a.name) + ); + if (extraFields?.length) { + const wanted = new Set(extraFields); + customAttrs = customAttrs.filter((a) => wanted.has(a.name)); + } + if (customAttrs.length > 0) { + lines.push(""); + lines.push( + mdKvTable( + customAttrs.map((a) => [ + a.name, + a.type === "array" ? JSON.stringify(a.value) : String(a.value), + ]), + "Custom Attributes" + ) + ); + } + } else if (extraFields?.length) { + // Fallback: no trace-items detail available, show only explicitly requested fields + const customRows: [string, string][] = extraFields + .filter((f) => log[f] !== null && log[f] !== undefined) + .map((f) => [f, String(log[f])]); + if (customRows.length > 0) { + lines.push(""); + lines.push(mdKvTable(customRows, "Custom Attributes")); + } + } + return renderMarkdown(lines.join("\n")); } diff --git a/src/types/index.ts b/src/types/index.ts index ed8af9394..79d91207b 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -138,6 +138,8 @@ export type { StackFrame, Stacktrace, TraceContext, + TraceItemAttribute, + TraceItemDetail, TraceLog, TraceLogsResponse, TraceMeta, @@ -164,6 +166,8 @@ export { SentryUserSchema, SpanListItemSchema, SpansResponseSchema, + TraceItemAttributeSchema, + TraceItemDetailSchema, TraceLogSchema, TraceLogsResponseSchema, TraceMetaSchema, diff --git a/src/types/sentry.ts b/src/types/sentry.ts index 3a173ce5b..888e0069e 100644 --- a/src/types/sentry.ts +++ b/src/types/sentry.ts @@ -840,6 +840,40 @@ export const DetailedLogsResponseSchema = z.object({ export type DetailedLogsResponse = z.infer; +// Trace-item detail types (from /projects/{org}/{project}/trace-items/{itemId}/ endpoint) + +/** + * A single attribute on a trace item (log, span, etc.). + * + * Mirrors Sentry's TraceItemResponseAttribute: + * https://github.com/getsentry/sentry/blob/8a4f150b21b/static/app/views/explore/hooks/useTraceItemDetails.tsx#L85-L89 + * + * The endpoint is EXPERIMENTAL and not yet in @sentry/api (getsentry/sentry-api-schema). + */ +export const TraceItemAttributeSchema = z.discriminatedUnion("type", [ + z.object({ name: z.string(), type: z.literal("str"), value: z.string() }), + z.object({ name: z.string(), type: z.literal("int"), value: z.number() }), + z.object({ name: z.string(), type: z.literal("float"), value: z.number() }), + z.object({ name: z.string(), type: z.literal("bool"), value: z.boolean() }), + // "array" is gated by organizations:trace-item-details-array-fields in Sentry backend + z.object({ + name: z.string(), + type: z.literal("array"), + value: z.array(z.unknown()), + }), +]); +export type TraceItemAttribute = z.infer; + +/** Response from GET /projects/{org}/{project}/trace-items/{itemId}/ (logs and spans) */ +export const TraceItemDetailSchema = z + .object({ + itemId: z.string(), + timestamp: z.string(), + attributes: z.array(TraceItemAttributeSchema), + }) + .passthrough(); // preserves meta, links, and any future fields returned by the endpoint +export type TraceItemDetail = z.infer; + // Trace-log types (from /organizations/{org}/trace-logs/ endpoint) /** diff --git a/test/commands/log/view.func.test.ts b/test/commands/log/view.func.test.ts index c2e0209e2..3258579c4 100644 --- a/test/commands/log/view.func.test.ts +++ b/test/commands/log/view.func.test.ts @@ -66,12 +66,19 @@ function createMockContext() { describe("viewCommand.func", () => { let getLogsSpy: ReturnType; + let getLogItemDetailSpy: ReturnType; let resolveOrgAndProjectSpy: ReturnType; let resolveProjectBySlugSpy: ReturnType; let openInBrowserSpy: ReturnType; beforeEach(() => { getLogsSpy = spyOn(apiClient, "getLogs"); + getLogItemDetailSpy = spyOn(apiClient, "getLogItemDetail"); + getLogItemDetailSpy.mockResolvedValue({ + itemId: "", + timestamp: "", + attributes: [], + }); resolveOrgAndProjectSpy = spyOn(resolveTarget, "resolveOrgAndProject"); resolveProjectBySlugSpy = spyOn(resolveTarget, "resolveProjectBySlug"); openInBrowserSpy = spyOn(browser, "openInBrowser"); @@ -79,6 +86,7 @@ describe("viewCommand.func", () => { afterEach(() => { getLogsSpy.mockRestore(); + getLogItemDetailSpy.mockRestore(); resolveOrgAndProjectSpy.mockRestore(); resolveProjectBySlugSpy.mockRestore(); openInBrowserSpy.mockRestore(); @@ -193,7 +201,12 @@ describe("viewCommand.func", () => { ); // getLogs should have been called with both IDs - expect(getLogsSpy).toHaveBeenCalledWith("my-org", "proj", [ID1, ID2]); + expect(getLogsSpy).toHaveBeenCalledWith( + "my-org", + "proj", + [ID1, ID2], + undefined + ); const output = stdoutWrite.mock.calls.map((c) => c[0]).join(""); const parsed = JSON.parse(output); @@ -295,9 +308,12 @@ describe("viewCommand.func", () => { await func.call(context, { json: true, web: false }, "my-project", ID1); expect(resolveProjectBySlugSpy).toHaveBeenCalled(); - expect(getLogsSpy).toHaveBeenCalledWith("resolved-org", "resolved-proj", [ - ID1, - ]); + expect(getLogsSpy).toHaveBeenCalledWith( + "resolved-org", + "resolved-proj", + [ID1], + undefined + ); }); test("org/ target (org-all) throws ContextError", async () => { @@ -327,9 +343,12 @@ describe("viewCommand.func", () => { await func.call(context, { json: false, web: false }, ID1); expect(resolveOrgAndProjectSpy).toHaveBeenCalled(); - expect(getLogsSpy).toHaveBeenCalledWith("detected-org", "detected-proj", [ - ID1, - ]); + expect(getLogsSpy).toHaveBeenCalledWith( + "detected-org", + "detected-proj", + [ID1], + undefined + ); // Human output should include the detected-from hint const output = stdoutWrite.mock.calls.map((c) => c[0]).join(""); @@ -353,4 +372,83 @@ describe("viewCommand.func", () => { } }); }); + + describe("detail attribute fetching", () => { + test("calls getLogItemDetail for logs that have a trace", async () => { + const log = makeSampleLog(ID1); // makeSampleLog sets trace: "abc123..." + getLogsSpy.mockResolvedValue([log]); + + const { context } = createMockContext(); + const func = await viewCommand.loader(); + await func.call(context, { json: false, web: false }, "my-org/proj", ID1); + + expect(getLogItemDetailSpy).toHaveBeenCalledWith( + "my-org", + "proj", + ID1, + log.trace + ); + }); + + test("does not call getLogItemDetail for logs without a trace", async () => { + const log = makeSampleLog(ID1, "no trace log"); + log.trace = null; + getLogsSpy.mockResolvedValue([log]); + + const { context } = createMockContext(); + const func = await viewCommand.loader(); + await func.call(context, { json: false, web: false }, "my-org/proj", ID1); + + expect(getLogItemDetailSpy).not.toHaveBeenCalled(); + }); + + test("still renders output when getLogItemDetail fails", async () => { + const log = makeSampleLog(ID1); + getLogsSpy.mockResolvedValue([log]); + getLogItemDetailSpy.mockRejectedValue(new Error("network error")); + + const { context, stdoutWrite } = createMockContext(); + const func = await viewCommand.loader(); + await func.call(context, { json: false, web: false }, "my-org/proj", ID1); + + // Should still render the log with standard fields despite detail failure + const output = stdoutWrite.mock.calls.map((c) => c[0]).join(""); + expect(output).toContain(ID1); + }); + + test("does not call getLogItemDetail in JSON mode", async () => { + const log = makeSampleLog(ID1); + getLogsSpy.mockResolvedValue([log]); + + const { context } = createMockContext(); + const func = await viewCommand.loader(); + await func.call(context, { json: true, web: false }, "my-org/proj", ID1); + + expect(getLogItemDetailSpy).not.toHaveBeenCalled(); + }); + + test("renders custom attributes in human output when detail available", async () => { + const log = makeSampleLog(ID1); + getLogsSpy.mockResolvedValue([log]); + getLogItemDetailSpy.mockResolvedValue({ + itemId: ID1, + timestamp: log.timestamp, + attributes: [ + { name: "user.id", type: "str", value: "u_42" }, + { name: "order.status", type: "str", value: "shipped" }, + ], + }); + + const { context, stdoutWrite } = createMockContext(); + const func = await viewCommand.loader(); + await func.call(context, { json: false, web: false }, "my-org/proj", ID1); + + const output = stdoutWrite.mock.calls.map((c) => c[0]).join(""); + expect(output).toContain("Custom Attributes"); + expect(output).toContain("user.id"); + expect(output).toContain("u_42"); + expect(output).toContain("order.status"); + expect(output).toContain("shipped"); + }); + }); }); diff --git a/test/lib/api/traces.test.ts b/test/lib/api/traces.test.ts index 64e835101..be2d61a96 100644 --- a/test/lib/api/traces.test.ts +++ b/test/lib/api/traces.test.ts @@ -6,7 +6,11 @@ */ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; -import { listSpans, listTransactions } from "../../../src/lib/api/traces.js"; +import { + getSpanDetails, + listSpans, + listTransactions, +} from "../../../src/lib/api/traces.js"; import { mockFetch, useTestConfigDir } from "../../helpers.js"; // --------------------------------------------------------------------------- @@ -634,3 +638,80 @@ describe("listSpans", () => { expect(result.nextCursor).toBeUndefined(); }); }); + +// --------------------------------------------------------------------------- +// getSpanDetails +// --------------------------------------------------------------------------- + +describe("getSpanDetails", () => { + useTestConfigDir("traces-span-details-test-"); + + let originalFetch: typeof globalThis.fetch; + let capturedUrl = ""; + let capturedParams: Record = {}; + + beforeEach(() => { + originalFetch = globalThis.fetch; + capturedUrl = ""; + capturedParams = {}; + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + function mockOk(body: unknown) { + globalThis.fetch = mockFetch(async (input, init) => { + const req = new Request(input!, init); + capturedUrl = req.url; + const url = new URL(capturedUrl); + url.searchParams.forEach((v, k) => { + capturedParams[k] = v; + }); + return new Response(JSON.stringify(body), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }); + } + + const DETAIL_RESPONSE = { + itemId: "abc123", + timestamp: "2026-01-01T00:00:00Z", + attributes: [ + { name: "span.op", type: "str", value: "db.query" }, + { name: "user.id", type: "str", value: "u_42" }, + ], + }; + + test("calls trace-items endpoint with item_type=spans", async () => { + mockOk(DETAIL_RESPONSE); + + await getSpanDetails("my-org", "my-project", "span-id-abc", "trace-id-xyz"); + + expect(capturedUrl).toContain( + "/projects/my-org/my-project/trace-items/span-id-abc/" + ); + expect(capturedParams.item_type).toBe("spans"); + expect(capturedParams.trace_id).toBe("trace-id-xyz"); + }); + + test("returns parsed attributes", async () => { + mockOk(DETAIL_RESPONSE); + + const result = await getSpanDetails( + "my-org", + "my-project", + "span-id-abc", + "trace-id-xyz" + ); + + expect(result.itemId).toBe("abc123"); + expect(result.attributes).toHaveLength(2); + expect(result.attributes[0]).toEqual({ + name: "span.op", + type: "str", + value: "db.query", + }); + }); +}); diff --git a/test/lib/formatters/log.test.ts b/test/lib/formatters/log.test.ts index f8f8398a2..288647d60 100644 --- a/test/lib/formatters/log.test.ts +++ b/test/lib/formatters/log.test.ts @@ -12,7 +12,11 @@ import { formatLogTable, getLogId, } from "../../../src/lib/formatters/log.js"; -import type { DetailedSentryLog, SentryLog } from "../../../src/types/index.js"; +import type { + DetailedSentryLog, + SentryLog, + TraceItemAttribute, +} from "../../../src/types/index.js"; /** Force rendered (TTY) mode for a describe block */ function useRenderedMode() { @@ -441,6 +445,103 @@ describe("formatLogDetails", () => { expect(result).not.toContain("SDK"); expect(result).not.toContain("Trace"); }); + + describe("Custom Attributes section", () => { + const customAttrs: TraceItemAttribute[] = [ + { name: "user.id", type: "str", value: "u_42" }, + { name: "order.total", type: "float", value: 99.9 }, + { name: "retry.count", type: "int", value: 3 }, + { name: "is_premium", type: "bool", value: true }, + ]; + + test("renders custom attributes when allAttributes provided", () => { + const log = createDetailedTestLog(); + const result = stripAnsi(formatLogDetails(log, "test-org", customAttrs)); + + expect(result).toContain("Custom Attributes"); + expect(result).toContain("user.id"); + expect(result).toContain("u_42"); + expect(result).toContain("order.total"); + expect(result).toContain("99.9"); + expect(result).toContain("retry.count"); + expect(result).toContain("3"); + expect(result).toContain("is_premium"); + expect(result).toContain("true"); + }); + + test("filters out REDUNDANT_LOG_DETAIL_ATTRS from custom attributes", () => { + const attrsWithRedundant: TraceItemAttribute[] = [ + { name: "user.id", type: "str", value: "u_42" }, + // these are in REDUNDANT_LOG_DETAIL_ATTRS and should be suppressed + { name: "severity_number", type: "int", value: 9 }, + { name: "project.id", type: "str", value: "123" }, + ]; + const log = createDetailedTestLog(); + const result = stripAnsi( + formatLogDetails(log, "test-org", attrsWithRedundant) + ); + + expect(result).toContain("user.id"); + expect(result).not.toContain("severity_number"); + expect(result).not.toContain("project.id"); + }); + + test("extraFields limits which custom attributes are shown", () => { + const log = createDetailedTestLog(); + const result = stripAnsi( + formatLogDetails(log, "test-org", customAttrs, ["user.id"]) + ); + + expect(result).toContain("Custom Attributes"); + expect(result).toContain("user.id"); + expect(result).not.toContain("order.total"); + expect(result).not.toContain("retry.count"); + }); + + test("shows no Custom Attributes section when all are filtered by extraFields", () => { + const log = createDetailedTestLog(); + const result = stripAnsi( + formatLogDetails(log, "test-org", customAttrs, ["nonexistent.field"]) + ); + + expect(result).not.toContain("Custom Attributes"); + }); + + test("fallback: shows extraFields from log when allAttributes absent", () => { + const log = createDetailedTestLog({ + "user.id": "u_99", + } as DetailedSentryLog); + const result = stripAnsi( + formatLogDetails(log, "test-org", undefined, ["user.id"]) + ); + + expect(result).toContain("Custom Attributes"); + expect(result).toContain("user.id"); + expect(result).toContain("u_99"); + }); + + test("renders array attributes with JSON.stringify instead of [object Object]", () => { + const attrsWithArray: TraceItemAttribute[] = [ + { name: "tags", type: "array", value: ["prod", "web"] }, + { name: "ids", type: "array", value: [1, 2, 3] }, + ]; + const log = createDetailedTestLog(); + const result = stripAnsi( + formatLogDetails(log, "test-org", attrsWithArray) + ); + + expect(result).toContain('["prod","web"]'); + expect(result).toContain("[1,2,3]"); + expect(result).not.toContain("[object Object]"); + }); + + test("no Custom Attributes section when allAttributes is empty array", () => { + const log = createDetailedTestLog(); + const result = stripAnsi(formatLogDetails(log, "test-org", [])); + + expect(result).not.toContain("Custom Attributes"); + }); + }); }); describe("getLogId", () => {