Skip to content
61 changes: 55 additions & 6 deletions src/commands/log/view.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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[];
};

/**
Expand All @@ -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");
}
Expand Down Expand Up @@ -496,19 +513,51 @@ 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);
}

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 };
},
});
1 change: 1 addition & 0 deletions src/lib/api-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ export {
updateIssueStatus,
} from "./api/issues.js";
export {
getLogItemDetail,
getLogs,
type LogSortDirection,
listLogs,
Expand Down
60 changes: 53 additions & 7 deletions src/lib/api/logs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<ReturnType<typeof getOrgSdkConfig>>;
extraFields?: string[];
};

async function getLogsBatch(
orgSlug: string,
projectSlug: string,
batchIds: string[],
config: Awaited<ReturnType<typeof getOrgSdkConfig>>
{ config, extraFields }: GetLogsBatchOptions
): Promise<DetailedSentryLog[]> {
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,
Expand Down Expand Up @@ -199,13 +211,14 @@ async function getLogsBatch(
export async function getLogs(
orgSlug: string,
projectSlug: string,
logIds: string[]
logIds: string[],
extraFields?: string[]
): Promise<DetailedSentryLog[]> {
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
Expand All @@ -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();
Expand Down Expand Up @@ -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<TraceItemDetail> {
return getTraceItemDetail(orgSlug, projectSlug, logId, {
traceId,
itemType: "logs",
});
}
Comment thread
cursor[bot] marked this conversation as resolved.
77 changes: 46 additions & 31 deletions src/lib/api/traces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import {
type SpanListItem,
type SpansResponse,
SpansResponseSchema,
type TraceItemDetail,
TraceItemDetailSchema,
type TraceMeta,
TraceMetaSchema,
type TraceSpan,
Expand All @@ -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";
Expand Down Expand Up @@ -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<string, unknown>;
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 = {
Expand Down Expand Up @@ -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<TraceItemDetail> {
const regionUrl = await resolveOrgRegion(orgSlug);

const { data } = await apiRequestToRegion<TraceItemDetail>(
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<TraceItemDetail> {
return getTraceItemDetail(orgSlug, projectSlug, spanId, {
traceId,
itemType: "spans",
});
}

/**
* Fetch high-level metadata for a trace.
*
Expand Down
Loading
Loading