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
185 changes: 172 additions & 13 deletions packages/cli/src/commands/issue/list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@

import type { SentryContext } from "../../context.js";
import { buildProjectAliasMap } from "../../lib/alias.js";
import {
fetchScopedCounts,
type ScopedCount,
} from "../../lib/api/issue-scoped-counts.js";
import {
API_MAX_PER_PAGE,
buildIssueListCollapse,
Expand Down Expand Up @@ -87,6 +91,7 @@ import {
import {
SEARCH_SYNTAX_REFERENCE,
sanitizeQuery,
unscopedCountFilterKeys,
} from "../../lib/search-query.js";
import { isSaaS } from "../../lib/sentry-web-urls.js";
import {
Expand Down Expand Up @@ -261,6 +266,166 @@ function buildListApiOptions(json: boolean, fields?: string[]): ListApiOptions {
};
}

/**
* Whether the seen-stats counts (`count`/`userCount`) are displayed for the
* given flags. Human output always shows them; JSON only omits them when
* `--fields` collapses lifetime fields away. Used to skip the scoped-count
* refetch when the counts aren't shown anyway. See #1518.
*/
function countsShown(json: boolean, fields?: string[]): boolean {
return !(json && shouldCollapseForFields(fields, LIFETIME_FIELDS));
}

/** Whether the issue ordering depends on `count`/`userCount`. */
function sortUsesCounts(sort: ListFlags["sort"]): boolean {
return sort === "freq" || sort === "user";
}

/**
* Whether a scoped-count refetch applies to the current flags: the query must
* filter on a count-unscoped key, and the counts must either be displayed or
* drive the `freq`/`user` ordering.
*/
function shouldScopeCounts(
flags: Pick<ListFlags, "query" | "json" | "fields" | "sort">,
): boolean {
return Boolean(
flags.query &&
(countsShown(flags.json, flags.fields) || sortUsesCounts(flags.sort)) &&
unscopedCountFilterKeys(flags.query).length > 0,
);
}
Comment thread
cursor[bot] marked this conversation as resolved.

/**
* Group display rows by org slug so each events query runs against the right
* organization (org-all / multi-project modes mix orgs). Rows without a short
* ID are skipped — they can't be scoped via `issue:`.
*/
function groupRowsByOrg(rows: IssueTableRow[]): Map<string, IssueTableRow[]> {
const byOrg = new Map<string, IssueTableRow[]>();
for (const row of rows) {
if (!row.issue.shortId) {
continue;
}
const group = byOrg.get(row.orgSlug);
if (group) {
group.push(row);
} else {
byOrg.set(row.orgSlug, [row]);
}
}
return byOrg;
}

/**
* Overwrite each row's issue counts from the per-org scoped-count maps.
* Returns true when at least one count changed.
*/
function overwriteScopedCounts(
groups: { orgRows: IssueTableRow[]; scoped: Map<string, ScopedCount> }[],
): boolean {
let changed = false;
for (const { orgRows, scoped } of groups) {
for (const row of orgRows) {
const s = scoped.get(row.issue.shortId);
if (s) {
row.issue.count = String(s.count);
row.issue.userCount = s.userCount;
changed = true;
}
}
}
return changed;
}

/**
* Replace each issue's unscoped list-endpoint counts (`count`/`userCount`) with
* filter-scoped counts from the events endpoint when the query filters on a key
* the issues-list seen-stats ignore (e.g. `release:`). The list endpoint reports
* period-wide totals across every value of that key, so a release-scoped triage
* silently over-reports; the events endpoint returns counts that match the
* filter. See getsentry/cli#1518.
*
* Best-effort: any failure leaves the original counts untouched. When the sort
* depends on counts (`freq`/`user`), the display is re-ranked to follow the
* corrected numbers. Mutates `result` in place.
*/
async function applyScopedCounts(
result: IssueListResult,
flags: Pick<ListFlags, "query" | "json" | "fields" | "sort">,
timeRange: TimeRange,
): Promise<void> {
try {
const rows = result.displayRows;
if (!(shouldScopeCounts(flags) && rows && rows.length > 0)) {
return;
}
const byOrg = groupRowsByOrg(rows);
if (byOrg.size === 0) {
return;
}

const timeParams = timeRangeToApiParams(timeRange);
const groups = await Promise.all(
[...byOrg.entries()].map(async ([orgSlug, orgRows]) => {
const scoped = await fetchScopedCounts(
orgSlug,
orgRows.map((row) => ({
shortId: row.issue.shortId,
projectId: parseProjectId(row.issue.project?.id),
})),
{
query: flags.query ?? "",
statsPeriod: timeParams.statsPeriod,
start: timeParams.start,
end: timeParams.end,
},
);
return { orgRows, scoped };
}),
);

// Re-rank when the ordering follows the counts we just corrected.
if (overwriteScopedCounts(groups) && sortUsesCounts(flags.sort)) {
const cmp = getComparator(flags.sort);
result.items.sort(cmp);
rows.sort((a, b) => cmp(a.issue, b.issue));
}
} catch (error) {
logger
.withTag("issue.list")
.debug(`Scoped-count refetch skipped: ${String(error)}`);
}
}

/** Coerce an optional project ID (string from the API) to a number. */
function parseProjectId(id: string | undefined): number | undefined {
if (id === undefined) {
return;
}
const n = Number(id);
return Number.isFinite(n) ? n : undefined;
}

/**
* Combine the framework footer hint from a result's more/footer parts. Only
* forwards a hint when items exist — empty results render their hint text
* inside {@link formatIssueListHuman}.
*/
function buildCombinedHint(result: IssueListResult): string | undefined {
if (result.items.length === 0) {
return;
}
const hintParts: string[] = [];
if (result.moreHint) {
hintParts.push(result.moreHint);
}
if (result.footer) {
hintParts.push(result.footer);
}
return hintParts.length > 0 ? hintParts.join("\n") : result.hint;
}

/**
* Resolve the effective compact mode from the flag tri-state and issue count.
*
Expand Down Expand Up @@ -1721,19 +1886,13 @@ export const listCommand = buildListCommand("issue", {
},
})) as IssueListResult;

// Only forward hints to the framework footer when items exist — empty
// results already render hint text inside formatIssueListHuman.
let combinedHint: string | undefined;
if (result.items.length > 0) {
const hintParts: string[] = [];
if (result.moreHint) {
hintParts.push(result.moreHint);
}
if (result.footer) {
hintParts.push(result.footer);
}
combinedHint = hintParts.length > 0 ? hintParts.join("\n") : result.hint;
}
// The issues-list endpoint's seen-stats (count/userCount) are period-wide
// totals that ignore query filters like release:. When such a filter is
// active, re-fetch filter-scoped counts from the events endpoint so the
// displayed numbers (and freq/user ordering) match the query. See #1518.
await applyScopedCounts(result, flags, timeRange);

const combinedHint = buildCombinedHint(result);

yield new CommandOutput(result);
return { hint: combinedHint };
Expand Down
7 changes: 7 additions & 0 deletions packages/cli/src/lib/api/explore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,12 @@ export type ExploreQueryOptions = {
start?: string;
/** Absolute end datetime (ISO-8601). Mutually exclusive with statsPeriod. */
end?: string;
/**
* Numeric project IDs to scope the query to. Passed as repeated `project`
* params. Avoids the "project not actively selected" error when querying a
* specific project's events.
*/
project?: string[];
};

/**
Expand Down Expand Up @@ -79,6 +85,7 @@ async function fetchEventsPage(
start: options.start,
end: options.end,
cursor: options.cursor,
project: options.project,
},
schema: EventsTableResponseSchema,
},
Expand Down
113 changes: 113 additions & 0 deletions packages/cli/src/lib/api/issue-scoped-counts.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
/**
* Scoped issue counts via the Sentry events endpoint.
*
* The issues-list endpoint's per-group seen-stats (`count`, `userCount`) honor
* the `statsPeriod` but are NOT scoped to arbitrary query filters like
* `release:` — they report period-wide totals across every value of the key.
* The events endpoint, in contrast, returns counts that actually match the
* filter. We re-query it per issue with `issue:<shortId>` AND the user's query
* to replace the over-reported numbers. See getsentry/cli#1518.
*/

import pLimit from "p-limit";
import { logger } from "../logger.js";
import { queryEvents } from "./explore.js";
import { ORG_FANOUT_CONCURRENCY } from "./infrastructure.js";

const log = logger.withTag("issue-scoped-counts");

/** Filter-scoped event and affected-user counts for a single issue. */
export type ScopedCount = {
count: number;
userCount: number;
};

/** Time range + project scope shared by scoped-count queries. */
type ScopedCountTimeOptions = {
statsPeriod?: string;
start?: string;
end?: string;
};

/**
* Fetch filter-scoped event & user counts for a single issue via the events
* endpoint. Returns `undefined` on any failure so the caller keeps the
* (unscoped) list-endpoint numbers rather than breaking the listing.
*
* @param orgSlug - Organization slug
* @param shortId - Issue short ID (scopes to the one group via `issue:`)
* @param options - The user's query plus time range and optional project ID
*/
export async function fetchScopedCount(
orgSlug: string,
shortId: string,
options: ScopedCountTimeOptions & {
/** The user's --query (already sanitized). */
query: string;
/** Numeric project ID to scope the events query to. */
projectId?: number;
},
): Promise<ScopedCount | undefined> {
const query = `issue:${shortId} ${options.query}`.trim();
try {
const response = await queryEvents(orgSlug, {
fields: ["count()", "count_unique(user)"],
dataset: "errors",
query,
statsPeriod: options.statsPeriod,
start: options.start,
end: options.end,
project:
options.projectId === undefined
? undefined
: [String(options.projectId)],
limit: 1,
});
const row = response.data.data[0];
if (!row) {
return;
}
return {
count: Number(row["count()"] ?? 0),
userCount: Number(row["count_unique(user)"] ?? 0),
};
} catch (error) {
log.debug(`Scoped count refetch failed for ${shortId}: ${String(error)}`);
return;
}
}

/**
* Fetch scoped counts for many issues in parallel (bounded concurrency),
* returning a Map keyed by issue short ID. Issues whose refetch fails are
* absent from the map so the caller keeps the unscoped fallback.
*/
export async function fetchScopedCounts(
orgSlug: string,
issues: { shortId: string; projectId?: number }[],
options: ScopedCountTimeOptions & { query: string },
): Promise<Map<string, ScopedCount>> {
const limit = pLimit(ORG_FANOUT_CONCURRENCY);
const results = await Promise.all(
issues.map((issue) =>
limit(async () => {
const scoped = await fetchScopedCount(orgSlug, issue.shortId, {
query: options.query,
statsPeriod: options.statsPeriod,
start: options.start,
end: options.end,
projectId: issue.projectId,
});
return { shortId: issue.shortId, scoped };
}),
),
);

const map = new Map<string, ScopedCount>();
for (const { shortId, scoped } of results) {
if (scoped) {
map.set(shortId, scoped);
}
}
return map;
}
Loading
Loading