Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions apps/cli-docs/src/fragments/commands/log.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,11 @@ sentry log list my-org/backend -f -q 'severity:error'

### View a log entry

Log ID lookups automatically narrow the search when a usable timestamp can be
determined from each ID. Otherwise, they use a 90-day lookup window. If a partial
scan leaves IDs missing, the CLI retries those IDs once with the highest
available scan accuracy.

```bash
sentry log view 968c763c740cfda8b6728f27fb9e9b01
```
Expand Down
102 changes: 85 additions & 17 deletions packages/cli/src/lib/api/logs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
TraceLogsResponseSchema,
} from "../../types/index.js";
import { ApiError } from "../errors.js";
import { decodeUuidV7Timestamp } from "../hex-id.js";
import { resolveOrgRegion } from "../region.js";
import { LOG_RETENTION_PERIOD } from "../retention.js";
import { isAllDigits } from "../utils.js";
Expand Down Expand Up @@ -261,6 +262,43 @@ const DETAILED_LOG_FIELDS = [
"sentry.otel.instrumentation_scope.name",
];

/** Matches the Sentry UI's window around a pinned log's UUIDv7 timestamp. */
const LOG_LOOKUP_MARGIN_MS = 5 * 60 * 1000;

// Match the UI's plausibility limits before trusting an ID as a time hint.
const MIN_LOG_LOOKUP_TIMESTAMP_MS = Date.UTC(2020, 0, 1);
const LOG_LOOKUP_FUTURE_TOLERANCE_MS = 24 * 60 * 60 * 1000;

/** Use the whole retention window if any ID has no plausible timestamp. */
function getLogLookupWindow(
logIds: string[],
): { start: string; end: string } | { statsPeriod: string } {
let earliest = Number.POSITIVE_INFINITY;
let latest = Number.NEGATIVE_INFINITY;
for (const id of logIds) {
const timestamp = decodeUuidV7Timestamp(id);
if (!timestamp) {
return { statsPeriod: LOG_RETENTION_PERIOD };
}
const time = timestamp.createdAt.getTime();
if (
time < MIN_LOG_LOOKUP_TIMESTAMP_MS ||
time > Date.now() + LOG_LOOKUP_FUTURE_TOLERANCE_MS
) {
return { statsPeriod: LOG_RETENTION_PERIOD };
}
earliest = Math.min(earliest, time);
latest = Math.max(latest, time);
}
if (logIds.length === 0) {
return { statsPeriod: LOG_RETENTION_PERIOD };
}
return {
start: new Date(earliest - LOG_LOOKUP_MARGIN_MS).toISOString(),
end: new Date(latest + LOG_LOOKUP_MARGIN_MS).toISOString(),
};
}

/**
* Fetch a single batch of log entries by their item IDs.
* Batch size must not exceed {@link API_MAX_PER_PAGE}.
Expand All @@ -284,7 +322,6 @@ async function getLogsBatch(
// `project:<slug>` filter (which only matches actively-selected projects).
const projectFilter =
numericProjectId === undefined ? `project:${projectSlug} ` : "";
const query = `${projectFilter}sentry.item_id:[${batchIds.join(",")}]`;

const fields = extraFields?.length
? [
Expand All @@ -293,26 +330,53 @@ async function getLogsBatch(
]
: DETAILED_LOG_FIELDS;

const result = await listOrganizationEvents({
...config,
path: { organization_id_or_slug: orgSlug },
query: {
dataset: "logs",
async function fetchBatch(
ids: string[],
sampling: "NORMAL" | "HIGHEST_ACCURACY",
) {
// `sampling` is supported by the backend but absent from the SDK's query type.
const queryParams = {
dataset: "logs" as const,
field: fields,
project: numericProjectId === undefined ? undefined : [numericProjectId],
query,
per_page: batchIds.length,
statsPeriod: LOG_RETENTION_PERIOD,
},
});
query: `${projectFilter}sentry.item_id:[${ids.join(",")}]`,
per_page: ids.length,
sampling,
...getLogLookupWindow(ids),
};
const result = await listOrganizationEvents({
...config,
path: { organization_id_or_slug: orgSlug },
query: queryParams,
});

const data = unwrapResult(result, "Failed to get log");
const logsResponse = safeParseResponse(
DetailedLogsResponseSchema,
data,
"Failed to get log",
return safeParseResponse(
DetailedLogsResponseSchema,
unwrapResult(result, "Failed to get log"),
"Failed to get log",
);
}

const response = await fetchBatch(batchIds, "NORMAL");
if (response.meta?.dataScanned !== "partial") {
return response.data;
}

const logsById = new Map(
response.data.map((log) => [log["sentry.item_id"], log]),
);
return logsResponse.data;
const missingIds = [...new Set(batchIds)].filter((id) => !logsById.has(id));
if (missingIds.length === 0) {
return response.data;
}

// Like Explore's empty partial-scan fallback, escalate only unresolved IDs.
// Do not escalate again: even HIGHEST_ACCURACY can return a partial scan.
const retry = await fetchBatch(missingIds, "HIGHEST_ACCURACY");
for (const log of retry.data) {
logsById.set(log["sentry.item_id"], log);
}
return [...logsById.values()];
}

/** Options for {@link getLogs}. */
Expand All @@ -333,6 +397,10 @@ type GetLogsOptions = {
*
* When more than {@link API_MAX_PER_PAGE} IDs are requested, the fetch is
* split into batches to avoid silent API truncation.
* UUIDv7 IDs with plausible timestamps bound each batch's search window to five
* minutes before/after its earliest/latest timestamp; other batches search the
* full log retention window. A partial scan with missing IDs gets one
* higher-accuracy retry for just those IDs.
*
* @param orgSlug - Organization slug
* @param projectSlug - Project slug for filtering
Expand Down
Loading
Loading