From ee0800694d501ff0596f7cc71bde42725e8cfdb2 Mon Sep 17 00:00:00 2001 From: lzhao-sentry Date: Tue, 20 Oct 2026 11:22:32 -0400 Subject: [PATCH 01/10] feat(cli): add attribute list command --- .../cli-docs/src/content/docs/contributing.md | 1 + .../src/fragments/commands/attribute.md | 28 + .../sentry-cli/skills/sentry-cli/SKILL.md | 8 + .../skills/sentry-cli/references/attribute.md | 53 ++ packages/cli/src/app.ts | 6 + packages/cli/src/commands/attribute/index.ts | 17 + packages/cli/src/commands/attribute/list.ts | 440 ++++++++++++++ packages/cli/src/lib/api-client.ts | 6 + packages/cli/src/lib/api/traces.ts | 119 ++++ packages/cli/src/lib/formatters/attribute.ts | 137 +++++ .../cli/test/commands/attribute/list.test.ts | 546 ++++++++++++++++++ .../lib/api/trace-item-attributes.test.ts | 150 +++++ 12 files changed, 1511 insertions(+) create mode 100644 apps/cli-docs/src/fragments/commands/attribute.md create mode 100644 packages/cli/plugins/sentry-cli/skills/sentry-cli/references/attribute.md create mode 100644 packages/cli/src/commands/attribute/index.ts create mode 100644 packages/cli/src/commands/attribute/list.ts create mode 100644 packages/cli/src/lib/formatters/attribute.ts create mode 100644 packages/cli/test/commands/attribute/list.test.ts create mode 100644 packages/cli/test/lib/api/trace-item-attributes.test.ts diff --git a/apps/cli-docs/src/content/docs/contributing.md b/apps/cli-docs/src/content/docs/contributing.md index 86c950e94..6741441cf 100644 --- a/apps/cli-docs/src/content/docs/contributing.md +++ b/apps/cli-docs/src/content/docs/contributing.md @@ -57,6 +57,7 @@ toolkit/ │ │ ├── commands/ # CLI commands │ │ │ ├── agent-conversation/# list, view │ │ │ ├── alert/ # create, delete, edit, list, view +│ │ │ ├── attribute/ # list │ │ │ ├── auth/ # list, login, logout, refresh, status, switch, token, whoami │ │ │ ├── build/ # download, upload │ │ │ ├── cli/ # completion, defaults, feedback, fix, import, setup, uninstall, upgrade diff --git a/apps/cli-docs/src/fragments/commands/attribute.md b/apps/cli-docs/src/fragments/commands/attribute.md new file mode 100644 index 000000000..09826d314 --- /dev/null +++ b/apps/cli-docs/src/fragments/commands/attribute.md @@ -0,0 +1,28 @@ + + +## Examples + +### List attributes + +```bash +# List span attribute keys for a project +sentry attribute list my-org/my-project + +# List log attribute keys across the whole organization +sentry attribute list my-org/ --dataset logs + +# Find keys containing "http" that are also of type string +sentry attribute list --search http --type string + +# Attributes seen on the same spans as the specified attribute +sentry attribute list --with gen_ai.request.model + +# Add descriptions, example values, and deprecation info +sentry attribute list --search http --context + +# Show more than the default 25 +sentry attribute list my-org/my-project -n 100 + +# Select fields as JSON +sentry attribute list --json --fields key,type,source +``` diff --git a/packages/cli/plugins/sentry-cli/skills/sentry-cli/SKILL.md b/packages/cli/plugins/sentry-cli/skills/sentry-cli/SKILL.md index b98a64094..e243bdc72 100644 --- a/packages/cli/plugins/sentry-cli/skills/sentry-cli/SKILL.md +++ b/packages/cli/plugins/sentry-cli/skills/sentry-cli/SKILL.md @@ -463,6 +463,14 @@ Manage Sentry alert rules → Full flags and examples: `references/alert.md` +### Attribute + +Explore span, log, and metric attributes + +- `sentry attribute list []` — List attribute keys for spans, logs, or metrics + +→ Full flags and examples: `references/attribute.md` + ### Build Manage mobile build artifacts diff --git a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/attribute.md b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/attribute.md new file mode 100644 index 000000000..6b6814a8d --- /dev/null +++ b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/attribute.md @@ -0,0 +1,53 @@ +--- +name: sentry-cli-attribute +version: 0.48.0-dev.0 +description: Explore span, log, and metric attributes +requires: + bins: ["sentry"] + auth: true +--- + +# Attribute Commands + +Explore span, log, and metric attributes + +### `sentry attribute list []` + +List attribute keys for spans, logs, or metrics + +**Flags:** +- `-d, --dataset - Dataset to list attributes for (spans, logs, metrics) - (default: "spans")` +- `--type ... - Find attributes of these types (string, number, boolean, array; repeatable, comma-separated)` +- `-s, --search - Find attribute keys containing this text (case-sensitive)` +- `--with ... - Find attributes that appear on the same items as all of these keys (repeatable, comma-separated)` +- `-t, --period - Time range: "7d", "2024-01-01..2024-02-01", ">=2024-01-01" - (default: "14d")` +- `-n, --limit - Maximum number of attributes to list - (default: "25")` +- `--context - Include each attribute's description, examples, and deprecation info (experimental API)` +- `-f, --fresh - Bypass cache, re-detect projects, and fetch fresh data` + +**Examples:** + +```bash +# List span attribute keys for a project +sentry attribute list my-org/my-project + +# List log attribute keys across the whole organization +sentry attribute list my-org/ --dataset logs + +# Find keys containing "http" that are also of type string +sentry attribute list --search http --type string + +# Attributes seen on the same spans as the specified attribute +sentry attribute list --with gen_ai.request.model + +# Add descriptions, example values, and deprecation info +sentry attribute list --search http --context + +# Show more than the default 25 +sentry attribute list my-org/my-project -n 100 + +# Select fields as JSON +sentry attribute list --json --fields key,type,source +``` + +All commands also support `--json`, `--fields`, `--help`, `--log-level`, and `--verbose` flags. diff --git a/packages/cli/src/app.ts b/packages/cli/src/app.ts index 49002ebcc..5e696cbd1 100644 --- a/packages/cli/src/app.ts +++ b/packages/cli/src/app.ts @@ -9,6 +9,8 @@ import { conversationRoute } from "./commands/agent-conversation/index.js"; import { listCommand as conversationListCommand } from "./commands/agent-conversation/list.js"; import { alertRoute } from "./commands/alert/index.js"; import { apiCommand } from "./commands/api.js"; +import { attributeRoute } from "./commands/attribute/index.js"; +import { listCommand as attributeListCommand } from "./commands/attribute/list.js"; import { authRoute } from "./commands/auth/index.js"; import { whoamiCommand } from "./commands/auth/whoami.js"; import { bashHookCommand } from "./commands/bash-hook.js"; @@ -96,6 +98,7 @@ import { buildRouteMap } from "./lib/route-map.js"; */ const PLURAL_TO_SINGULAR: Record = { "agent-conversations": "agent-conversation", + attributes: "attribute", dashboards: "dashboard", dsns: "dsn", events: "event", @@ -121,6 +124,7 @@ export const routes = buildRouteMap({ routes: { help: helpCommand, alert: alertRoute, + attribute: attributeRoute, auth: authRoute, build: buildRoute, cli: cliRoute, @@ -167,6 +171,7 @@ export const routes = buildRouteMap({ "send-envelope": sendEnvelopeCommand, "bash-hook": bashHookCommand, "agent-conversations": conversationListCommand, + attributes: attributeListCommand, dashboards: dashboardListCommand, dsns: dsnListCommand, integrations: integrationListCommand, @@ -194,6 +199,7 @@ export const routes = buildRouteMap({ hideRoute: { dsns: true, "agent-conversations": true, + attributes: true, dashboards: true, events: true, integrations: true, diff --git a/packages/cli/src/commands/attribute/index.ts b/packages/cli/src/commands/attribute/index.ts new file mode 100644 index 000000000..841265592 --- /dev/null +++ b/packages/cli/src/commands/attribute/index.ts @@ -0,0 +1,17 @@ +/** Trace item attribute commands. */ + +import { buildRouteMap } from "../../lib/route-map.js"; +import { listCommand } from "./list.js"; + +export const attributeRoute = buildRouteMap({ + routes: { list: listCommand }, + docs: { + brief: "Explore span, log, and metric attributes", + fullDescription: + "List the attribute keys available on spans, logs, and trace metrics.\n\n" + + "Commands:\n" + + " list List attribute keys with their type and source\n\n" + + "Alias: `sentry attributes` → `sentry attribute list`", + hideRoute: {}, + }, +}); diff --git a/packages/cli/src/commands/attribute/list.ts b/packages/cli/src/commands/attribute/list.ts new file mode 100644 index 000000000..a8d60ddfe --- /dev/null +++ b/packages/cli/src/commands/attribute/list.ts @@ -0,0 +1,440 @@ +/** + * sentry attribute list + * + * List the attribute keys available on a trace item dataset (spans, logs, + * or metrics) + */ + +import type { SentryContext } from "../../context.js"; +import type { + TraceItemAttributeDataset, + TraceItemAttributeDefinition, + TraceItemAttributeValueType, +} from "../../lib/api/traces.js"; +import { listTraceItemAttributes } from "../../lib/api-client.js"; +import { validateLimit } from "../../lib/arg-parsing.js"; +import { + ApiError, + isSearchQueryParseError, + ValidationError, +} from "../../lib/errors.js"; +import { + type AttributeListItem, + formatAttributeList, + toAttributeListItem, +} from "../../lib/formatters/attribute.js"; +import { CommandOutput } from "../../lib/formatters/output.js"; +import { + buildListCommand, + buildListLimitFlag, + LIST_MAX_LIMIT, + LIST_MIN_LIMIT, + LIST_TARGET_POSITIONAL, + PERIOD_ALIASES, +} from "../../lib/list-command.js"; +import { + jsonTransformListResult, + type ListResult, +} from "../../lib/org-list.js"; +import { withProgress } from "../../lib/polling.js"; +import { resolveOrgOptionalFromArg } from "../../lib/resolve-target.js"; +import { + PERIOD_BRIEF, + parsePeriod, + type TimeRange, + timeRangeToApiParams, +} from "../../lib/time-range.js"; + +const COMMAND_NAME = "attribute list"; + +/** Default dataset when `--dataset` is omitted */ +const DEFAULT_DATASET = "spans"; + +/** Default window — matches the MCP server's attribute discovery. */ +const DEFAULT_PERIOD = "14d"; + +/** User-facing dataset names (and aliases) that map to API dataset names. */ +const DATASET_ALIASES: Record = { + spans: "spans", + span: "spans", + logs: "logs", + log: "logs", + metrics: "tracemetrics", + metric: "tracemetrics", + tracemetrics: "tracemetrics", +}; + +/** Dataset names shown in help text and validation errors. */ +const VALID_DATASETS = ["spans", "logs", "metrics"] as const; + +/** API dataset name → canonical user-facing name, for hints. */ +const API_TO_USER_DATASET: Record = { + spans: "spans", + logs: "logs", + tracemetrics: "metrics", +}; + +/** Attribute value types accepted by `--type`. */ +const VALID_TYPES: readonly TraceItemAttributeValueType[] = [ + "string", + "number", + "boolean", + "array", +]; + +type AttributeListFlags = { + readonly dataset: TraceItemAttributeDataset; + readonly type?: readonly string[]; + readonly search?: string; + readonly with?: readonly string[]; + readonly period: TimeRange; + readonly context: boolean; + readonly limit: number; + readonly json: boolean; + readonly fresh: boolean; + readonly fields?: string[]; +}; + +/** + * Parse and validate `--dataset`. Accepts canonical names and singular aliases. + * @internal Exported for testing + */ +export function parseDataset(value: string): TraceItemAttributeDataset { + const resolved = DATASET_ALIASES[value.trim().toLowerCase()]; + if (!resolved) { + throw new ValidationError( + `Invalid dataset "${value}". Must be one of: ${VALID_DATASETS.join(", ")}`, + "dataset", + ); + } + return resolved; +} + +/** Characters that cannot appear in an attribute key passed to `--with`. */ +const INVALID_WITH_KEY = /[\s"'()]/; + +/** + * Split a comma-separated key list, keeping commas inside brackets so typed + * keys like `tags[cart.total,number]` stay whole. + */ +function splitKeyList(value: string): string[] { + const parts: string[] = []; + let depth = 0; + let current = ""; + for (const char of value) { + if (char === "[") { + depth += 1; + } else if (char === "]" && depth > 0) { + depth -= 1; + } else if (char === "," && depth === 0) { + parts.push(current); + current = ""; + continue; + } + current += char; + } + parts.push(current); + return parts; +} + +/** + * Normalize repeated and/or comma-separated `--with` values into a + * deduplicated list of attribute keys. + * @internal Exported for testing + */ +export function parseWithKeys( + values: readonly string[] | undefined, +): string[] | undefined { + if (!values?.length) { + return; + } + const keys = new Set(); + for (const raw of values.flatMap(splitKeyList)) { + const key = raw.trim(); + if (!key) { + continue; + } + if (INVALID_WITH_KEY.test(key)) { + throw new ValidationError( + `Invalid attribute key "${key}" for --with. Pass keys as listed by this command, e.g. http.method or tags[cart.total,number].`, + "with", + ); + } + keys.add(key); + } + return keys.size > 0 ? [...keys] : undefined; +} + +/** + * Build the API `query` for `--with`. The attributes endpoint only uses the + * key names in its query (values are ignored) and returns keys that co-occur + * with all of them, so `has:` filters state exactly what it does. + * @internal Exported for testing + */ +export function buildWithQuery(keys: readonly string[]): string { + return keys.map((key) => `has:${key}`).join(" "); +} + +/** + * Normalize repeated and/or comma-separated `--type` values into a + * deduplicated list of attribute types. + * @internal Exported for testing + */ +export function parseAttributeTypes( + values: readonly string[] | undefined, +): TraceItemAttributeValueType[] | undefined { + if (!values?.length) { + return; + } + const types = new Set(); + for (const raw of values.flatMap((v) => v.split(","))) { + const value = raw.trim().toLowerCase(); + if (!value) { + continue; + } + if (!VALID_TYPES.includes(value as TraceItemAttributeValueType)) { + throw new ValidationError( + `Invalid attribute type "${raw.trim()}". Must be one of: ${VALID_TYPES.join(", ")}`, + "type", + ); + } + types.add(value as TraceItemAttributeValueType); + } + return types.size > 0 ? [...types] : undefined; +} + +/** + * Order a page by display name, then type. The API concatenates one result + * set per attribute type, so without sorting a trimmed page would show only + * the first type's keys. + * @internal Exported for testing + */ +export function compareAttributes( + a: TraceItemAttributeDefinition, + b: TraceItemAttributeDefinition, +): number { + return ( + (a.name || a.key).localeCompare(b.name || b.key) || + a.attributeType.localeCompare(b.attributeType) + ); +} + +/** Shell-quote a key for a copyable command (zsh globs `tags[...]`). */ +function shellKey(key: string): string { + return /^[\w.:-]+$/.test(key) ? key : `'${key}'`; +} + +/** + * Build a `Tip:` line showing how to use a attribute keys in other commands. + * @internal Exported for testing + */ +export function buildUsageTip( + dataset: TraceItemAttributeDataset, + org: string, + project: string | undefined, + items: readonly AttributeListItem[], +): string | undefined { + const example = items.find((item) => item.source === "user") ?? items[0]; + if (!example) { + return; + } + const { key } = example; + if (dataset === "tracemetrics") { + return `Tip: Group or filter metrics with 'sentry explore ${org}/ --dataset metrics' (-F to group, -q to filter)`; + } + const command = dataset === "logs" ? "log list" : "span list"; + const target = project ? `${org}/${project}` : `${org}/`; + return `Tip: Filter or add to columns with: sentry ${command} ${target} -q "has:${key}" --fields ${shellKey(key)}`; +} + +/** + * Keep at most `limit` rows per attribute type, in the server's order. + * + * Sentry's paginator fetches `limit + 1` rows per type to detect another page + * and returns them all. The extra row only signals that more exist, so drop + * it; otherwise a full single-type page looks trimmed and the counts are off. + * @internal Exported for testing + */ +export function dropLookAheadRows( + data: readonly TraceItemAttributeDefinition[], + limit: number, +): TraceItemAttributeDefinition[] { + const seenPerType = new Map(); + return data.filter((attribute) => { + const seen = seenPerType.get(attribute.attributeType) ?? 0; + seenPerType.set(attribute.attributeType, seen + 1); + return seen < limit; + }); +} + +export const listCommand = buildListCommand( + "attribute", + { + docs: { + brief: "List attribute keys for spans, logs, or metrics", + fullDescription: + "List the attribute keys available on a trace item dataset\n" + + "Target specification:\n" + + " sentry attribute list # auto-detect org from DSN or config\n" + + " sentry attribute list / # all projects in the organization\n" + + " sentry attribute list / # one project\n" + + " sentry attribute list # find project across all orgs\n\n" + + "Examples:\n" + + " sentry attribute list --dataset logs --with http.method # Attributes seen on logs with http.method\n" + + " sentry attribute list --type string --search http # Attributes containing http and are type string\n" + + " sentry attribute list --period 24h # Get attributes that appeared in the last 24 hours\n" + + " sentry attribute list --context # Display attribute metadata information\n" + + " sentry attribute list --json --fields key,type # Output in JSON format with specified fields\n\n" + + "Alias: `sentry attributes` → `sentry attribute list`", + }, + output: { + human: formatAttributeList, + jsonTransform: ( + result: ListResult, + fields?: string[], + ) => jsonTransformListResult(result, fields), + }, + parameters: { + positional: LIST_TARGET_POSITIONAL, + flags: { + dataset: { + kind: "parsed", + parse: parseDataset, + brief: `Dataset to list attributes for (${VALID_DATASETS.join(", ")})`, + default: DEFAULT_DATASET, + }, + type: { + kind: "parsed", + parse: String, + brief: `Find attributes of these types (${VALID_TYPES.join(", ")}; repeatable, comma-separated)`, + variadic: true, + optional: true, + }, + search: { + kind: "parsed", + parse: String, + brief: "Find attribute keys containing this text (case-sensitive)", + optional: true, + }, + with: { + kind: "parsed", + parse: String, + brief: + "Find attributes that appear on the same items as all of these keys (repeatable, comma-separated)", + variadic: true, + optional: true, + }, + period: { + kind: "parsed", + parse: parsePeriod, + brief: PERIOD_BRIEF, + default: DEFAULT_PERIOD, + }, + limit: { + ...buildListLimitFlag("attributes"), + parse: (value: string) => + validateLimit(value, LIST_MIN_LIMIT, LIST_MAX_LIMIT), + }, + context: { + kind: "boolean", + brief: + "Include each attribute's description, examples, and deprecation info (experimental API)", + default: false, + }, + }, + aliases: { + ...PERIOD_ALIASES, + d: "dataset", + n: "limit", + s: "search", + }, + }, + async *func( + this: SentryContext, + flags: AttributeListFlags, + target?: string, + ) { + const types = parseAttributeTypes(flags.type); + const withKeys = parseWithKeys(flags.with); + const { org, project } = await resolveOrgOptionalFromArg( + target, + this.cwd, + COMMAND_NAME, + ); + const scope = project ? `${org}/${project}` : `${org}/`; + + const { data, nextCursor } = await withProgress( + { message: "Fetching attributes...", json: flags.json }, + () => + listTraceItemAttributes(org, { + dataset: flags.dataset, + project: project ? [project] : undefined, + attributeTypes: types, + substringMatch: flags.search, + query: withKeys ? buildWithQuery(withKeys) : undefined, + context: flags.context, + perPage: flags.limit, + ...timeRangeToApiParams(flags.period), + }).catch((error: unknown): never => { + // A key Sentry can't parse is a user input mistake, not a CLI bug. + if ( + withKeys && + error instanceof ApiError && + isSearchQueryParseError(error) + ) { + throw new ValidationError( + `Sentry rejected --with ${withKeys.join(",")}${error.detail ? `: ${error.detail}` : ""}`, + "with", + ); + } + throw error; + }), + ); + + // The API returns up to --limit + 1 rows *per type* (the extra row only + // signals more). Drop those look-ahead rows, then show the first --limit + // by name: without --type, a response holds one page per type and can + // exceed --limit. + const sorted = dropLookAheadRows(data, flags.limit).sort( + compareAttributes, + ); + const trimmed = sorted.length > flags.limit; + const items = sorted.slice(0, flags.limit).map(toAttributeListItem); + const hasMore = trimmed || !!nextCursor; + + const datasetLabel = API_TO_USER_DATASET[flags.dataset] ?? flags.dataset; + const plural = (n: number) => `attribute${n === 1 ? "" : "s"}`; + const higherLimit = Math.min(flags.limit * 2, LIST_MAX_LIMIT); + const more = + higherLimit > flags.limit + ? `Use -n ${higherLimit} for more, or narrow with --type or --search.` + : "Narrow with --type or --search to see the rest."; + let countText: string | undefined; + if (trimmed) { + const total = `${sorted.length}${nextCursor ? "+" : ""}`; + countText = `Showing ${items.length} of ${total} ${datasetLabel} ${plural(sorted.length)} in ${scope}. ${more}`; + } else if (nextCursor) { + countText = `Showing ${items.length} ${datasetLabel} ${plural(items.length)} in ${scope}; more are available. ${more}`; + } else if (items.length > 0) { + countText = `Showing ${items.length} ${datasetLabel} ${plural(items.length)} in ${scope}.`; + } + + const result: ListResult = { + items, + hasMore, + hint: + [countText, buildUsageTip(flags.dataset, org, project, items)] + .filter(Boolean) + .join("\n") || undefined, + }; + yield new CommandOutput(result); + return { hint: result.hint }; + }, + }, + // No --cursor / -c: pagination for now. Instead this command makes a single request + // (up to --limit, max 1000) and users narrow using --type/--search/--with. + // The per_page parameter is per attribute type, so a single request can return more + // than --limit rows. The API can also append extra internal attributes, which can + // cause the results to exceed --limit as well + { noCursorFlag: true }, +); diff --git a/packages/cli/src/lib/api-client.ts b/packages/cli/src/lib/api-client.ts index 3a4efd603..708b6ea37 100644 --- a/packages/cli/src/lib/api-client.ts +++ b/packages/cli/src/lib/api-client.ts @@ -202,7 +202,12 @@ export { } from "./api/teams.js"; export type { FetchMultiSpanDetailsOptions, + ListTraceItemAttributesOptions, TraceItemAttribute, + TraceItemAttributeContext, + TraceItemAttributeDataset, + TraceItemAttributeDefinition, + TraceItemAttributeValueType, TraceItemDetail, } from "./api/traces.js"; export { @@ -212,6 +217,7 @@ export { getSpanDetails, getTraceMeta, listSpans, + listTraceItemAttributes, listTransactions, normalizeTraceSpan, REDUNDANT_DETAIL_ATTRS, diff --git a/packages/cli/src/lib/api/traces.ts b/packages/cli/src/lib/api/traces.ts index a3193befd..574938753 100644 --- a/packages/cli/src/lib/api/traces.ts +++ b/packages/cli/src/lib/api/traces.ts @@ -4,6 +4,11 @@ * Functions for retrieving detailed traces, listing transactions, and listing spans. */ +import { + type ListOrganizationTraceItemAttributesData, + type ListOrganizationTraceItemAttributesResponse, + listOrganizationTraceItemAttributes, +} from "@sentry/api"; import pLimit from "p-limit"; import { @@ -30,9 +35,11 @@ import { isAllDigits } from "../utils.js"; import { API_MAX_PER_PAGE, apiRequestToRegion, + getOrgSdkConfig, type PaginatedResponse, paginate, parseLinkHeader, + unwrapPaginatedResult, } from "./infrastructure.js"; const log = logger.withTag("api.traces"); @@ -243,6 +250,118 @@ export function getSpanDetails( }); } +// --------------------------------------------------------------------------- +// Trace item attribute discovery +// --------------------------------------------------------------------------- + +type TraceItemAttributesQuery = NonNullable< + ListOrganizationTraceItemAttributesData["query"] +>; + +/** Trace item dataset accepted by the attributes endpoint (e.g. "spans", "logs"). */ +export type TraceItemAttributeDataset = NonNullable< + TraceItemAttributesQuery["dataset"] +>; + +/** Attribute value type the attributes endpoint can filter on. */ +export type TraceItemAttributeValueType = NonNullable< + TraceItemAttributesQuery["attributeType"] +>[number]; + +/** + * An attribute key available on a trace item dataset (name, type, and whether + * it is Sentry-defined or user-defined). This describes a key, not to be confused + * with {@link TraceItemAttribute}, which is a key/value pair + */ +export type TraceItemAttributeDefinition = + ListOrganizationTraceItemAttributesResponse[number]; + +/** Metadata returned for an attribute when `context` is requested. */ +export type TraceItemAttributeContext = NonNullable< + TraceItemAttributeDefinition["context"] +>; + +/** Options for {@link listTraceItemAttributes} */ +export type ListTraceItemAttributesOptions = { + /** Dataset to list attribute keys for */ + dataset: TraceItemAttributeDataset; + /** Project IDs or slugs to scope to (omit for all accessible projects) */ + project?: Array; + /** Environment names to scope to */ + environment?: string[]; + /** Only return attributes of these value types (filtered server-side) */ + attributeTypes?: TraceItemAttributeValueType[]; + /** Only return attribute names containing this substring (case-sensitive) */ + substringMatch?: string; + /** + * Sentry search query. The result is limited to keys that co-occur + * with all of those keys. `has:` filters express this exactly. + */ + query?: string; + /** Relative time window, e.g. "24h" or "14d" (overrides start/end) */ + statsPeriod?: string; + /** Absolute window start (ISO-8601) */ + start?: string; + /** Absolute window end (ISO-8601) */ + end?: string; + /** + * Page size, sent as `per_page` Applied per attribute type, so total + * results can exceed this value + */ + perPage?: number; + /** + * Include each attribute's `context` metadata (description, examples, + * deprecation) + */ + context?: boolean; +}; + +/** + * List the attribute keys available on a trace item dataset via + * `GET /organizations/{org}/trace-items/attributes/`. + * + * Empty filters are omitted rather than sent as empty params, and the + * next-page cursor is parsed from the Link header. + * + * ({@link getTraceItemDetail}) for the values on a specific item. + * + * @param orgSlug - Organization slug + * @param options - Dataset plus optional project, type, substring, query, and time filters + * @returns Attribute definitions and an optional next-page cursor + */ +export async function listTraceItemAttributes( + orgSlug: string, + options: ListTraceItemAttributesOptions, +): Promise> { + const config = await getOrgSdkConfig(orgSlug); + // `per_page` is accepted by the endpoint's paginator but missing from the + // SDK's generated query type, so widen the type rather than cast the call. + const query: TraceItemAttributesQuery & { per_page?: number } = { + dataset: options.dataset, + project: options.project?.length ? options.project : undefined, + environment: options.environment?.length ? options.environment : undefined, + attributeType: options.attributeTypes?.length + ? options.attributeTypes + : undefined, + substringMatch: options.substringMatch || undefined, + query: options.query || undefined, + statsPeriod: options.statsPeriod, + start: options.start, + end: options.end, + expand: options.context ? ["context"] : undefined, + per_page: options.perPage, + }; + const result = await listOrganizationTraceItemAttributes({ + ...config, + path: { organization_id_or_slug: orgSlug }, + query, + }); + return unwrapPaginatedResult( + result, + `Failed to list ${options.dataset} attributes`, + ); +} + /** * Fetch high-level metadata for a trace. * diff --git a/packages/cli/src/lib/formatters/attribute.ts b/packages/cli/src/lib/formatters/attribute.ts new file mode 100644 index 000000000..65cbba310 --- /dev/null +++ b/packages/cli/src/lib/formatters/attribute.ts @@ -0,0 +1,137 @@ +/** + * Attribute-specific formatters + * + * Provides formatting utilities for displaying Sentry attributes in the CLI. + */ + +import type { + TraceItemAttributeContext, + TraceItemAttributeDefinition, +} from "../api/traces.js"; +import type { ListResult } from "../org-list.js"; +import { escapeMarkdownCell } from "./markdown.js"; +import { type Column, formatTable } from "./table.js"; + +/** + * One attribute key, flattened from the API's attribute definition so that + * `--fields` can select `key`, `type`, or `source` directly. + */ +export type AttributeListItem = { + /** + * Exact key to use in `--query` and `--fields`. Custom non-string + * attributes use Sentry's typed form, e.g. "tags[cart.total,number]". + */ + key: string; + /** Display name without the `tags[...]` wrapper, e.g. "cart.total" */ + name: string; + /** Value type: "string", "number", "boolean", or "array" */ + type: TraceItemAttributeDefinition["attributeType"]; + /** "sentry" for SDK/convention attributes, "user" for custom attributes */ + source: TraceItemAttributeDefinition["attributeSource"]["source_type"]; + /** Other names that resolve to this attribute, when the API reports any */ + aliases?: string[]; + /** + * Description, examples, and deprecation info. Present only when requested + * with `--context` (may be `{}` when the API has no metadata for the key). + */ + context?: TraceItemAttributeContext; +}; + +/** Flatten an API attribute definition into an {@link AttributeListItem}. */ +export function toAttributeListItem( + attribute: TraceItemAttributeDefinition, +): AttributeListItem { + const item: AttributeListItem = { + key: attribute.key, + name: attribute.name, + type: attribute.attributeType, + source: attribute.attributeSource.source_type, + }; + if (attribute.secondaryAliases?.length) { + item.aliases = attribute.secondaryAliases; + } + if (attribute.context) { + item.context = attribute.context; + } + return item; +} + +/** Max example values shown per row in human output. */ +const MAX_EXAMPLES = 3; + +/** + * One-line description for human output: a deprecation note (with the + * replacement key, when known) followed by the brief. + * @internal Exported for testing + */ +export function describeAttribute(context: TraceItemAttributeContext): string { + const parts: string[] = []; + if (context.isDeprecated) { + parts.push( + context.replacementAttribute + ? `Deprecated, use ${context.replacementAttribute}.` + : "Deprecated.", + ); + } + if (context.brief) { + parts.push(context.brief); + } + return parts.join(" "); +} + +const KEY_COLUMNS: Column[] = [ + { + // The exact key, not the display name: custom non-string attributes only + // work in --query/--fields in their typed form, e.g. tags[cart.total,number]. + header: "KEY", + value: (item) => escapeMarkdownCell(item.key), + minWidth: 24, + }, + { header: "TYPE", value: (item) => item.type, minWidth: 7 }, + { header: "SOURCE", value: (item) => item.source, minWidth: 6 }, +]; + +const ALIASES_COLUMN: Column = { + header: "ALIASES", + value: (item) => escapeMarkdownCell(item.aliases?.join(", ") ?? ""), +}; + +const COLUMNS: Column[] = [...KEY_COLUMNS, ALIASES_COLUMN]; + +/** Columns used with `--context`: aliases move after the metadata. */ +const CONTEXT_COLUMNS: Column[] = [ + ...KEY_COLUMNS, + { + header: "DESCRIPTION", + value: (item) => + escapeMarkdownCell(item.context ? describeAttribute(item.context) : ""), + minWidth: 20, + }, + { + header: "EXAMPLES", + value: (item) => + escapeMarkdownCell( + (item.context?.examples ?? []) + .slice(0, MAX_EXAMPLES) + .map(String) + .join(", "), + ), + truncate: true, + }, + ALIASES_COLUMN, +]; + +/** Render a page of attribute keys as a table. */ +export function formatAttributeList( + result: ListResult, +): string { + if (result.items.length === 0) { + return "No attributes found."; + } + const withContext = result.items.some((item) => item.context !== undefined); + const table = formatTable( + result.items, + withContext ? CONTEXT_COLUMNS : COLUMNS, + ); + return result.header ? `${result.header}\n\n${table}` : table; +} diff --git a/packages/cli/test/commands/attribute/list.test.ts b/packages/cli/test/commands/attribute/list.test.ts new file mode 100644 index 000000000..998c083d3 --- /dev/null +++ b/packages/cli/test/commands/attribute/list.test.ts @@ -0,0 +1,546 @@ +/** + * Attribute List Command Tests + * + * Tests for `sentry attribute list` in src/commands/attribute/list.ts: + * flag parsing, the API call it makes, JSON/human output, display names, + * --limit trimming, --context, and footer hints. + */ + +import { afterEach, describe, expect, test, vi } from "vitest"; +import { + buildUsageTip, + buildWithQuery, + compareAttributes, + dropLookAheadRows, + listCommand, + parseAttributeTypes, + parseDataset, + parseWithKeys, +} from "../../../src/commands/attribute/list.js"; +import { + describeAttribute, + toAttributeListItem, +} from "../../../src/lib/formatters/attribute.js"; + +vi.mock("../../../src/lib/api-client.js", async (importOriginal) => { + const actual = + await importOriginal(); + return Object.fromEntries( + Object.entries(actual).map(([k, v]) => [ + k, + typeof v === "function" ? vi.fn(v as (...args: unknown[]) => unknown) : v, + ]), + ); +}); + +// oxlint-disable-next-line sentry-cli/no-namespace-import -- needed for vi.mocked access +import * as apiClient from "../../../src/lib/api-client.js"; + +vi.mock("../../../src/lib/resolve-target.js", async (importOriginal) => { + const actual = + await importOriginal(); + return Object.fromEntries( + Object.entries(actual).map(([k, v]) => [ + k, + typeof v === "function" ? vi.fn(v as (...args: unknown[]) => unknown) : v, + ]), + ); +}); + +// oxlint-disable-next-line sentry-cli/no-namespace-import -- needed for vi.mocked access +import * as resolveTarget from "../../../src/lib/resolve-target.js"; +import { ApiError, ValidationError } from "../../../src/lib/errors.js"; +import { parsePeriod } from "../../../src/lib/time-range.js"; +import { useTestConfigDir } from "../../helpers.js"; + +useTestConfigDir("attribute-list-"); + +type Attr = Parameters[0]; + +function attr( + name: string, + attributeType: Attr["attributeType"] = "string", + source: "sentry" | "user" = "sentry", + extra: Partial = {}, +): Attr { + // Custom non-string attributes come back with Sentry's typed key form. + const key = + source === "user" && attributeType !== "string" + ? `tags[${name},${attributeType}]` + : name; + return { + key, + name, + attributeType, + attributeSource: { source_type: source }, + ...extra, + }; +} + +const HTTP_METHOD = attr("http.method", "string", "sentry", { + secondaryAliases: ["http.request.method"], +}); +const CART_TOTAL = attr("cart.total", "number", "user"); +const ATTRIBUTES = [HTTP_METHOD, CART_TOTAL]; + +/** A deprecated key with full context metadata. */ +const CONTEXT_ATTRIBUTE = attr("http.method", "string", "sentry", { + context: { + isConvention: true, + brief: "The HTTP request method.", + isDeprecated: true, + replacementAttribute: "http.request.method", + examples: ["GET", "POST", "PUT", "PATCH"], + }, +}); + +function createMockContext() { + const stdoutWrite = vi.fn((_chunk: string) => true); + const stderrWrite = vi.fn((_chunk: string) => true); + return { + context: { + stdout: { write: stdoutWrite }, + stderr: { write: stderrWrite }, + cwd: "/tmp", + }, + stdoutWrite, + }; +} + +/** Flags as Stricli would hand them to func (defaults applied). */ +function flags(overrides: Record = {}) { + return { + dataset: "spans", + period: parsePeriod("14d"), + context: false, + limit: 25, + json: false, + fresh: false, + ...overrides, + }; +} + +/** The loaded command function, typed for direct `.call` in tests. */ +type ListFunc = ( + this: unknown, + flags: Record, + ...args: unknown[] +) => Promise; + +async function run( + flagOverrides: Record, + target?: string, +): Promise { + const { context, stdoutWrite } = createMockContext(); + const func = (await listCommand.loader()) as unknown as ListFunc; + await func.call(context, flags(flagOverrides), target); + return stdoutWrite.mock.calls.map((c) => c[0]).join(""); +} + +const listSpy = vi.mocked(apiClient.listTraceItemAttributes); +const resolveSpy = vi.mocked(resolveTarget.resolveOrgOptionalFromArg); + +afterEach(() => { + listSpy.mockReset(); + resolveSpy.mockReset(); +}); + +describe("parseDataset", () => { + test("maps user-facing names and aliases to API datasets", () => { + expect(parseDataset("spans")).toBe("spans"); + expect(parseDataset("span")).toBe("spans"); + expect(parseDataset("LOGS")).toBe("logs"); + expect(parseDataset("metrics")).toBe("tracemetrics"); + }); + + test("rejects unknown datasets", () => { + expect(() => parseDataset("errors")).toThrow(ValidationError); + }); +}); + +describe("parseAttributeTypes", () => { + test("accepts repeated and comma-separated values, deduplicated", () => { + expect( + parseAttributeTypes(["string,number", "NUMBER", " boolean "]), + ).toEqual(["string", "number", "boolean"]); + }); + + test("returns undefined when no types are given", () => { + expect(parseAttributeTypes(undefined)).toBeUndefined(); + expect(parseAttributeTypes([])).toBeUndefined(); + expect(parseAttributeTypes([","])).toBeUndefined(); + }); + + test("rejects unknown types", () => { + expect(() => parseAttributeTypes(["string,float"])).toThrow( + ValidationError, + ); + }); +}); + +describe("parseWithKeys / buildWithQuery", () => { + test("accepts repeated and comma-separated keys, deduplicated", () => { + expect( + parseWithKeys(["gen_ai.request.model,http.method", " http.method "]), + ).toEqual(["gen_ai.request.model", "http.method"]); + }); + + test("keeps typed tags[...] keys intact", () => { + expect(parseWithKeys(["tags[cart.total,number],http.method"])).toEqual([ + "tags[cart.total,number]", + "http.method", + ]); + }); + + test("returns undefined when no keys are given", () => { + expect(parseWithKeys(undefined)).toBeUndefined(); + expect(parseWithKeys([" , "])).toBeUndefined(); + }); + + test("rejects keys that look like search syntax", () => { + expect(() => parseWithKeys(['span.op:"db"'])).toThrow(ValidationError); + expect(() => parseWithKeys(["span.op db"])).toThrow(ValidationError); + }); + + test("builds has: filters for every key", () => { + expect(buildWithQuery(["gen_ai.request.model", "http.method"])).toBe( + "has:gen_ai.request.model has:http.method", + ); + }); +}); + +describe("buildUsageTip", () => { + const items = [HTTP_METHOD, CART_TOTAL].map(toAttributeListItem); + + /** + * Assert the tip's structure, not its prose: it must be a `Tip:` line (so + * the tips preference can strip it) carrying a runnable command. + */ + function expectTipWithCommand(tip: string | undefined, command: string) { + expect(tip).toMatch(/^Tip: /); + expect(tip).not.toContain("\n"); + expect(tip).toContain(command); + } + + test("spans: suggests span list with a custom key, quoted for the shell", () => { + const tip = buildUsageTip("spans", "my-org", "backend", items); + expectTipWithCommand(tip, "sentry span list my-org/backend"); + expect(tip).toContain('-q "has:tags[cart.total,number]"'); + expect(tip).toContain("--fields 'tags[cart.total,number]'"); + }); + + test("logs: suggests log list, with a placeholder for org-wide listings", () => { + const tip = buildUsageTip("logs", "my-org", undefined, [ + toAttributeListItem(HTTP_METHOD), + ]); + expectTipWithCommand(tip, "sentry log list my-org/"); + expect(tip).toContain('-q "has:http.method"'); + expect(tip).toContain("--fields http.method"); + }); + + test("metrics: points at explore", () => { + expectTipWithCommand( + buildUsageTip("tracemetrics", "my-org", undefined, items), + "sentry explore my-org/ --dataset metrics", + ); + }); + + test("returns nothing for an empty page", () => { + expect(buildUsageTip("spans", "my-org", undefined, [])).toBeUndefined(); + }); +}); + +describe("dropLookAheadRows", () => { + test("keeps at most `limit` rows per type, in server order", () => { + const data = [ + attr("s1"), + attr("s2"), + attr("s3"), // look-ahead row for strings + attr("n1", "number", "user"), + attr("n2", "number", "user"), + ]; + expect(dropLookAheadRows(data, 2).map((a) => a.name)).toEqual([ + "s1", + "s2", + "n1", + "n2", + ]); + }); +}); + +describe("compareAttributes", () => { + test("sorts by display name (not the tags[] key), then type", () => { + const sorted = [ + attr("zeta"), + attr("alpha", "number", "user"), + attr("alpha"), + attr("beta", "boolean", "user"), + ].sort(compareAttributes); + expect(sorted.map((a) => `${a.name}:${a.attributeType}`)).toEqual([ + "alpha:number", + "alpha:string", + "beta:boolean", + "zeta:string", + ]); + }); +}); + +describe("describeAttribute", () => { + test("puts the deprecation note and replacement before the brief", () => { + expect(describeAttribute(CONTEXT_ATTRIBUTE.context!)).toBe( + "Deprecated, use http.request.method. The HTTP request method.", + ); + }); + + test("handles deprecation without a replacement, and empty context", () => { + expect(describeAttribute({ isDeprecated: true })).toBe("Deprecated."); + expect(describeAttribute({ brief: "Cart total." })).toBe("Cart total."); + expect(describeAttribute({})).toBe(""); + }); +}); + +describe("listCommand.func", () => { + test("lists span attributes for a project, 25 per page over 14d, sorted by name", async () => { + resolveSpy.mockResolvedValue({ org: "my-org", project: "backend" }); + listSpy.mockResolvedValue({ data: ATTRIBUTES }); + + const output = await run({ json: true }, "my-org/backend"); + + expect(listSpy).toHaveBeenCalledWith("my-org", { + dataset: "spans", + project: ["backend"], + attributeTypes: undefined, + substringMatch: undefined, + query: undefined, + context: false, + perPage: 25, + statsPeriod: "14d", + }); + expect(JSON.parse(output)).toEqual({ + data: [ + { + key: "tags[cart.total,number]", + name: "cart.total", + type: "number", + source: "user", + }, + { + key: "http.method", + name: "http.method", + type: "string", + source: "sentry", + aliases: ["http.request.method"], + }, + ], + hasMore: false, + hasPrev: false, + }); + }); + + test("passes dataset, types, search, --with, and limit through for an org-wide listing", async () => { + resolveSpy.mockResolvedValue({ org: "my-org" }); + listSpy.mockResolvedValue({ data: [] }); + + await run( + { + json: true, + dataset: "logs", + type: ["string,boolean"], + search: "http", + with: ["gen_ai.request.model", "http.method"], + period: parsePeriod("24h"), + limit: 50, + }, + "my-org/", + ); + + expect(listSpy).toHaveBeenCalledWith("my-org", { + dataset: "logs", + project: undefined, + attributeTypes: ["string", "boolean"], + substringMatch: "http", + query: "has:gen_ai.request.model has:http.method", + context: false, + perPage: 50, + statsPeriod: "24h", + }); + }); + + test("shows the exact key in the table, including typed tags[...] keys", async () => { + resolveSpy.mockResolvedValue({ org: "my-org" }); + listSpy.mockResolvedValue({ data: ATTRIBUTES }); + + const output = await run({}, "my-org/"); + // Check the table rows only, not the Tip: line, which also prints a key. + const table = output + .split("\n") + .filter((line) => !line.includes("Tip:")) + .join("\n"); + + expect(table).toContain("KEY"); + expect(table).toContain(CART_TOTAL.key); + expect(table).toContain(HTTP_METHOD.key); + }); + + test("--fields selects flattened keys in JSON output", async () => { + resolveSpy.mockResolvedValue({ org: "my-org" }); + listSpy.mockResolvedValue({ data: ATTRIBUTES }); + + const output = await run( + { json: true, fields: ["name", "type"] }, + "my-org/", + ); + + expect(JSON.parse(output).data).toEqual([ + { name: "cart.total", type: "number" }, + { name: "http.method", type: "string" }, + ]); + }); + + test("renders a table and points at -n when the server has more", async () => { + resolveSpy.mockResolvedValue({ org: "my-org" }); + listSpy.mockResolvedValue({ data: ATTRIBUTES, nextCursor: "0:100:0" }); + + const output = await run( + { dataset: "logs", search: "http", type: ["string"], limit: 10 }, + "my-org/", + ); + + expect(output).toContain("TYPE"); + expect(output).toContain("SOURCE"); + expect(output).toContain("http.method"); + expect(output).toContain("-n 20"); + }); + + test("drops the server's look-ahead row for a single type", async () => { + resolveSpy.mockResolvedValue({ org: "my-org" }); + // Sentry returns limit + 1 rows when more exist. + listSpy.mockResolvedValue({ + data: [attr("a"), attr("b"), attr("c")], + nextCursor: "0:2:0", + }); + + const json = JSON.parse( + await run({ json: true, limit: 2, type: ["string"] }, "my-org/"), + ); + expect(json.data.map((a: { name: string }) => a.name)).toEqual(["a", "b"]); + expect(json.hasMore).toBe(true); + }); + + test("trims a page over --limit, keeps hasMore, and suggests -n or narrowing", async () => { + resolveSpy.mockResolvedValue({ org: "my-org" }); + listSpy.mockResolvedValue({ + data: [attr("c"), attr("a", "number", "user"), attr("b")], + nextCursor: "0:2:0", + }); + + const json = JSON.parse(await run({ json: true, limit: 2 }, "my-org/")); + expect(json.data.map((a: { name: string }) => a.name)).toEqual(["a", "b"]); + expect(json.hasMore).toBe(true); + + const output = await run({ limit: 2 }, "my-org/"); + expect(output).toContain( + "Showing 2 of 3+ spans attributes in my-org/. Use -n 4 for more, or narrow with --type or --search.", + ); + }); + + test("prints a usage tip on its own line after the count", async () => { + resolveSpy.mockResolvedValue({ org: "my-org", project: "backend" }); + listSpy.mockResolvedValue({ data: ATTRIBUTES }); + + const output = await run({}, "my-org/backend"); + + const count = output.indexOf( + "Showing 2 spans attributes in my-org/backend.", + ); + const tipLine = output.split("\n").find((line) => line.includes("Tip:")); + expect(count).toBeGreaterThan(-1); + expect(tipLine).toContain("sentry span list my-org/backend"); + // On its own line after the count, so `sentry cli defaults tips off` + // strips only the tip and keeps the count text. + expect(output.indexOf(tipLine!)).toBeGreaterThan(count); + expect(tipLine).not.toContain("Showing"); + }); + + test("says so when no attributes match", async () => { + resolveSpy.mockResolvedValue({ org: "my-org" }); + listSpy.mockResolvedValue({ data: [] }); + + const output = await run({}, "my-org/"); + + expect(output).toContain("No attributes found."); + }); + + test("--context requests metadata and includes it in JSON", async () => { + resolveSpy.mockResolvedValue({ org: "my-org" }); + listSpy.mockResolvedValue({ + data: [{ ...CART_TOTAL, context: {} }, CONTEXT_ATTRIBUTE], + }); + + const output = await run({ json: true, context: true }, "my-org/"); + + expect(listSpy).toHaveBeenCalledWith( + "my-org", + expect.objectContaining({ context: true }), + ); + const { data } = JSON.parse(output); + expect(data[0].context).toEqual({}); + expect(data[1].context).toEqual(CONTEXT_ATTRIBUTE.context); + }); + + test("omits context from JSON without --context", async () => { + resolveSpy.mockResolvedValue({ org: "my-org" }); + listSpy.mockResolvedValue({ data: ATTRIBUTES }); + + const output = await run({ json: true }, "my-org/"); + + for (const item of JSON.parse(output).data) { + expect(item).not.toHaveProperty("context"); + } + }); + + test("--context adds description and example columns to the table", async () => { + resolveSpy.mockResolvedValue({ org: "my-org" }); + listSpy.mockResolvedValue({ + data: [CONTEXT_ATTRIBUTE], + nextCursor: "0:100:0", + }); + const context = CONTEXT_ATTRIBUTE.context!; + const examples = context.examples!.map(String); + + const withContext = await run({ context: true }, "my-org/"); + + // The extra columns appear only with --context. + for (const header of ["DESCRIPTION", "EXAMPLES"]) { + expect(withContext).toContain(header); + } + // The row has entries from the attribute's context, whatever the wording: + // the deprecation note's replacement key, and leading example values. + expect(withContext).toContain(context.replacementAttribute); + expect(withContext).toContain(examples[0]); + // Long example lists are cut short. + expect(withContext).not.toContain(examples.at(-1)); + }); + + test("turns a Sentry query parse error into a --with validation error", async () => { + resolveSpy.mockResolvedValue({ org: "my-org" }); + listSpy.mockRejectedValue( + new ApiError( + "Failed to list spans attributes", + 400, + "Error parsing search query: Expected one of: ...", + ), + ); + + await expect(run({ with: ["bad]key"] }, "my-org/")).rejects.toThrow( + /Sentry rejected --with bad\]key/, + ); + }); + + test("rejects an invalid --type before calling the API", async () => { + await expect(run({ type: ["text"] }, "my-org/")).rejects.toThrow( + ValidationError, + ); + expect(listSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/cli/test/lib/api/trace-item-attributes.test.ts b/packages/cli/test/lib/api/trace-item-attributes.test.ts new file mode 100644 index 000000000..7a6a614ea --- /dev/null +++ b/packages/cli/test/lib/api/trace-item-attributes.test.ts @@ -0,0 +1,150 @@ +/** + * Tests for listTraceItemAttributes (trace-items attribute key discovery). + * + * Verifies URL construction, query parameter encoding (including repeated + * array params and omission of empty filters), "has more" detection, + * and error propagation. + */ + +import { afterEach, beforeEach, describe, expect, test } from "vitest"; +import { listTraceItemAttributes } from "../../../src/lib/api/traces.js"; +import { DEFAULT_SENTRY_URL } from "../../../src/lib/constants.js"; +import { setAuthToken } from "../../../src/lib/db/auth.js"; +import { setOrgRegion } from "../../../src/lib/db/regions.js"; +import { ApiError } from "../../../src/lib/errors.js"; +import { resetAuthenticatedFetch } from "../../../src/lib/sentry-client.js"; +import { mockFetch, useTestConfigDir } from "../../helpers.js"; + +useTestConfigDir("api-trace-item-attributes-"); + +const ATTRIBUTES = [ + { + key: "span.op", + name: "span.op", + attributeType: "string", + attributeSource: { source_type: "sentry" }, + }, + { + key: "gen_ai.usage.input_tokens", + name: "gen_ai.usage.input_tokens", + attributeType: "number", + attributeSource: { source_type: "user" }, + }, +]; + +let originalFetch: typeof globalThis.fetch; +let capturedUrl: URL | undefined; + +function mockOk(body: unknown, headers: Record = {}) { + globalThis.fetch = mockFetch(async (input, init) => { + capturedUrl = new URL(new Request(input!, init).url); + return Response.json(body, { headers }); + }); +} + +beforeEach(async () => { + originalFetch = globalThis.fetch; + capturedUrl = undefined; + resetAuthenticatedFetch(); + await setAuthToken("test-token"); + setOrgRegion("test-org", DEFAULT_SENTRY_URL); +}); + +afterEach(() => { + globalThis.fetch = originalFetch; + resetAuthenticatedFetch(); +}); + +describe("listTraceItemAttributes", () => { + test("calls the org-scoped attributes endpoint with only the dataset when no filters are set", async () => { + mockOk(ATTRIBUTES); + + const { data } = await listTraceItemAttributes("test-org", { + dataset: "spans", + }); + + expect(capturedUrl?.pathname).toBe( + "/api/0/organizations/test-org/trace-items/attributes/", + ); + expect([...capturedUrl!.searchParams.keys()]).toEqual(["dataset"]); + expect(capturedUrl?.searchParams.get("dataset")).toBe("spans"); + expect(data).toEqual(ATTRIBUTES); + }); + + test("encodes array filters as repeated params and passes string filters through", async () => { + mockOk([]); + + await listTraceItemAttributes("test-org", { + dataset: "logs", + project: [123, "backend"], + environment: ["production"], + attributeTypes: ["string", "number"], + }); + + const params = capturedUrl!.searchParams; + expect(params.get("dataset")).toBe("logs"); + expect(params.getAll("project")).toEqual(["123", "backend"]); + expect(params.getAll("environment")).toEqual(["production"]); + expect(params.getAll("attributeType")).toEqual(["string", "number"]); + }); + + test("omits empty arrays and empty strings instead of sending blank params", async () => { + mockOk([]); + + await listTraceItemAttributes("test-org", { + dataset: "spans", + project: [], + environment: [], + query: "", + }); + + expect([...capturedUrl!.searchParams.keys()]).toEqual(["dataset"]); + }); + + test("sends perPage as per_page", async () => { + mockOk([]); + await listTraceItemAttributes("test-org", { + dataset: "spans", + perPage: 25, + }); + expect(capturedUrl?.searchParams.get("per_page")).toBe("25"); + }); + + test("sends expand=context only when context is requested", async () => { + mockOk([]); + await listTraceItemAttributes("test-org", { + dataset: "spans", + context: true, + }); + expect(capturedUrl?.searchParams.getAll("expand")).toEqual(["context"]); + + mockOk([]); + await listTraceItemAttributes("test-org", { + dataset: "spans", + context: false, + }); + expect(capturedUrl?.searchParams.has("expand")).toBe(false); + }); + + test("reports that more results exist when Sentry's Link header says so", async () => { + mockOk(ATTRIBUTES, { + Link: '; rel="next"; results="true"; cursor="0:100:0"', + }); + + const result = await listTraceItemAttributes("test-org", { + dataset: "spans", + }); + + expect(result.nextCursor).toBeTruthy(); + }); + + test("throws ApiError with dataset context on failure", async () => { + globalThis.fetch = mockFetch(async () => + Response.json({ detail: "You do not have permission" }, { status: 403 }), + ); + + const promise = listTraceItemAttributes("test-org", { dataset: "logs" }); + await expect(promise).rejects.toBeInstanceOf(ApiError); + await expect(promise).rejects.toThrow(/logs attributes/); + }); +}); From 6a62b80d0879f1c2cc1776a7f1e8ca578db49778 Mon Sep 17 00:00:00 2001 From: lzhao-sentry Date: Tue, 20 Oct 2026 13:22:05 -0400 Subject: [PATCH 02/10] make list.ts comment clearer, add cmd to complete.ts --- packages/cli/src/commands/attribute/list.ts | 5 +++-- packages/cli/src/lib/complete.ts | 1 + 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/commands/attribute/list.ts b/packages/cli/src/commands/attribute/list.ts index a8d60ddfe..77441eeef 100644 --- a/packages/cli/src/commands/attribute/list.ts +++ b/packages/cli/src/commands/attribute/list.ts @@ -431,10 +431,11 @@ export const listCommand = buildListCommand( return { hint: result.hint }; }, }, - // No --cursor / -c: pagination for now. Instead this command makes a single request + // No --cursor / -c: pagination. Instead this command makes a single request // (up to --limit, max 1000) and users narrow using --type/--search/--with. // The per_page parameter is per attribute type, so a single request can return more // than --limit rows. The API can also append extra internal attributes, which can - // cause the results to exceed --limit as well + // cause the results to exceed --limit as well. So to simplify logic, it drops + // extra rows and avoids pagination for now. { noCursorFlag: true }, ); diff --git a/packages/cli/src/lib/complete.ts b/packages/cli/src/lib/complete.ts index 811488511..baf9c67db 100644 --- a/packages/cli/src/lib/complete.ts +++ b/packages/cli/src/lib/complete.ts @@ -132,6 +132,7 @@ export const ORG_PROJECT_COMMANDS = new Set([ "event list", "log list", "log view", + "attribute list", "dashboard list", "dashboard view", "dashboard create", From d6e8a1eee56f81670f09da8241e2bbea0311762d Mon Sep 17 00:00:00 2001 From: lzhao-sentry Date: Wed, 21 Oct 2026 10:08:27 -0400 Subject: [PATCH 03/10] revert complete.ts change, fix --context example comment --- apps/cli-docs/src/fragments/commands/attribute.md | 2 +- .../sentry-cli/skills/sentry-cli/references/attribute.md | 2 +- packages/cli/src/lib/complete.ts | 1 - 3 files changed, 2 insertions(+), 3 deletions(-) diff --git a/apps/cli-docs/src/fragments/commands/attribute.md b/apps/cli-docs/src/fragments/commands/attribute.md index 09826d314..b983af144 100644 --- a/apps/cli-docs/src/fragments/commands/attribute.md +++ b/apps/cli-docs/src/fragments/commands/attribute.md @@ -17,7 +17,7 @@ sentry attribute list --search http --type string # Attributes seen on the same spans as the specified attribute sentry attribute list --with gen_ai.request.model -# Add descriptions, example values, and deprecation info +# Show descriptions, example values, and deprecation info sentry attribute list --search http --context # Show more than the default 25 diff --git a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/attribute.md b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/attribute.md index 6b6814a8d..df21b8c60 100644 --- a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/attribute.md +++ b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/attribute.md @@ -40,7 +40,7 @@ sentry attribute list --search http --type string # Attributes seen on the same spans as the specified attribute sentry attribute list --with gen_ai.request.model -# Add descriptions, example values, and deprecation info +# Show descriptions, example values, and deprecation info sentry attribute list --search http --context # Show more than the default 25 diff --git a/packages/cli/src/lib/complete.ts b/packages/cli/src/lib/complete.ts index baf9c67db..811488511 100644 --- a/packages/cli/src/lib/complete.ts +++ b/packages/cli/src/lib/complete.ts @@ -132,7 +132,6 @@ export const ORG_PROJECT_COMMANDS = new Set([ "event list", "log list", "log view", - "attribute list", "dashboard list", "dashboard view", "dashboard create", From 2ebde766825a7ec34a8b7c80de71685039a5bf2a Mon Sep 17 00:00:00 2001 From: lzhao-sentry Date: Thu, 22 Oct 2026 15:44:55 -0400 Subject: [PATCH 04/10] remove redundant functions, attempt to make help better --- .../src/fragments/commands/attribute.md | 23 ++++- .../skills/sentry-cli/references/attribute.md | 8 +- packages/cli/src/commands/attribute/list.ts | 91 ++++++------------- packages/cli/src/lib/formatters/json.ts | 17 ++-- .../cli/test/commands/attribute/list.test.ts | 14 +-- packages/cli/test/lib/formatters/json.test.ts | 8 ++ 6 files changed, 78 insertions(+), 83 deletions(-) diff --git a/apps/cli-docs/src/fragments/commands/attribute.md b/apps/cli-docs/src/fragments/commands/attribute.md index b983af144..6019628ca 100644 --- a/apps/cli-docs/src/fragments/commands/attribute.md +++ b/apps/cli-docs/src/fragments/commands/attribute.md @@ -11,14 +11,14 @@ sentry attribute list my-org/my-project # List log attribute keys across the whole organization sentry attribute list my-org/ --dataset logs -# Find keys containing "http" that are also of type string -sentry attribute list --search http --type string +# Find keys containing "gen_ai" that are also of type string +sentry attribute list --search gen_ai --type string # Attributes seen on the same spans as the specified attribute sentry attribute list --with gen_ai.request.model # Show descriptions, example values, and deprecation info -sentry attribute list --search http --context +sentry attribute list --context # Show more than the default 25 sentry attribute list my-org/my-project -n 100 @@ -26,3 +26,20 @@ sentry attribute list my-org/my-project -n 100 # Select fields as JSON sentry attribute list --json --fields key,type,source ``` + +## Using Attribute Keys + +Attributes are the typed key-value data attached to spans, logs, and metric samples. Their keys can be passed to other commands to filter, add columns, or group results: + +```bash +# Filter spans that have the attribute +sentry span list my-org/my-project -q 'has:gen_ai.request.model' + +# Add the attribute as a column +sentry span list my-org/my-project --fields gen_ai.request.model + +# Group a metric by the attribute +sentry explore my-org/ --dataset metrics -m llm.token_usage -F gen_ai.request.model +``` + +Custom non-string attributes include their type in the key, e.g. `tags[cart.total,number]`. Quote these keys in the shell. diff --git a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/attribute.md b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/attribute.md index df21b8c60..8ea8cc0ae 100644 --- a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/attribute.md +++ b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/attribute.md @@ -20,7 +20,7 @@ List attribute keys for spans, logs, or metrics - `--type ... - Find attributes of these types (string, number, boolean, array; repeatable, comma-separated)` - `-s, --search - Find attribute keys containing this text (case-sensitive)` - `--with ... - Find attributes that appear on the same items as all of these keys (repeatable, comma-separated)` -- `-t, --period - Time range: "7d", "2024-01-01..2024-02-01", ">=2024-01-01" - (default: "14d")` +- `-t, --period - Time range: "7d", "2024-01-01..2024-02-01", ">=2024-01-01" - (default: "7d")` - `-n, --limit - Maximum number of attributes to list - (default: "25")` - `--context - Include each attribute's description, examples, and deprecation info (experimental API)` - `-f, --fresh - Bypass cache, re-detect projects, and fetch fresh data` @@ -34,14 +34,14 @@ sentry attribute list my-org/my-project # List log attribute keys across the whole organization sentry attribute list my-org/ --dataset logs -# Find keys containing "http" that are also of type string -sentry attribute list --search http --type string +# Find keys containing "gen_ai" that are also of type string +sentry attribute list --search gen_ai --type string # Attributes seen on the same spans as the specified attribute sentry attribute list --with gen_ai.request.model # Show descriptions, example values, and deprecation info -sentry attribute list --search http --context +sentry attribute list --context # Show more than the default 25 sentry attribute list my-org/my-project -n 100 diff --git a/packages/cli/src/commands/attribute/list.ts b/packages/cli/src/commands/attribute/list.ts index 77441eeef..57bfeb5cf 100644 --- a/packages/cli/src/commands/attribute/list.ts +++ b/packages/cli/src/commands/attribute/list.ts @@ -23,12 +23,14 @@ import { formatAttributeList, toAttributeListItem, } from "../../lib/formatters/attribute.js"; +import { parseFieldsList } from "../../lib/formatters/json.js"; import { CommandOutput } from "../../lib/formatters/output.js"; import { buildListCommand, buildListLimitFlag, LIST_MAX_LIMIT, LIST_MIN_LIMIT, + LIST_PERIOD_FLAG, LIST_TARGET_POSITIONAL, PERIOD_ALIASES, } from "../../lib/list-command.js"; @@ -38,21 +40,14 @@ import { } from "../../lib/org-list.js"; import { withProgress } from "../../lib/polling.js"; import { resolveOrgOptionalFromArg } from "../../lib/resolve-target.js"; -import { - PERIOD_BRIEF, - parsePeriod, - type TimeRange, - timeRangeToApiParams, -} from "../../lib/time-range.js"; +import { type TimeRange, timeRangeToApiParams } from "../../lib/time-range.js"; +import { shellQuote } from "../../lib/utils.js"; const COMMAND_NAME = "attribute list"; /** Default dataset when `--dataset` is omitted */ const DEFAULT_DATASET = "spans"; -/** Default window — matches the MCP server's attribute discovery. */ -const DEFAULT_PERIOD = "14d"; - /** User-facing dataset names (and aliases) that map to API dataset names. */ const DATASET_ALIASES: Record = { spans: "spans", @@ -113,30 +108,6 @@ export function parseDataset(value: string): TraceItemAttributeDataset { /** Characters that cannot appear in an attribute key passed to `--with`. */ const INVALID_WITH_KEY = /[\s"'()]/; -/** - * Split a comma-separated key list, keeping commas inside brackets so typed - * keys like `tags[cart.total,number]` stay whole. - */ -function splitKeyList(value: string): string[] { - const parts: string[] = []; - let depth = 0; - let current = ""; - for (const char of value) { - if (char === "[") { - depth += 1; - } else if (char === "]" && depth > 0) { - depth -= 1; - } else if (char === "," && depth === 0) { - parts.push(current); - current = ""; - continue; - } - current += char; - } - parts.push(current); - return parts; -} - /** * Normalize repeated and/or comma-separated `--with` values into a * deduplicated list of attribute keys. @@ -149,14 +120,10 @@ export function parseWithKeys( return; } const keys = new Set(); - for (const raw of values.flatMap(splitKeyList)) { - const key = raw.trim(); - if (!key) { - continue; - } + for (const key of values.flatMap(parseFieldsList)) { if (INVALID_WITH_KEY.test(key)) { throw new ValidationError( - `Invalid attribute key "${key}" for --with. Pass keys as listed by this command, e.g. http.method or tags[cart.total,number].`, + `Invalid attribute key "${key}" for --with. Pass keys as listed by this command, e.g. gen_ai.request.model.`, "with", ); } @@ -187,14 +154,11 @@ export function parseAttributeTypes( return; } const types = new Set(); - for (const raw of values.flatMap((v) => v.split(","))) { - const value = raw.trim().toLowerCase(); - if (!value) { - continue; - } + for (const raw of values.flatMap(parseFieldsList)) { + const value = raw.toLowerCase(); if (!VALID_TYPES.includes(value as TraceItemAttributeValueType)) { throw new ValidationError( - `Invalid attribute type "${raw.trim()}". Must be one of: ${VALID_TYPES.join(", ")}`, + `Invalid attribute type "${raw}". Must be one of: ${VALID_TYPES.join(", ")}`, "type", ); } @@ -219,11 +183,6 @@ export function compareAttributes( ); } -/** Shell-quote a key for a copyable command (zsh globs `tags[...]`). */ -function shellKey(key: string): string { - return /^[\w.:-]+$/.test(key) ? key : `'${key}'`; -} - /** * Build a `Tip:` line showing how to use a attribute keys in other commands. * @internal Exported for testing @@ -240,11 +199,12 @@ export function buildUsageTip( } const { key } = example; if (dataset === "tracemetrics") { - return `Tip: Group or filter metrics with 'sentry explore ${org}/ --dataset metrics' (-F to group, -q to filter)`; + const target = project ? `${org}/${project}` : `${org}/`; + return `Tip: Group metrics with: sentry explore ${target} --dataset metrics -m -F ${shellQuote(key)}`; } - const command = dataset === "logs" ? "log list" : "span list"; const target = project ? `${org}/${project}` : `${org}/`; - return `Tip: Filter or add to columns with: sentry ${command} ${target} -q "has:${key}" --fields ${shellKey(key)}`; + const command = dataset === "logs" ? "log list" : "span list"; + return `Tip: Filter or add to columns with: sentry ${command} ${target} -q ${shellQuote(`has:${key}`)} --fields ${shellQuote(key)}`; } /** @@ -273,17 +233,27 @@ export const listCommand = buildListCommand( docs: { brief: "List attribute keys for spans, logs, or metrics", fullDescription: - "List the attribute keys available on a trace item dataset\n" + + "List the attribute keys available on spans, logs, or metrics datasets.\n" + + "Attributes are typed key-value data attached to each span, log or metric sample.\n\n" + + "Columns:\n" + + " KEY Name identifier; can be used for other commands in -q/--fields\n" + + " e.g. span list -q 'has:tags[cart.total,number]'\n" + + " TYPE Value type of: string, number, boolean, array\n" + + " SOURCE One of: sentry (built-in) or user (set by your SDK)\n" + + " ALIASES Other names the attribute can be queried by\n" + + " DESCRIPTION What the attribute means; deprecated keys name replacements (pass --context)\n" + + " EXAMPLES Sample values seen for the attribute (pass --context)\n\n" + "Target specification:\n" + " sentry attribute list # auto-detect org from DSN or config\n" + " sentry attribute list / # all projects in the organization\n" + " sentry attribute list / # one project\n" + " sentry attribute list # find project across all orgs\n\n" + "Examples:\n" + - " sentry attribute list --dataset logs --with http.method # Attributes seen on logs with http.method\n" + - " sentry attribute list --type string --search http # Attributes containing http and are type string\n" + + " sentry attribute list --dataset logs # Attributes on logs instead of spans\n" + + " sentry attribute list --with gen_ai.request.model # Attributes seen on spans with gen_ai.request.model\n" + + " sentry attribute list --type string --search gen_ai # Attributes containing gen_ai and are type string\n" + " sentry attribute list --period 24h # Get attributes that appeared in the last 24 hours\n" + - " sentry attribute list --context # Display attribute metadata information\n" + + " sentry attribute list --context # Display description and examples for each attribute\n" + " sentry attribute list --json --fields key,type # Output in JSON format with specified fields\n\n" + "Alias: `sentry attributes` → `sentry attribute list`", }, @@ -324,12 +294,7 @@ export const listCommand = buildListCommand( variadic: true, optional: true, }, - period: { - kind: "parsed", - parse: parsePeriod, - brief: PERIOD_BRIEF, - default: DEFAULT_PERIOD, - }, + period: LIST_PERIOD_FLAG, limit: { ...buildListLimitFlag("attributes"), parse: (value: string) => diff --git a/packages/cli/src/lib/formatters/json.ts b/packages/cli/src/lib/formatters/json.ts index 0ef4b5cdd..7c956271f 100644 --- a/packages/cli/src/lib/formatters/json.ts +++ b/packages/cli/src/lib/formatters/json.ts @@ -140,13 +140,21 @@ export function filterFields(data: T, fields: string[]): Partial { return result as Partial; } +/** + * Regex to match comma-separated field items. + * For nested square-brackets, the comma inside is ignored. This is for handling + * typed fields, like some attributes, which use the syntax `tags[name,type]`. + */ +const FIELD_ITEM_RE = /(?:\[[^\]]*\]|[^,])+/g; + /** * Parse a comma-separated fields string into a trimmed, deduplicated array. * * Handles whitespace around commas and filters out empty segments: * ```ts - * parseFieldsList("id, title , status") // → ["id", "title", "status"] - * parseFieldsList("id,,title") // → ["id", "title"] + * parseFieldsList("id, title , status") // → ["id", "title", "status"] + * parseFieldsList("id,,title") // → ["id", "title"] + * parseFieldsList("id,tags[cart.total,number]") // → ["id", "tags[cart.total,number]"] * ``` * * @param input - Raw `--fields` flag value @@ -155,10 +163,7 @@ export function filterFields(data: T, fields: string[]): Partial { export function parseFieldsList(input: string): string[] { return [ ...new Set( - input - .split(",") - .map((f) => f.trim()) - .filter(Boolean), + (input.match(FIELD_ITEM_RE) ?? []).map((f) => f.trim()).filter(Boolean), ), ]; } diff --git a/packages/cli/test/commands/attribute/list.test.ts b/packages/cli/test/commands/attribute/list.test.ts index 998c083d3..d97a7d5af 100644 --- a/packages/cli/test/commands/attribute/list.test.ts +++ b/packages/cli/test/commands/attribute/list.test.ts @@ -111,7 +111,7 @@ function createMockContext() { function flags(overrides: Record = {}) { return { dataset: "spans", - period: parsePeriod("14d"), + period: parsePeriod("7d"), context: false, limit: 25, json: false, @@ -225,7 +225,7 @@ describe("buildUsageTip", () => { test("spans: suggests span list with a custom key, quoted for the shell", () => { const tip = buildUsageTip("spans", "my-org", "backend", items); expectTipWithCommand(tip, "sentry span list my-org/backend"); - expect(tip).toContain('-q "has:tags[cart.total,number]"'); + expect(tip).toContain("-q 'has:tags[cart.total,number]'"); expect(tip).toContain("--fields 'tags[cart.total,number]'"); }); @@ -234,14 +234,14 @@ describe("buildUsageTip", () => { toAttributeListItem(HTTP_METHOD), ]); expectTipWithCommand(tip, "sentry log list my-org/"); - expect(tip).toContain('-q "has:http.method"'); - expect(tip).toContain("--fields http.method"); + expect(tip).toContain("-q 'has:http.method'"); + expect(tip).toContain("--fields 'http.method'"); }); test("metrics: points at explore", () => { expectTipWithCommand( buildUsageTip("tracemetrics", "my-org", undefined, items), - "sentry explore my-org/ --dataset metrics", + "sentry explore my-org/ --dataset metrics -m -F 'tags[cart.total,number]'", ); }); @@ -300,7 +300,7 @@ describe("describeAttribute", () => { }); describe("listCommand.func", () => { - test("lists span attributes for a project, 25 per page over 14d, sorted by name", async () => { + test("lists span attributes for a project, 25 per page over 7d, sorted by name", async () => { resolveSpy.mockResolvedValue({ org: "my-org", project: "backend" }); listSpy.mockResolvedValue({ data: ATTRIBUTES }); @@ -314,7 +314,7 @@ describe("listCommand.func", () => { query: undefined, context: false, perPage: 25, - statsPeriod: "14d", + statsPeriod: "7d", }); expect(JSON.parse(output)).toEqual({ data: [ diff --git a/packages/cli/test/lib/formatters/json.test.ts b/packages/cli/test/lib/formatters/json.test.ts index 8d95838c8..b4b612434 100644 --- a/packages/cli/test/lib/formatters/json.test.ts +++ b/packages/cli/test/lib/formatters/json.test.ts @@ -129,6 +129,14 @@ describe("parseFieldsList", () => { test("returns empty array for empty string", () => { expect(parseFieldsList("")).toEqual([]); }); + + test("keeps commas inside brackets (typed attribute keys)", () => { + expect(parseFieldsList("id,tags[cart.total,number],title")).toEqual([ + "id", + "tags[cart.total,number]", + "title", + ]); + }); }); describe("writeJson with fields", () => { From 7270b13a46940c5056cf9916e4ea8dc8783b1d8e Mon Sep 17 00:00:00 2001 From: lzhao-sentry Date: Fri, 23 Oct 2026 09:49:18 -0400 Subject: [PATCH 05/10] normalize/add pagination, make dataset module --- .../src/fragments/commands/attribute.md | 3 + .../skills/sentry-cli/references/attribute.md | 4 + packages/cli/src/commands/attribute/list.ts | 529 +++++++++++------- packages/cli/src/commands/explore.ts | 67 +-- packages/cli/src/lib/api/traces.ts | 3 + packages/cli/src/lib/datasets.ts | 79 +++ .../cli/test/commands/attribute/list.test.ts | 186 +++--- packages/cli/test/lib/datasets.test.ts | 53 ++ 8 files changed, 573 insertions(+), 351 deletions(-) create mode 100644 packages/cli/src/lib/datasets.ts create mode 100644 packages/cli/test/lib/datasets.test.ts diff --git a/apps/cli-docs/src/fragments/commands/attribute.md b/apps/cli-docs/src/fragments/commands/attribute.md index 6019628ca..42395426a 100644 --- a/apps/cli-docs/src/fragments/commands/attribute.md +++ b/apps/cli-docs/src/fragments/commands/attribute.md @@ -23,6 +23,9 @@ sentry attribute list --context # Show more than the default 25 sentry attribute list my-org/my-project -n 100 +# Show the next page of the previous listing (same flags) +sentry attribute list my-org/my-project -n 100 -c next + # Select fields as JSON sentry attribute list --json --fields key,type,source ``` diff --git a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/attribute.md b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/attribute.md index 8ea8cc0ae..a39df7bc0 100644 --- a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/attribute.md +++ b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/attribute.md @@ -24,6 +24,7 @@ List attribute keys for spans, logs, or metrics - `-n, --limit - Maximum number of attributes to list - (default: "25")` - `--context - Include each attribute's description, examples, and deprecation info (experimental API)` - `-f, --fresh - Bypass cache, re-detect projects, and fetch fresh data` +- `-c, --cursor - Navigate pages: "next", "prev", "first" (or raw cursor string)` **Examples:** @@ -46,6 +47,9 @@ sentry attribute list --context # Show more than the default 25 sentry attribute list my-org/my-project -n 100 +# Show the next page of the previous listing (same flags) +sentry attribute list my-org/my-project -n 100 -c next + # Select fields as JSON sentry attribute list --json --fields key,type,source ``` diff --git a/packages/cli/src/commands/attribute/list.ts b/packages/cli/src/commands/attribute/list.ts index 57bfeb5cf..38f51fb95 100644 --- a/packages/cli/src/commands/attribute/list.ts +++ b/packages/cli/src/commands/attribute/list.ts @@ -6,13 +6,24 @@ */ import type { SentryContext } from "../../context.js"; +import { paginate } from "../../lib/api/infrastructure.js"; import type { + ListTraceItemAttributesOptions, TraceItemAttributeDataset, TraceItemAttributeDefinition, TraceItemAttributeValueType, } from "../../lib/api/traces.js"; import { listTraceItemAttributes } from "../../lib/api-client.js"; import { validateLimit } from "../../lib/arg-parsing.js"; +import { buildDatasetParser, datasetDisplayName } from "../../lib/datasets.js"; +import { + advancePaginationState, + buildPaginationContextKey, + decodeTargetCursors, + encodeCompoundCursor, + hasPreviousPage, + resolveCursor, +} from "../../lib/db/pagination.js"; import { ApiError, isSearchQueryParseError, @@ -28,11 +39,13 @@ import { CommandOutput } from "../../lib/formatters/output.js"; import { buildListCommand, buildListLimitFlag, + LIST_DEFAULT_LIMIT, LIST_MAX_LIMIT, LIST_MIN_LIMIT, LIST_PERIOD_FLAG, LIST_TARGET_POSITIONAL, PERIOD_ALIASES, + paginationHint, } from "../../lib/list-command.js"; import { jsonTransformListResult, @@ -40,34 +53,24 @@ import { } from "../../lib/org-list.js"; import { withProgress } from "../../lib/polling.js"; import { resolveOrgOptionalFromArg } from "../../lib/resolve-target.js"; -import { type TimeRange, timeRangeToApiParams } from "../../lib/time-range.js"; +import { + appendPeriodHint, + serializeTimeRange, + type TimeRange, + timeRangeToApiParams, +} from "../../lib/time-range.js"; import { shellQuote } from "../../lib/utils.js"; const COMMAND_NAME = "attribute list"; +/** Cursor storage key for `-c next` / `-c prev` */ +const PAGINATION_KEY = "attribute-list"; + /** Default dataset when `--dataset` is omitted */ const DEFAULT_DATASET = "spans"; -/** User-facing dataset names (and aliases) that map to API dataset names. */ -const DATASET_ALIASES: Record = { - spans: "spans", - span: "spans", - logs: "logs", - log: "logs", - metrics: "tracemetrics", - metric: "tracemetrics", - tracemetrics: "tracemetrics", -}; - -/** Dataset names shown in help text and validation errors. */ -const VALID_DATASETS = ["spans", "logs", "metrics"] as const; - -/** API dataset name → canonical user-facing name, for hints. */ -const API_TO_USER_DATASET: Record = { - spans: "spans", - logs: "logs", - tracemetrics: "metrics", -}; +/** `--dataset` values: the trace item datasets that have attributes. */ +const DATASETS = buildDatasetParser(["spans", "logs", "tracemetrics"]); /** Attribute value types accepted by `--type`. */ const VALID_TYPES: readonly TraceItemAttributeValueType[] = [ @@ -87,24 +90,10 @@ type AttributeListFlags = { readonly limit: number; readonly json: boolean; readonly fresh: boolean; + readonly cursor?: string; readonly fields?: string[]; }; -/** - * Parse and validate `--dataset`. Accepts canonical names and singular aliases. - * @internal Exported for testing - */ -export function parseDataset(value: string): TraceItemAttributeDataset { - const resolved = DATASET_ALIASES[value.trim().toLowerCase()]; - if (!resolved) { - throw new ValidationError( - `Invalid dataset "${value}". Must be one of: ${VALID_DATASETS.join(", ")}`, - "dataset", - ); - } - return resolved; -} - /** Characters that cannot appear in an attribute key passed to `--with`. */ const INVALID_WITH_KEY = /[\s"'()]/; @@ -207,200 +196,308 @@ export function buildUsageTip( return `Tip: Filter or add to columns with: sentry ${command} ${target} -q ${shellQuote(`has:${key}`)} --fields ${shellQuote(key)}`; } +/** One attribute type's rows for a page, and the cursors around it. */ +type TypePage = { + type: TraceItemAttributeValueType; + data: TraceItemAttributeDefinition[]; + startCursor?: string; + nextCursor?: string; +}; + +/** + * Fetch up to `limit` keys of one attribute type. + * + * The endpoint pages each type separately, so one request per type gives each + * its own cursor and lets {@link paginate} handle it like any list endpoint. + */ +async function fetchTypePage( + org: string, + type: TraceItemAttributeValueType, + options: Omit< + ListTraceItemAttributesOptions, + "attributeTypes" | "perPage" | "cursor" + >, + limit: number, + startCursor: string | undefined, +): Promise { + const { data, nextCursor } = await paginate( + { limit, cursor: startCursor }, + async (perPage, cursor) => { + const page = await listTraceItemAttributes(org, { + ...options, + attributeTypes: [type], + perPage, + cursor, + }); + // Sentry returns its look-ahead row (perPage + 1) instead of trimming it + return { ...page, data: page.data.slice(0, perPage) }; + }, + ); + return { type, data, startCursor, nextCursor }; +} + /** - * Keep at most `limit` rows per attribute type, in the server's order. + * Pick up to `limit` rows across types, sorted by name for display. + * Taken in sorted order by name, then type. * - * Sentry's paginator fetches `limit + 1` rows per type to detect another page - * and returns them all. The extra row only signals that more exist, so drop - * it; otherwise a full single-type page looks trimmed and the counts are off. * @internal Exported for testing */ -export function dropLookAheadRows( - data: readonly TraceItemAttributeDefinition[], +export function selectAcrossTypes( + pages: readonly TypePage[], limit: number, ): TraceItemAttributeDefinition[] { - const seenPerType = new Map(); - return data.filter((attribute) => { - const seen = seenPerType.get(attribute.attributeType) ?? 0; - seenPerType.set(attribute.attributeType, seen + 1); - return seen < limit; - }); + const heads = pages.map(() => 0); + const picked: TraceItemAttributeDefinition[] = []; + while (picked.length < limit) { + let best: { index: number; row: TraceItemAttributeDefinition } | undefined; + for (const [index, page] of pages.entries()) { + const row = page.data[heads[index] ?? 0]; + if (row && (!best || compareAttributes(row, best.row) < 0)) { + best = { index, row }; + } + } + if (!best) { + break; + } + picked.push(best.row); + heads[best.index] = (heads[best.index] ?? 0) + 1; + } + return picked.sort(compareAttributes); } -export const listCommand = buildListCommand( - "attribute", - { - docs: { - brief: "List attribute keys for spans, logs, or metrics", - fullDescription: - "List the attribute keys available on spans, logs, or metrics datasets.\n" + - "Attributes are typed key-value data attached to each span, log or metric sample.\n\n" + - "Columns:\n" + - " KEY Name identifier; can be used for other commands in -q/--fields\n" + - " e.g. span list -q 'has:tags[cart.total,number]'\n" + - " TYPE Value type of: string, number, boolean, array\n" + - " SOURCE One of: sentry (built-in) or user (set by your SDK)\n" + - " ALIASES Other names the attribute can be queried by\n" + - " DESCRIPTION What the attribute means; deprecated keys name replacements (pass --context)\n" + - " EXAMPLES Sample values seen for the attribute (pass --context)\n\n" + - "Target specification:\n" + - " sentry attribute list # auto-detect org from DSN or config\n" + - " sentry attribute list / # all projects in the organization\n" + - " sentry attribute list / # one project\n" + - " sentry attribute list # find project across all orgs\n\n" + - "Examples:\n" + - " sentry attribute list --dataset logs # Attributes on logs instead of spans\n" + - " sentry attribute list --with gen_ai.request.model # Attributes seen on spans with gen_ai.request.model\n" + - " sentry attribute list --type string --search gen_ai # Attributes containing gen_ai and are type string\n" + - " sentry attribute list --period 24h # Get attributes that appeared in the last 24 hours\n" + - " sentry attribute list --context # Display description and examples for each attribute\n" + - " sentry attribute list --json --fields key,type # Output in JSON format with specified fields\n\n" + - "Alias: `sentry attributes` → `sentry attribute list`", - }, - output: { - human: formatAttributeList, - jsonTransform: ( - result: ListResult, - fields?: string[], - ) => jsonTransformListResult(result, fields), - }, - parameters: { - positional: LIST_TARGET_POSITIONAL, - flags: { - dataset: { - kind: "parsed", - parse: parseDataset, - brief: `Dataset to list attributes for (${VALID_DATASETS.join(", ")})`, - default: DEFAULT_DATASET, - }, - type: { - kind: "parsed", - parse: String, - brief: `Find attributes of these types (${VALID_TYPES.join(", ")}; repeatable, comma-separated)`, - variadic: true, - optional: true, - }, - search: { - kind: "parsed", - parse: String, - brief: "Find attribute keys containing this text (case-sensitive)", - optional: true, - }, - with: { - kind: "parsed", - parse: String, - brief: - "Find attributes that appear on the same items as all of these keys (repeatable, comma-separated)", - variadic: true, - optional: true, - }, - period: LIST_PERIOD_FLAG, - limit: { - ...buildListLimitFlag("attributes"), - parse: (value: string) => - validateLimit(value, LIST_MIN_LIMIT, LIST_MAX_LIMIT), - }, - context: { - kind: "boolean", - brief: - "Include each attribute's description, examples, and deprecation info (experimental API)", - default: false, - }, +function pageHint( + direction: "next" | "prev", + scope: string, + flags: AttributeListFlags, + types: readonly string[] | undefined, + withKeys: readonly string[] | undefined, +): string { + const parts = [`sentry attribute list ${scope} -c ${direction}`]; + if (flags.dataset !== DEFAULT_DATASET) { + parts.push(`--dataset ${datasetDisplayName(flags.dataset)}`); + } + if (types) { + parts.push(`--type ${types.join(",")}`); + } + if (flags.search) { + parts.push(`--search ${shellQuote(flags.search)}`); + } + for (const key of withKeys ?? []) { + parts.push(`--with ${shellQuote(key)}`); + } + appendPeriodHint(parts, flags.period, LIST_PERIOD_FLAG.default); + if (flags.limit !== LIST_DEFAULT_LIMIT) { + parts.push(`-n ${flags.limit}`); + } + if (flags.context) { + parts.push("--context"); + } + return parts.join(" "); +} + +export const listCommand = buildListCommand("attribute", { + docs: { + brief: "List attribute keys for spans, logs, or metrics", + fullDescription: + "List the attribute keys available on spans, logs, or metrics datasets.\n" + + "Attributes are typed key-value data attached to each span, log or metric sample.\n\n" + + "Columns:\n" + + " KEY Name identifier; can be used for other commands in -q/--fields\n" + + " e.g. span list -q 'has:tags[cart.total,number]'\n" + + " TYPE Value type of: string, number, boolean, array\n" + + " SOURCE One of: sentry (built-in) or user (set by your SDK)\n" + + " ALIASES Other names the attribute can be queried by\n" + + " DESCRIPTION What the attribute means; deprecated keys name replacements (pass --context)\n" + + " EXAMPLES Sample values seen for the attribute (pass --context)\n\n" + + "Target specification:\n" + + " sentry attribute list # auto-detect org from DSN or config\n" + + " sentry attribute list / # all projects in the organization\n" + + " sentry attribute list / # one project\n" + + " sentry attribute list # find project across all orgs\n\n" + + "Examples:\n" + + " sentry attribute list --dataset logs # Attributes on logs instead of spans\n" + + " sentry attribute list --with gen_ai.request.model # Attributes seen on spans with gen_ai.request.model\n" + + " sentry attribute list --type string --search gen_ai # Attributes containing gen_ai and are type string\n" + + " sentry attribute list --period 24h # Get attributes that appeared in the last 24 hours\n" + + " sentry attribute list --context # Display description and examples for each attribute\n" + + " sentry attribute list my-org/ -c next # Next page of the previous listing\n" + + " sentry attribute list --json --fields key,type # Output in JSON format with specified fields\n\n" + + "Alias: `sentry attributes` → `sentry attribute list`", + }, + output: { + human: formatAttributeList, + jsonTransform: (result: ListResult, fields?: string[]) => + jsonTransformListResult(result, fields), + }, + parameters: { + positional: LIST_TARGET_POSITIONAL, + flags: { + dataset: { + kind: "parsed", + parse: DATASETS.parse, + brief: `Dataset to list attributes for (${DATASETS.names.join(", ")})`, + default: DEFAULT_DATASET, + }, + type: { + kind: "parsed", + parse: String, + brief: `Find attributes of these types (${VALID_TYPES.join(", ")}; repeatable, comma-separated)`, + variadic: true, + optional: true, + }, + search: { + kind: "parsed", + parse: String, + brief: "Find attribute keys containing this text (case-sensitive)", + optional: true, }, - aliases: { - ...PERIOD_ALIASES, - d: "dataset", - n: "limit", - s: "search", + with: { + kind: "parsed", + parse: String, + brief: + "Find attributes that appear on the same items as all of these keys (repeatable, comma-separated)", + variadic: true, + optional: true, + }, + period: LIST_PERIOD_FLAG, + limit: { + ...buildListLimitFlag("attributes"), + parse: (value: string) => + validateLimit(value, LIST_MIN_LIMIT, LIST_MAX_LIMIT), + }, + context: { + kind: "boolean", + brief: + "Include each attribute's description, examples, and deprecation info (experimental API)", + default: false, }, }, - async *func( - this: SentryContext, - flags: AttributeListFlags, - target?: string, - ) { - const types = parseAttributeTypes(flags.type); - const withKeys = parseWithKeys(flags.with); - const { org, project } = await resolveOrgOptionalFromArg( - target, - this.cwd, - COMMAND_NAME, - ); - const scope = project ? `${org}/${project}` : `${org}/`; + aliases: { + ...PERIOD_ALIASES, + d: "dataset", + n: "limit", + s: "search", + }, + }, + async *func(this: SentryContext, flags: AttributeListFlags, target?: string) { + const types = parseAttributeTypes(flags.type); + const withKeys = parseWithKeys(flags.with); + const { org, project } = await resolveOrgOptionalFromArg( + target, + this.cwd, + COMMAND_NAME, + ); + const scope = project ? `${org}/${project}` : `${org}/`; - const { data, nextCursor } = await withProgress( - { message: "Fetching attributes...", json: flags.json }, - () => - listTraceItemAttributes(org, { - dataset: flags.dataset, - project: project ? [project] : undefined, - attributeTypes: types, - substringMatch: flags.search, - query: withKeys ? buildWithQuery(withKeys) : undefined, - context: flags.context, - perPage: flags.limit, - ...timeRangeToApiParams(flags.period), - }).catch((error: unknown): never => { - // A key Sentry can't parse is a user input mistake, not a CLI bug. - if ( - withKeys && - error instanceof ApiError && - isSearchQueryParseError(error) - ) { - throw new ValidationError( - `Sentry rejected --with ${withKeys.join(",")}${error.detail ? `: ${error.detail}` : ""}`, - "with", - ); - } - throw error; - }), - ); + // Sorted so each type keeps its position in the compound cursor + const requestTypes = [...(types ?? VALID_TYPES)].sort(); + const contextKey = buildPaginationContextKey("attribute", scope, { + dataset: flags.dataset, + type: requestTypes.join(","), + search: flags.search, + with: withKeys?.join(","), + period: serializeTimeRange(flags.period), + limit: String(flags.limit), + }); + const { cursor, direction } = resolveCursor( + flags.cursor, + PAGINATION_KEY, + contextKey, + ); + const { startCursors, exhausted } = decodeTargetCursors( + cursor, + requestTypes, + ); + const options = { + dataset: flags.dataset, + project: project ? [project] : undefined, + substringMatch: flags.search, + query: withKeys ? buildWithQuery(withKeys) : undefined, + context: flags.context, + ...timeRangeToApiParams(flags.period), + }; - // The API returns up to --limit + 1 rows *per type* (the extra row only - // signals more). Drop those look-ahead rows, then show the first --limit - // by name: without --type, a response holds one page per type and can - // exceed --limit. - const sorted = dropLookAheadRows(data, flags.limit).sort( - compareAttributes, - ); - const trimmed = sorted.length > flags.limit; - const items = sorted.slice(0, flags.limit).map(toAttributeListItem); - const hasMore = trimmed || !!nextCursor; + const pages = await withProgress( + { message: "Fetching attributes...", json: flags.json }, + () => + Promise.all( + requestTypes + .filter((type) => !exhausted.has(type)) + .map((type) => + fetchTypePage( + org, + type, + options, + flags.limit, + startCursors.get(type), + ), + ), + ).catch((error: unknown): never => { + // A key Sentry can't parse is a user input mistake, not a CLI bug. + if ( + withKeys && + error instanceof ApiError && + isSearchQueryParseError(error) + ) { + throw new ValidationError( + `Sentry rejected --with ${withKeys.join(",")}${error.detail ? `: ${error.detail}` : ""}`, + "with", + ); + } + throw error; + }), + ); - const datasetLabel = API_TO_USER_DATASET[flags.dataset] ?? flags.dataset; - const plural = (n: number) => `attribute${n === 1 ? "" : "s"}`; - const higherLimit = Math.min(flags.limit * 2, LIST_MAX_LIMIT); - const more = - higherLimit > flags.limit - ? `Use -n ${higherLimit} for more, or narrow with --type or --search.` - : "Narrow with --type or --search to see the rest."; - let countText: string | undefined; - if (trimmed) { - const total = `${sorted.length}${nextCursor ? "+" : ""}`; - countText = `Showing ${items.length} of ${total} ${datasetLabel} ${plural(sorted.length)} in ${scope}. ${more}`; - } else if (nextCursor) { - countText = `Showing ${items.length} ${datasetLabel} ${plural(items.length)} in ${scope}; more are available. ${more}`; - } else if (items.length > 0) { - countText = `Showing ${items.length} ${datasetLabel} ${plural(items.length)} in ${scope}.`; + // Each type returns up to --limit keys; show --limit of them in total. + const shown = selectAcrossTypes(pages, flags.limit); + const cursors = requestTypes.map((type) => { + const page = pages.find((p) => p.type === type); + if (!page) { + return null; // exhausted on an earlier page } + const shownOfType = shown.filter( + (attribute) => attribute.attributeType === type, + ).length; + if (shownOfType >= page.data.length) { + return page.nextCursor ?? null; + } + // Rows cut by --limit come back next page: resume at the start offset + // (`value:offset:isPrev`) plus the rows shown, not the API's cursor. + const start = Number(page.startCursor?.split(":")[1] ?? 0); + return `0:${start + shownOfType}:0`; + }); + const nextCursor = cursors.some((c) => c !== null) + ? encodeCompoundCursor(cursors) + : undefined; + advancePaginationState(PAGINATION_KEY, contextKey, direction, nextCursor); + const hasPrev = hasPreviousPage(PAGINATION_KEY, contextKey); + const hasMore = !!nextCursor; - const result: ListResult = { - items, - hasMore, - hint: - [countText, buildUsageTip(flags.dataset, org, project, items)] - .filter(Boolean) - .join("\n") || undefined, - }; - yield new CommandOutput(result); - return { hint: result.hint }; - }, + const items = shown.map(toAttributeListItem); + const datasetLabel = datasetDisplayName(flags.dataset); + const plural = (n: number) => `attribute${n === 1 ? "" : "s"}`; + const countText = + items.length > 0 + ? `Showing ${items.length} ${datasetLabel} ${plural(items.length)} in ${scope}${hasMore ? "; more are available" : ""}.` + : undefined; + const nav = paginationHint({ + hasPrev, + hasMore, + prevHint: pageHint("prev", scope, flags, types, withKeys), + nextHint: pageHint("next", scope, flags, types, withKeys), + }); + + const result: ListResult = { + items, + hasMore, + hasPrev, + nextCursor, + hint: + [countText, nav, buildUsageTip(flags.dataset, org, project, items)] + .filter(Boolean) + .join("\n") || undefined, + }; + yield new CommandOutput(result); + return { hint: result.hint }; }, - // No --cursor / -c: pagination. Instead this command makes a single request - // (up to --limit, max 1000) and users narrow using --type/--search/--with. - // The per_page parameter is per attribute type, so a single request can return more - // than --limit rows. The API can also append extra internal attributes, which can - // cause the results to exceed --limit as well. So to simplify logic, it drops - // extra rows and avoids pagination for now. - { noCursorFlag: true }, -); +}); diff --git a/packages/cli/src/commands/explore.ts b/packages/cli/src/commands/explore.ts index 699ad570c..7103c0c4e 100644 --- a/packages/cli/src/commands/explore.ts +++ b/packages/cli/src/commands/explore.ts @@ -25,6 +25,7 @@ import { hasPreviousPage, resolveCursor, } from "../lib/db/pagination.js"; +import { buildDatasetParser, datasetDisplayName } from "../lib/datasets.js"; import { toSearchQueryError, ValidationError } from "../lib/errors.js"; import { filterFields } from "../lib/formatters/json.js"; import { buildMetaColumns } from "../lib/formatters/meta-table.js"; @@ -83,43 +84,9 @@ const DEFAULT_PERIOD = "24h"; /** Command key for pagination cursor storage */ const PAGINATION_KEY = "explore"; -/** - * Dataset aliases: user-facing name → API-level dataset name. - * - * All entries are accepted as `--dataset` values. `VALID_DATASETS` controls - * which appear in `--help` and validation error messages. - */ -const DATASET_ALIASES: Record = { - errors: "errors", - error: "errors", - spans: "spans", - span: "spans", - metrics: "tracemetrics", - logs: "logs", - log: "logs", - replays: "replays", - replay: "replays", -}; - -/** - * User-facing dataset names shown in `--help` and validation errors. - * - * Set preserves insertion order for the join-based help/error rendering. - */ -const VALID_DATASETS = new Set([ - "errors", - "spans", - "metrics", - "logs", - "replays", -]); - -/** - * Reverse map from API-level dataset name → canonical user-facing name. - * Used by pagination hints so they emit `--dataset metrics` not `--dataset tracemetrics`. - */ -const API_TO_USER_DATASET = new Map( - Array.from(VALID_DATASETS, (name) => [DATASET_ALIASES[name] ?? name, name]), +const DATASETS = buildDatasetParser( + ["errors", "spans", "tracemetrics", "logs", "replays"], + [...LEGACY_TRANSACTION_DATASETS], ); // --------------------------------------------------------------------------- @@ -164,22 +131,6 @@ type ExploreData = { // Flag parsing // --------------------------------------------------------------------------- -/** - * Parse and validate dataset flag value. - * Accepts canonical names and common aliases. - */ -function parseDataset(value: string): string { - const lower = value.toLowerCase(); - const resolved = DATASET_ALIASES[lower]; - if (resolved || LEGACY_TRANSACTION_DATASETS.has(lower)) { - return resolved ?? lower; - } - throw new ValidationError( - `Invalid dataset "${value}". Must be one of: ${Array.from(VALID_DATASETS).join(", ")}`, - "dataset", - ); -} - /** * Parse --limit flag. Capped at {@link LIST_MAX_LIMIT} (1000). * `queryEvents` auto-paginates when the limit exceeds the API's per-page cap. @@ -273,7 +224,7 @@ function formatExploreHuman(data: ExploreData): string { ); const scope = data.project ? `${data.org}/${data.project}` : data.org; - const displayDataset = API_TO_USER_DATASET.get(data.dataset) ?? data.dataset; + const displayDataset = datasetDisplayName(data.dataset); const header = `Querying ${displayDataset} in ${scope}:\n\n`; return header + formatTable(data.data, columns); } @@ -373,9 +324,7 @@ function appendFlagHints( flags.dataset === "replays" ? DEFAULT_REPLAY_SORT : undefined; if (flags.dataset !== DEFAULT_DATASET) { // Emit user-facing name, not API-level name (e.g. "metrics" not "tracemetrics") - const displayDataset = - API_TO_USER_DATASET.get(flags.dataset) ?? flags.dataset; - parts.push(`--dataset ${displayDataset}`); + parts.push(`--dataset ${datasetDisplayName(flags.dataset)}`); } appendMetricHints(parts, flags.metric, flags.agg); appendSortHint(parts, flags.sort, defaultSort); @@ -660,8 +609,8 @@ export const exploreCommand = buildListCommand("explore", { }, dataset: { kind: "parsed", - parse: parseDataset, - brief: `Dataset to query (${[...VALID_DATASETS].join(", ")}; transaction(s) routes to spans)`, + parse: DATASETS.parse, + brief: `Dataset to query (${DATASETS.names.join(", ")}; transaction(s) routes to spans)`, default: DEFAULT_DATASET, }, query: { diff --git a/packages/cli/src/lib/api/traces.ts b/packages/cli/src/lib/api/traces.ts index 1baf64c43..20c69b22a 100644 --- a/packages/cli/src/lib/api/traces.ts +++ b/packages/cli/src/lib/api/traces.ts @@ -317,6 +317,8 @@ export type ListTraceItemAttributesOptions = { * deprecation) */ context?: boolean; + /** Pagination cursor from a previous page's `nextCursor` */ + cursor?: string; }; /** @@ -353,6 +355,7 @@ export async function listTraceItemAttributes( end: options.end, expand: options.context ? ["context"] : undefined, per_page: options.perPage, + cursor: options.cursor, }; const result = await listOrganizationTraceItemAttributes({ ...config, diff --git a/packages/cli/src/lib/datasets.ts b/packages/cli/src/lib/datasets.ts new file mode 100644 index 000000000..349e5d2f6 --- /dev/null +++ b/packages/cli/src/lib/datasets.ts @@ -0,0 +1,79 @@ +/** + * Explore datasets for `--dataset` flags: the user-facing names, their + * aliases, and the API names they map to. + */ + +import { ValidationError } from "./errors.js"; + +/** API-level dataset names */ +export type ExploreDataset = + | "errors" + | "spans" + | "logs" + | "tracemetrics" + | "replays"; + +/** Canonical user-facing name for each API dataset */ +const DISPLAY_NAMES: Record = { + errors: "errors", + spans: "spans", + logs: "logs", + tracemetrics: "metrics", + replays: "replays", +}; + +/** Every accepted spelling (lowercase) → API dataset */ +const ALIASES: Record = { + errors: "errors", + error: "errors", + spans: "spans", + span: "spans", + logs: "logs", + log: "logs", + metrics: "tracemetrics", + metric: "tracemetrics", + tracemetrics: "tracemetrics", + replays: "replays", + replay: "replays", +}; + +/** + * User-facing name for an API dataset, e.g. `tracemetrics` to `metrics`. + */ +export function datasetDisplayName(dataset: string): string { + return DISPLAY_NAMES[dataset as ExploreDataset] ?? dataset; +} + +/** + * Build a `--dataset` parser that accepts only `allowed` datasets, by their + * user-facing names or aliases, and returns the API name. + * + * @param allowed - API datasets the command supports, in help order + * @param passthrough - Extra lowercase values accepted and returned as-is + * (e.g. explore's legacy `transactions`) + * @returns The parser and the user-facing names for help and errors + */ +export function buildDatasetParser< + T extends ExploreDataset, + P extends string = never, +>( + allowed: readonly T[], + passthrough: readonly P[] = [], +): { parse: (value: string) => T | P; names: readonly string[] } { + const names = allowed.map(datasetDisplayName); + const parse = (value: string): T | P => { + const lower = value.trim().toLowerCase(); + const resolved = ALIASES[lower]; + if (resolved && (allowed as readonly ExploreDataset[]).includes(resolved)) { + return resolved as T; + } + if ((passthrough as readonly string[]).includes(lower)) { + return lower as P; + } + throw new ValidationError( + `Invalid dataset "${value}". Must be one of: ${names.join(", ")}`, + "dataset", + ); + }; + return { parse, names }; +} diff --git a/packages/cli/test/commands/attribute/list.test.ts b/packages/cli/test/commands/attribute/list.test.ts index d97a7d5af..7cb7297b5 100644 --- a/packages/cli/test/commands/attribute/list.test.ts +++ b/packages/cli/test/commands/attribute/list.test.ts @@ -2,8 +2,8 @@ * Attribute List Command Tests * * Tests for `sentry attribute list` in src/commands/attribute/list.ts: - * flag parsing, the API call it makes, JSON/human output, display names, - * --limit trimming, --context, and footer hints. + * flag parsing, the per-type API calls it makes, JSON/human output, display + * names, --limit trimming, -c next/prev, --context, and footer hints. */ import { afterEach, describe, expect, test, vi } from "vitest"; @@ -11,11 +11,10 @@ import { buildUsageTip, buildWithQuery, compareAttributes, - dropLookAheadRows, listCommand, parseAttributeTypes, - parseDataset, parseWithKeys, + selectAcrossTypes, } from "../../../src/commands/attribute/list.js"; import { describeAttribute, @@ -140,24 +139,29 @@ async function run( const listSpy = vi.mocked(apiClient.listTraceItemAttributes); const resolveSpy = vi.mocked(resolveTarget.resolveOrgOptionalFromArg); +/** + * Serve `data` the way Sentry's attributes endpoint does: one type per + * request, offset cursors, and the look-ahead row (perPage + 1) left in. + */ +function mockAttributes(data: Attr[]) { + listSpy.mockImplementation(async (_org, options) => { + const ofType = data.filter((a) => + options.attributeTypes?.includes(a.attributeType), + ); + const offset = Number(options.cursor?.split(":")[1] ?? 0); + const perPage = options.perPage ?? 100; + const rows = ofType.slice(offset, offset + perPage + 1); + return rows.length > perPage + ? { data: rows, nextCursor: `0:${offset + perPage}:0` } + : { data: rows }; + }); +} + afterEach(() => { listSpy.mockReset(); resolveSpy.mockReset(); }); -describe("parseDataset", () => { - test("maps user-facing names and aliases to API datasets", () => { - expect(parseDataset("spans")).toBe("spans"); - expect(parseDataset("span")).toBe("spans"); - expect(parseDataset("LOGS")).toBe("logs"); - expect(parseDataset("metrics")).toBe("tracemetrics"); - }); - - test("rejects unknown datasets", () => { - expect(() => parseDataset("errors")).toThrow(ValidationError); - }); -}); - describe("parseAttributeTypes", () => { test("accepts repeated and comma-separated values, deduplicated", () => { expect( @@ -250,21 +254,24 @@ describe("buildUsageTip", () => { }); }); -describe("dropLookAheadRows", () => { - test("keeps at most `limit` rows per type, in server order", () => { - const data = [ - attr("s1"), - attr("s2"), - attr("s3"), // look-ahead row for strings - attr("n1", "number", "user"), - attr("n2", "number", "user"), - ]; - expect(dropLookAheadRows(data, 2).map((a) => a.name)).toEqual([ - "s1", - "s2", - "n1", - "n2", - ]); +describe("selectAcrossTypes", () => { + // Server order (by usage), not by name + const strings = { + type: "string" as const, + data: [attr("zeta"), attr("alpha"), attr("mid")], + }; + const numbers = { + type: "number" as const, + data: [attr("beta", "number", "user")], + }; + + test("takes each type's rows in server order, then sorts for display", () => { + // "alpha" sorts first but comes after "zeta" for its type, so it waits: + // the rows shown of each type stay a prefix of what the server returned. + expect(selectAcrossTypes([strings, numbers], 2).map((a) => a.name)).toEqual( + ["beta", "zeta"], + ); + expect(selectAcrossTypes([strings, numbers], 10)).toHaveLength(4); }); }); @@ -302,18 +309,21 @@ describe("describeAttribute", () => { describe("listCommand.func", () => { test("lists span attributes for a project, 25 per page over 7d, sorted by name", async () => { resolveSpy.mockResolvedValue({ org: "my-org", project: "backend" }); - listSpy.mockResolvedValue({ data: ATTRIBUTES }); + mockAttributes(ATTRIBUTES); const output = await run({ json: true }, "my-org/backend"); + // One request per attribute type, each paginated on its own + expect(listSpy).toHaveBeenCalledTimes(4); expect(listSpy).toHaveBeenCalledWith("my-org", { dataset: "spans", project: ["backend"], - attributeTypes: undefined, + attributeTypes: ["string"], substringMatch: undefined, query: undefined, context: false, perPage: 25, + cursor: undefined, statsPeriod: "7d", }); expect(JSON.parse(output)).toEqual({ @@ -339,7 +349,7 @@ describe("listCommand.func", () => { test("passes dataset, types, search, --with, and limit through for an org-wide listing", async () => { resolveSpy.mockResolvedValue({ org: "my-org" }); - listSpy.mockResolvedValue({ data: [] }); + mockAttributes([]); await run( { @@ -354,21 +364,25 @@ describe("listCommand.func", () => { "my-org/", ); - expect(listSpy).toHaveBeenCalledWith("my-org", { - dataset: "logs", - project: undefined, - attributeTypes: ["string", "boolean"], - substringMatch: "http", - query: "has:gen_ai.request.model has:http.method", - context: false, - perPage: 50, - statsPeriod: "24h", - }); + expect(listSpy).toHaveBeenCalledTimes(2); + for (const type of ["boolean", "string"]) { + expect(listSpy).toHaveBeenCalledWith("my-org", { + dataset: "logs", + project: undefined, + attributeTypes: [type], + substringMatch: "http", + query: "has:gen_ai.request.model has:http.method", + context: false, + perPage: 50, + cursor: undefined, + statsPeriod: "24h", + }); + } }); test("shows the exact key in the table, including typed tags[...] keys", async () => { resolveSpy.mockResolvedValue({ org: "my-org" }); - listSpy.mockResolvedValue({ data: ATTRIBUTES }); + mockAttributes(ATTRIBUTES); const output = await run({}, "my-org/"); // Check the table rows only, not the Tip: line, which also prints a key. @@ -384,7 +398,7 @@ describe("listCommand.func", () => { test("--fields selects flattened keys in JSON output", async () => { resolveSpy.mockResolvedValue({ org: "my-org" }); - listSpy.mockResolvedValue({ data: ATTRIBUTES }); + mockAttributes(ATTRIBUTES); const output = await run( { json: true, fields: ["name", "type"] }, @@ -397,28 +411,30 @@ describe("listCommand.func", () => { ]); }); - test("renders a table and points at -n when the server has more", async () => { + test("renders a table and points at -c next with the same flags when the server has more", async () => { resolveSpy.mockResolvedValue({ org: "my-org" }); - listSpy.mockResolvedValue({ data: ATTRIBUTES, nextCursor: "0:100:0" }); + mockAttributes([HTTP_METHOD, attr("http.route"), attr("http.status")]); const output = await run( - { dataset: "logs", search: "http", type: ["string"], limit: 10 }, + { dataset: "logs", search: "http", type: ["string"], limit: 2 }, "my-org/", ); expect(output).toContain("TYPE"); expect(output).toContain("SOURCE"); expect(output).toContain("http.method"); - expect(output).toContain("-n 20"); + expect(output).toContain( + "Showing 2 logs attributes in my-org/; more are available.", + ); + expect(output).toContain( + "Next: sentry attribute list my-org/ -c next --dataset logs --type string --search 'http' -n 2", + ); }); test("drops the server's look-ahead row for a single type", async () => { resolveSpy.mockResolvedValue({ org: "my-org" }); // Sentry returns limit + 1 rows when more exist. - listSpy.mockResolvedValue({ - data: [attr("a"), attr("b"), attr("c")], - nextCursor: "0:2:0", - }); + mockAttributes([attr("a"), attr("b"), attr("c")]); const json = JSON.parse( await run({ json: true, limit: 2, type: ["string"] }, "my-org/"), @@ -427,26 +443,49 @@ describe("listCommand.func", () => { expect(json.hasMore).toBe(true); }); - test("trims a page over --limit, keeps hasMore, and suggests -n or narrowing", async () => { + test("trims across types to --limit, then -c next resumes the rows it cut", async () => { resolveSpy.mockResolvedValue({ org: "my-org" }); - listSpy.mockResolvedValue({ - data: [attr("c"), attr("a", "number", "user"), attr("b")], - nextCursor: "0:2:0", - }); + // Server order for strings is c, b: only c fits on the first page. + mockAttributes([attr("c"), attr("a", "number", "user"), attr("b")]); + + const first = JSON.parse(await run({ json: true, limit: 2 }, "my-org/")); + expect(first.data.map((a: { name: string }) => a.name)).toEqual(["a", "c"]); + expect(first.hasMore).toBe(true); + // Types sorted: array|boolean|number|string; only strings remain. + expect(first.nextCursor).toBe("|||0:1:0"); + + listSpy.mockClear(); + const second = JSON.parse( + await run({ json: true, limit: 2, cursor: "next" }, "my-org/"), + ); + expect(second.data.map((a: { name: string }) => a.name)).toEqual(["b"]); + expect(second.hasMore).toBe(false); + expect(second.hasPrev).toBe(true); + // Exhausted types aren't requested again. + expect(listSpy).toHaveBeenCalledTimes(1); + expect(listSpy).toHaveBeenCalledWith( + "my-org", + expect.objectContaining({ attributeTypes: ["string"], cursor: "0:1:0" }), + ); - const json = JSON.parse(await run({ json: true, limit: 2 }, "my-org/")); - expect(json.data.map((a: { name: string }) => a.name)).toEqual(["a", "b"]); - expect(json.hasMore).toBe(true); + const back = JSON.parse( + await run({ json: true, limit: 2, cursor: "prev" }, "my-org/"), + ); + expect(back.data.map((a: { name: string }) => a.name)).toEqual(["a", "c"]); + }); - const output = await run({ limit: 2 }, "my-org/"); - expect(output).toContain( - "Showing 2 of 3+ spans attributes in my-org/. Use -n 4 for more, or narrow with --type or --search.", + test("-c next without a previous listing explains how to start", async () => { + resolveSpy.mockResolvedValue({ org: "my-org" }); + mockAttributes(ATTRIBUTES); + + await expect(run({ cursor: "next" }, "my-org/")).rejects.toThrow( + ValidationError, ); }); test("prints a usage tip on its own line after the count", async () => { resolveSpy.mockResolvedValue({ org: "my-org", project: "backend" }); - listSpy.mockResolvedValue({ data: ATTRIBUTES }); + mockAttributes(ATTRIBUTES); const output = await run({}, "my-org/backend"); @@ -464,7 +503,7 @@ describe("listCommand.func", () => { test("says so when no attributes match", async () => { resolveSpy.mockResolvedValue({ org: "my-org" }); - listSpy.mockResolvedValue({ data: [] }); + mockAttributes([]); const output = await run({}, "my-org/"); @@ -473,9 +512,7 @@ describe("listCommand.func", () => { test("--context requests metadata and includes it in JSON", async () => { resolveSpy.mockResolvedValue({ org: "my-org" }); - listSpy.mockResolvedValue({ - data: [{ ...CART_TOTAL, context: {} }, CONTEXT_ATTRIBUTE], - }); + mockAttributes([{ ...CART_TOTAL, context: {} }, CONTEXT_ATTRIBUTE]); const output = await run({ json: true, context: true }, "my-org/"); @@ -490,7 +527,7 @@ describe("listCommand.func", () => { test("omits context from JSON without --context", async () => { resolveSpy.mockResolvedValue({ org: "my-org" }); - listSpy.mockResolvedValue({ data: ATTRIBUTES }); + mockAttributes(ATTRIBUTES); const output = await run({ json: true }, "my-org/"); @@ -501,10 +538,7 @@ describe("listCommand.func", () => { test("--context adds description and example columns to the table", async () => { resolveSpy.mockResolvedValue({ org: "my-org" }); - listSpy.mockResolvedValue({ - data: [CONTEXT_ATTRIBUTE], - nextCursor: "0:100:0", - }); + mockAttributes([CONTEXT_ATTRIBUTE]); const context = CONTEXT_ATTRIBUTE.context!; const examples = context.examples!.map(String); diff --git a/packages/cli/test/lib/datasets.test.ts b/packages/cli/test/lib/datasets.test.ts new file mode 100644 index 000000000..33bfaba6d --- /dev/null +++ b/packages/cli/test/lib/datasets.test.ts @@ -0,0 +1,53 @@ +/** + * Dataset name tests + * + * Tests for src/lib/datasets.ts: the shared `--dataset` names, aliases, and + * per-command subsets used by explore and attribute list. + */ + +import { describe, expect, test } from "vitest"; +import { + buildDatasetParser, + datasetDisplayName, +} from "../../src/lib/datasets.js"; +import { ValidationError } from "../../src/lib/errors.js"; + +describe("datasetDisplayName", () => { + test("maps API names to user-facing names", () => { + expect(datasetDisplayName("tracemetrics")).toBe("metrics"); + expect(datasetDisplayName("spans")).toBe("spans"); + }); + + test("returns unknown names unchanged", () => { + expect(datasetDisplayName("transactions")).toBe("transactions"); + }); +}); + +describe("buildDatasetParser", () => { + const { parse, names } = buildDatasetParser(["spans", "tracemetrics"]); + + test("accepts user-facing names and aliases, case-insensitively", () => { + expect(parse("spans")).toBe("spans"); + expect(parse(" Span ")).toBe("spans"); + expect(parse("metrics")).toBe("tracemetrics"); + expect(parse("METRIC")).toBe("tracemetrics"); + expect(parse("tracemetrics")).toBe("tracemetrics"); + }); + + test("lists user-facing names in the given order", () => { + expect(names).toEqual(["spans", "metrics"]); + }); + + test("rejects datasets outside the allowed set, naming the allowed ones", () => { + expect(() => parse("errors")).toThrow(ValidationError); + expect(() => parse("bogus")).toThrow( + 'Invalid dataset "bogus". Must be one of: spans, metrics', + ); + }); + + test("returns passthrough values as-is", () => { + const withLegacy = buildDatasetParser(["spans"], ["transactions"]); + expect(withLegacy.parse("Transactions")).toBe("transactions"); + expect(withLegacy.names).toEqual(["spans"]); + }); +}); From 72773a3448231e0e0b98c358167b74aac17976f8 Mon Sep 17 00:00:00 2001 From: lzhao-sentry Date: Fri, 23 Oct 2026 12:17:21 -0400 Subject: [PATCH 06/10] make --with, --search into positional filters --- .../src/fragments/commands/attribute.md | 30 --- .../sentry-cli/skills/sentry-cli/SKILL.md | 2 +- .../skills/sentry-cli/references/attribute.md | 25 +- packages/cli/src/commands/attribute/list.ts | 223 ++++++++++++------ .../cli/test/commands/attribute/list.test.ts | 116 ++++++--- 5 files changed, 251 insertions(+), 145 deletions(-) diff --git a/apps/cli-docs/src/fragments/commands/attribute.md b/apps/cli-docs/src/fragments/commands/attribute.md index 42395426a..e00cd87cb 100644 --- a/apps/cli-docs/src/fragments/commands/attribute.md +++ b/apps/cli-docs/src/fragments/commands/attribute.md @@ -1,35 +1,5 @@ -## Examples - -### List attributes - -```bash -# List span attribute keys for a project -sentry attribute list my-org/my-project - -# List log attribute keys across the whole organization -sentry attribute list my-org/ --dataset logs - -# Find keys containing "gen_ai" that are also of type string -sentry attribute list --search gen_ai --type string - -# Attributes seen on the same spans as the specified attribute -sentry attribute list --with gen_ai.request.model - -# Show descriptions, example values, and deprecation info -sentry attribute list --context - -# Show more than the default 25 -sentry attribute list my-org/my-project -n 100 - -# Show the next page of the previous listing (same flags) -sentry attribute list my-org/my-project -n 100 -c next - -# Select fields as JSON -sentry attribute list --json --fields key,type,source -``` - ## Using Attribute Keys Attributes are the typed key-value data attached to spans, logs, and metric samples. Their keys can be passed to other commands to filter, add columns, or group results: diff --git a/packages/cli/plugins/sentry-cli/skills/sentry-cli/SKILL.md b/packages/cli/plugins/sentry-cli/skills/sentry-cli/SKILL.md index 65b2c5478..f53bb9607 100644 --- a/packages/cli/plugins/sentry-cli/skills/sentry-cli/SKILL.md +++ b/packages/cli/plugins/sentry-cli/skills/sentry-cli/SKILL.md @@ -467,7 +467,7 @@ Manage Sentry alert rules Explore span, log, and metric attributes -- `sentry attribute list []` — List attribute keys for spans, logs, or metrics +- `sentry attribute list [] []` — List attribute keys for spans, logs, or metrics → Full flags and examples: `references/attribute.md` diff --git a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/attribute.md b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/attribute.md index a39df7bc0..3f342248f 100644 --- a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/attribute.md +++ b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/attribute.md @@ -11,15 +11,13 @@ requires: Explore span, log, and metric attributes -### `sentry attribute list []` +### `sentry attribute list [] []` List attribute keys for spans, logs, or metrics **Flags:** - `-d, --dataset - Dataset to list attributes for (spans, logs, metrics) - (default: "spans")` - `--type ... - Find attributes of these types (string, number, boolean, array; repeatable, comma-separated)` -- `-s, --search - Find attribute keys containing this text (case-sensitive)` -- `--with ... - Find attributes that appear on the same items as all of these keys (repeatable, comma-separated)` - `-t, --period - Time range: "7d", "2024-01-01..2024-02-01", ">=2024-01-01" - (default: "7d")` - `-n, --limit - Maximum number of attributes to list - (default: "25")` - `--context - Include each attribute's description, examples, and deprecation info (experimental API)` @@ -35,14 +33,23 @@ sentry attribute list my-org/my-project # List log attribute keys across the whole organization sentry attribute list my-org/ --dataset logs -# Find keys containing "gen_ai" that are also of type string -sentry attribute list --search gen_ai --type string +# String attributes whose keys contain gen_ai (case-sensitive) +sentry attribute list my-org/ gen_ai --type string -# Attributes seen on the same spans as the specified attribute -sentry attribute list --with gen_ai.request.model +# Attributes seen on the same spans as an attribute +sentry attribute list my-org/ has:gen_ai.request.model + +# Attributes seen alongside several keys (one quoted argument) +sentry attribute list my-org/ 'has:gen_ai.request.model has:gen_ai.usage.input_tokens' + +# Text and has: filters together +sentry attribute list my-org/ 'gen_ai has:http.method' + +# Attributes seen in the last 24 hours +sentry attribute list my-org/ --period 24h # Show descriptions, example values, and deprecation info -sentry attribute list --context +sentry attribute list my-org/ --context # Show more than the default 25 sentry attribute list my-org/my-project -n 100 @@ -51,7 +58,7 @@ sentry attribute list my-org/my-project -n 100 sentry attribute list my-org/my-project -n 100 -c next # Select fields as JSON -sentry attribute list --json --fields key,type,source +sentry attribute list my-org/ --json --fields key,type,source ``` All commands also support `--json`, `--fields`, `--help`, `--log-level`, and `--verbose` flags. diff --git a/packages/cli/src/commands/attribute/list.ts b/packages/cli/src/commands/attribute/list.ts index 38f51fb95..5fb2aa736 100644 --- a/packages/cli/src/commands/attribute/list.ts +++ b/packages/cli/src/commands/attribute/list.ts @@ -5,6 +5,11 @@ * or metrics) */ +import { + FilterType, + parseSearch, + Token, +} from "@sentry/toolkit-core/search-syntax"; import type { SentryContext } from "../../context.js"; import { paginate } from "../../lib/api/infrastructure.js"; import type { @@ -51,6 +56,7 @@ import { jsonTransformListResult, type ListResult, } from "../../lib/org-list.js"; +import { isGlobFilter } from "../../lib/list-filter.js"; import { withProgress } from "../../lib/polling.js"; import { resolveOrgOptionalFromArg } from "../../lib/resolve-target.js"; import { @@ -83,8 +89,6 @@ const VALID_TYPES: readonly TraceItemAttributeValueType[] = [ type AttributeListFlags = { readonly dataset: TraceItemAttributeDataset; readonly type?: readonly string[]; - readonly search?: string; - readonly with?: readonly string[]; readonly period: TimeRange; readonly context: boolean; readonly limit: number; @@ -94,41 +98,70 @@ type AttributeListFlags = { readonly fields?: string[]; }; -/** Characters that cannot appear in an attribute key passed to `--with`. */ -const INVALID_WITH_KEY = /[\s"'()]/; +/** A parsed filter argument. */ +type AttributeFilter = { + /** The `has:` terms, as Sentry's `query`: keys must co-occur with all of them */ + query?: string; + /** The free text: Sentry matches it as a substring of attribute keys */ + name?: string; +}; /** - * Normalize repeated and/or comma-separated `--with` values into a - * deduplicated list of attribute keys. + * Parse the filter with the shared search parser. `has:` terms go to Sentry + * as the `query` and free text as the `substringMatch`. The attributes + * endpoint only uses key names, so values and negation are not supported. + * OR is not supported. + * + * TODO: support globbing/fuzzy search + * * @internal Exported for testing */ -export function parseWithKeys( - values: readonly string[] | undefined, -): string[] | undefined { - if (!values?.length) { - return; +export function parseAttributeFilter( + filter: string | undefined, +): AttributeFilter { + if (!filter?.trim()) { + return {}; + } + const tokens = parseSearch(filter); + if (!tokens) { + throw new ValidationError(`Couldn't parse the filter "${filter}".`); } - const keys = new Set(); - for (const key of values.flatMap(parseFieldsList)) { - if (INVALID_WITH_KEY.test(key)) { + const hasTerms = new Set(); + const names: string[] = []; + for (const token of tokens) { + if (token.type === Token.SPACES) { + continue; + } + if ( + token.type === Token.FILTER && + token.filter === FilterType.HAS && + !token.negated && + token.value.text + ) { + hasTerms.add(token.text); + } else if (token.type === Token.FREE_TEXT) { + names.push(token.value.trim()); + } else { throw new ValidationError( - `Invalid attribute key "${key}" for --with. Pass keys as listed by this command, e.g. gen_ai.request.model.`, - "with", + `Unsupported filter "${token.text.trim()}". The filter accepts a search term and/or has: terms (no values, negation, or OR).`, ); } - keys.add(key); } - return keys.size > 0 ? [...keys] : undefined; -} - -/** - * Build the API `query` for `--with`. The attributes endpoint only uses the - * key names in its query (values are ignored) and returns keys that co-occur - * with all of them, so `has:` filters state exactly what it does. - * @internal Exported for testing - */ -export function buildWithQuery(keys: readonly string[]): string { - return keys.map((key) => `has:${key}`).join(" "); + const name = names.join(" ") || undefined; + if (name && /\s/.test(name)) { + throw new ValidationError( + `The filter accepts a single search term, got "${name}".`, + ); + } + if (name && isGlobFilter(name)) { + throw new ValidationError( + `Globs aren't supported in the filter ("${name}"). Use plain text.`, + ); + } + return { + query: hasTerms.size > 0 ? [...hasTerms].join(" ") : undefined, + name, + }; } /** @@ -270,21 +303,16 @@ function pageHint( scope: string, flags: AttributeListFlags, types: readonly string[] | undefined, - withKeys: readonly string[] | undefined, + filter: string | undefined, ): string { - const parts = [`sentry attribute list ${scope} -c ${direction}`]; + const filterArg = filter ? ` ${shellQuote(filter)}` : ""; + const parts = [`sentry attribute list ${scope}${filterArg} -c ${direction}`]; if (flags.dataset !== DEFAULT_DATASET) { parts.push(`--dataset ${datasetDisplayName(flags.dataset)}`); } if (types) { parts.push(`--type ${types.join(",")}`); } - if (flags.search) { - parts.push(`--search ${shellQuote(flags.search)}`); - } - for (const key of withKeys ?? []) { - parts.push(`--with ${shellQuote(key)}`); - } appendPeriodHint(parts, flags.period, LIST_PERIOD_FLAG.default); if (flags.limit !== LIST_DEFAULT_LIMIT) { parts.push(`-n ${flags.limit}`); @@ -314,15 +342,60 @@ export const listCommand = buildListCommand("attribute", { " sentry attribute list / # all projects in the organization\n" + " sentry attribute list / # one project\n" + " sentry attribute list # find project across all orgs\n\n" + - "Examples:\n" + - " sentry attribute list --dataset logs # Attributes on logs instead of spans\n" + - " sentry attribute list --with gen_ai.request.model # Attributes seen on spans with gen_ai.request.model\n" + - " sentry attribute list --type string --search gen_ai # Attributes containing gen_ai and are type string\n" + - " sentry attribute list --period 24h # Get attributes that appeared in the last 24 hours\n" + - " sentry attribute list --context # Display description and examples for each attribute\n" + - " sentry attribute list my-org/ -c next # Next page of the previous listing\n" + - " sentry attribute list --json --fields key,type # Output in JSON format with specified fields\n\n" + + "Filter (space-separated terms in one argument):\n" + + " Attribute keys containing (case-sensitive)\n" + + " has: Attributes seen on the same items as \n\n" + "Alias: `sentry attributes` → `sentry attribute list`", + examples: [ + { + description: "List span attribute keys for a project", + command: "sentry attribute list my-org/my-project", + }, + { + description: "List log attribute keys across the whole organization", + command: "sentry attribute list my-org/ --dataset logs", + }, + { + description: + "String attributes whose keys contain gen_ai (case-sensitive)", + command: "sentry attribute list my-org/ gen_ai --type string", + }, + { + description: "Attributes seen on the same spans as an attribute", + command: "sentry attribute list my-org/ has:gen_ai.request.model", + }, + { + description: + "Attributes seen alongside several keys (one quoted argument)", + command: + "sentry attribute list my-org/ 'has:gen_ai.request.model has:gen_ai.usage.input_tokens'", + }, + { + description: "Text and has: filters together", + command: "sentry attribute list my-org/ 'gen_ai has:http.method'", + }, + { + description: "Attributes seen in the last 24 hours", + command: "sentry attribute list my-org/ --period 24h", + }, + { + description: "Show descriptions, example values, and deprecation info", + command: "sentry attribute list my-org/ --context", + }, + { + description: "Show more than the default 25", + command: "sentry attribute list my-org/my-project -n 100", + }, + { + description: "Show the next page of the previous listing (same flags)", + command: "sentry attribute list my-org/my-project -n 100 -c next", + }, + { + description: "Select fields as JSON", + command: + "sentry attribute list my-org/ --json --fields key,type,source", + }, + ], }, output: { human: formatAttributeList, @@ -330,7 +403,19 @@ export const listCommand = buildListCommand("attribute", { jsonTransformListResult(result, fields), }, parameters: { - positional: LIST_TARGET_POSITIONAL, + positional: { + kind: "tuple", + parameters: [ + ...LIST_TARGET_POSITIONAL.parameters, + { + placeholder: "filter", + brief: + "Text in attribute keys and/or has: terms, e.g. 'gen_ai has:http.method'", + parse: String, + optional: true, + }, + ], + }, flags: { dataset: { kind: "parsed", @@ -345,20 +430,6 @@ export const listCommand = buildListCommand("attribute", { variadic: true, optional: true, }, - search: { - kind: "parsed", - parse: String, - brief: "Find attribute keys containing this text (case-sensitive)", - optional: true, - }, - with: { - kind: "parsed", - parse: String, - brief: - "Find attributes that appear on the same items as all of these keys (repeatable, comma-separated)", - variadic: true, - optional: true, - }, period: LIST_PERIOD_FLAG, limit: { ...buildListLimitFlag("attributes"), @@ -376,26 +447,31 @@ export const listCommand = buildListCommand("attribute", { ...PERIOD_ALIASES, d: "dataset", n: "limit", - s: "search", }, }, - async *func(this: SentryContext, flags: AttributeListFlags, target?: string) { + async *func( + this: SentryContext, + flags: AttributeListFlags, + target?: string, + filter?: string, + ) { const types = parseAttributeTypes(flags.type); - const withKeys = parseWithKeys(flags.with); + const { query, name } = parseAttributeFilter(filter); const { org, project } = await resolveOrgOptionalFromArg( target, this.cwd, COMMAND_NAME, ); const scope = project ? `${org}/${project}` : `${org}/`; + // Normalized filter, for the cursor's context key and page hints + const filterText = [name, query].filter(Boolean).join(" ") || undefined; // Sorted so each type keeps its position in the compound cursor const requestTypes = [...(types ?? VALID_TYPES)].sort(); const contextKey = buildPaginationContextKey("attribute", scope, { dataset: flags.dataset, type: requestTypes.join(","), - search: flags.search, - with: withKeys?.join(","), + filter: filterText, period: serializeTimeRange(flags.period), limit: String(flags.limit), }); @@ -411,8 +487,8 @@ export const listCommand = buildListCommand("attribute", { const options = { dataset: flags.dataset, project: project ? [project] : undefined, - substringMatch: flags.search, - query: withKeys ? buildWithQuery(withKeys) : undefined, + substringMatch: name, + query, context: flags.context, ...timeRangeToApiParams(flags.period), }; @@ -435,13 +511,12 @@ export const listCommand = buildListCommand("attribute", { ).catch((error: unknown): never => { // A key Sentry can't parse is a user input mistake, not a CLI bug. if ( - withKeys && + query && error instanceof ApiError && isSearchQueryParseError(error) ) { throw new ValidationError( - `Sentry rejected --with ${withKeys.join(",")}${error.detail ? `: ${error.detail}` : ""}`, - "with", + `Sentry rejected the filter "${query}"${error.detail ? `: ${error.detail}` : ""}`, ); } throw error; @@ -473,20 +548,20 @@ export const listCommand = buildListCommand("attribute", { const hasPrev = hasPreviousPage(PAGINATION_KEY, contextKey); const hasMore = !!nextCursor; - const items = shown.map(toAttributeListItem); const datasetLabel = datasetDisplayName(flags.dataset); const plural = (n: number) => `attribute${n === 1 ? "" : "s"}`; const countText = - items.length > 0 - ? `Showing ${items.length} ${datasetLabel} ${plural(items.length)} in ${scope}${hasMore ? "; more are available" : ""}.` + shown.length > 0 + ? `Showing ${shown.length} ${datasetLabel} ${plural(shown.length)} in ${scope}${hasMore ? "; more are available" : ""}.` : undefined; const nav = paginationHint({ hasPrev, hasMore, - prevHint: pageHint("prev", scope, flags, types, withKeys), - nextHint: pageHint("next", scope, flags, types, withKeys), + prevHint: pageHint("prev", scope, flags, types, filterText), + nextHint: pageHint("next", scope, flags, types, filterText), }); + const items = shown.map(toAttributeListItem); const result: ListResult = { items, hasMore, diff --git a/packages/cli/test/commands/attribute/list.test.ts b/packages/cli/test/commands/attribute/list.test.ts index 7cb7297b5..67ae6019a 100644 --- a/packages/cli/test/commands/attribute/list.test.ts +++ b/packages/cli/test/commands/attribute/list.test.ts @@ -9,11 +9,10 @@ import { afterEach, describe, expect, test, vi } from "vitest"; import { buildUsageTip, - buildWithQuery, compareAttributes, listCommand, parseAttributeTypes, - parseWithKeys, + parseAttributeFilter, selectAcrossTypes, } from "../../../src/commands/attribute/list.js"; import { @@ -129,10 +128,11 @@ type ListFunc = ( async function run( flagOverrides: Record, target?: string, + filter?: string, ): Promise { const { context, stdoutWrite } = createMockContext(); const func = (await listCommand.loader()) as unknown as ListFunc; - await func.call(context, flags(flagOverrides), target); + await func.call(context, flags(flagOverrides), target, filter); return stdoutWrite.mock.calls.map((c) => c[0]).join(""); } @@ -182,33 +182,47 @@ describe("parseAttributeTypes", () => { }); }); -describe("parseWithKeys / buildWithQuery", () => { - test("accepts repeated and comma-separated keys, deduplicated", () => { +describe("parseAttributeFilter", () => { + test("keeps has: terms (deduplicated, typed keys intact) as the query and free text as the name", () => { expect( - parseWithKeys(["gen_ai.request.model,http.method", " http.method "]), - ).toEqual(["gen_ai.request.model", "http.method"]); + parseAttributeFilter( + " has:gen_ai.request.model gen_ai has:tags[cart.total,number] has:gen_ai.request.model ", + ), + ).toEqual({ + query: "has:gen_ai.request.model has:tags[cart.total,number]", + name: "gen_ai", + }); + expect(parseAttributeFilter("http")).toEqual({ name: "http" }); }); - test("keeps typed tags[...] keys intact", () => { - expect(parseWithKeys(["tags[cart.total,number],http.method"])).toEqual([ - "tags[cart.total,number]", - "http.method", - ]); + test("returns nothing without a filter", () => { + expect(parseAttributeFilter(undefined)).toEqual({}); + expect(parseAttributeFilter(" ")).toEqual({}); }); - test("returns undefined when no keys are given", () => { - expect(parseWithKeys(undefined)).toBeUndefined(); - expect(parseWithKeys([" , "])).toBeUndefined(); + test("rejects values, negation, OR, and an empty has:", () => { + for (const bad of [ + "span.op:db", + "!has:http.method", + "has:a OR has:b", + "has:", + ]) { + expect(() => parseAttributeFilter(bad)).toThrow(ValidationError); + } }); - test("rejects keys that look like search syntax", () => { - expect(() => parseWithKeys(['span.op:"db"'])).toThrow(ValidationError); - expect(() => parseWithKeys(["span.op db"])).toThrow(ValidationError); + test("rejects globs, since Sentry matches the text literally", () => { + expect(() => parseAttributeFilter("gen_ai.*")).toThrow( + /Globs aren't supported/, + ); }); - test("builds has: filters for every key", () => { - expect(buildWithQuery(["gen_ai.request.model", "http.method"])).toBe( - "has:gen_ai.request.model has:http.method", + test("rejects more than one search term", () => { + expect(() => parseAttributeFilter("gen_ai http")).toThrow( + /single search term/, + ); + expect(() => parseAttributeFilter("gen_ai has:x http")).toThrow( + /single search term/, ); }); }); @@ -319,7 +333,6 @@ describe("listCommand.func", () => { dataset: "spans", project: ["backend"], attributeTypes: ["string"], - substringMatch: undefined, query: undefined, context: false, perPage: 25, @@ -347,7 +360,7 @@ describe("listCommand.func", () => { }); }); - test("passes dataset, types, search, --with, and limit through for an org-wide listing", async () => { + test("passes dataset, types, the has: filter, and limit through for an org-wide listing", async () => { resolveSpy.mockResolvedValue({ org: "my-org" }); mockAttributes([]); @@ -356,12 +369,11 @@ describe("listCommand.func", () => { json: true, dataset: "logs", type: ["string,boolean"], - search: "http", - with: ["gen_ai.request.model", "http.method"], period: parsePeriod("24h"), limit: 50, }, "my-org/", + "has:gen_ai.request.model has:http.method", ); expect(listSpy).toHaveBeenCalledTimes(2); @@ -370,7 +382,6 @@ describe("listCommand.func", () => { dataset: "logs", project: undefined, attributeTypes: [type], - substringMatch: "http", query: "has:gen_ai.request.model has:http.method", context: false, perPage: 50, @@ -416,7 +427,7 @@ describe("listCommand.func", () => { mockAttributes([HTTP_METHOD, attr("http.route"), attr("http.status")]); const output = await run( - { dataset: "logs", search: "http", type: ["string"], limit: 2 }, + { dataset: "logs", type: ["string"], limit: 2 }, "my-org/", ); @@ -427,7 +438,7 @@ describe("listCommand.func", () => { "Showing 2 logs attributes in my-org/; more are available.", ); expect(output).toContain( - "Next: sentry attribute list my-org/ -c next --dataset logs --type string --search 'http' -n 2", + "Next: sentry attribute list my-org/ -c next --dataset logs --type string -n 2", ); }); @@ -556,7 +567,7 @@ describe("listCommand.func", () => { expect(withContext).not.toContain(examples.at(-1)); }); - test("turns a Sentry query parse error into a --with validation error", async () => { + test("turns a Sentry query parse error into a filter validation error", async () => { resolveSpy.mockResolvedValue({ org: "my-org" }); listSpy.mockRejectedValue( new ApiError( @@ -566,9 +577,52 @@ describe("listCommand.func", () => { ), ); - await expect(run({ with: ["bad]key"] }, "my-org/")).rejects.toThrow( - /Sentry rejected --with bad\]key/, + await expect(run({}, "my-org/", "has:bad]key")).rejects.toThrow( + /Sentry rejected the filter "has:bad\]key"/, + ); + }); + + test("rejects a value filter before calling the API", async () => { + await expect(run({}, "my-org/", "span.op:db")).rejects.toThrow( + ValidationError, + ); + expect(listSpy).not.toHaveBeenCalled(); + }); + + test("sends the name term as substringMatch and has: terms as the query", async () => { + resolveSpy.mockResolvedValue({ org: "my-org" }); + mockAttributes([]); + + await run({ json: true }, "my-org/", "has:http.method gen_ai"); + + expect(listSpy).toHaveBeenCalledWith( + "my-org", + expect.objectContaining({ + substringMatch: "gen_ai", + query: "has:http.method", + }), + ); + }); + + test("pages a filtered listing with -c next, repeating the filter in the hint", async () => { + resolveSpy.mockResolvedValue({ org: "my-org" }); + mockAttributes([attr("gen_ai.a"), attr("gen_ai.b"), attr("gen_ai.c")]); + + const output = await run({ limit: 2 }, "my-org/", "gen_ai has:http.method"); + expect(output).toContain( + "Next: sentry attribute list my-org/ 'gen_ai has:http.method' -c next -n 2", + ); + + const next = JSON.parse( + await run( + { json: true, limit: 2, cursor: "next" }, + "my-org/", + "gen_ai has:http.method", + ), ); + expect(next.data.map((a: { name: string }) => a.name)).toEqual([ + "gen_ai.c", + ]); }); test("rejects an invalid --type before calling the API", async () => { From 0fd8f828655780d65e1cfc272d14f4557beaac06 Mon Sep 17 00:00:00 2001 From: lzhao-sentry Date: Fri, 23 Oct 2026 12:26:11 -0400 Subject: [PATCH 07/10] fix linting error --- packages/cli/test/lib/api/trace-item-attributes.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/cli/test/lib/api/trace-item-attributes.test.ts b/packages/cli/test/lib/api/trace-item-attributes.test.ts index 7a6a614ea..98634bca6 100644 --- a/packages/cli/test/lib/api/trace-item-attributes.test.ts +++ b/packages/cli/test/lib/api/trace-item-attributes.test.ts @@ -42,11 +42,11 @@ function mockOk(body: unknown, headers: Record = {}) { }); } -beforeEach(async () => { +beforeEach(() => { originalFetch = globalThis.fetch; capturedUrl = undefined; resetAuthenticatedFetch(); - await setAuthToken("test-token"); + setAuthToken("test-token"); setOrgRegion("test-org", DEFAULT_SENTRY_URL); }); From f1770dbea122c04be720afe59adeac1ab0e72af3 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Fri, 23 Oct 2026 21:28:05 +0000 Subject: [PATCH 08/10] ref(cli): Resolve the attribute list target in its positional Move attribute list to the target positional API from #1594: the target uses orgOptionalTarget, func awaits target.resolved, and the COMMAND_NAME constant goes away. The help's target section now matches the other migrated list commands. Co-Authored-By: BYK --- packages/cli/src/commands/attribute/list.ts | 36 ++++++++++--------- .../cli/test/commands/attribute/list.test.ts | 8 ++++- 2 files changed, 27 insertions(+), 17 deletions(-) diff --git a/packages/cli/src/commands/attribute/list.ts b/packages/cli/src/commands/attribute/list.ts index 5fb2aa736..7a8fd3982 100644 --- a/packages/cli/src/commands/attribute/list.ts +++ b/packages/cli/src/commands/attribute/list.ts @@ -48,9 +48,9 @@ import { LIST_MAX_LIMIT, LIST_MIN_LIMIT, LIST_PERIOD_FLAG, - LIST_TARGET_POSITIONAL, PERIOD_ALIASES, paginationHint, + targetPatternExplanation, } from "../../lib/list-command.js"; import { jsonTransformListResult, @@ -58,7 +58,10 @@ import { } from "../../lib/org-list.js"; import { isGlobFilter } from "../../lib/list-filter.js"; import { withProgress } from "../../lib/polling.js"; -import { resolveOrgOptionalFromArg } from "../../lib/resolve-target.js"; +import { + type OrgOptionalTarget, + orgOptionalTarget, +} from "../../lib/target-positional.js"; import { appendPeriodHint, serializeTimeRange, @@ -67,8 +70,6 @@ import { } from "../../lib/time-range.js"; import { shellQuote } from "../../lib/utils.js"; -const COMMAND_NAME = "attribute list"; - /** Cursor storage key for `-c next` / `-c prev` */ const PAGINATION_KEY = "attribute-list"; @@ -337,11 +338,12 @@ export const listCommand = buildListCommand("attribute", { " ALIASES Other names the attribute can be queried by\n" + " DESCRIPTION What the attribute means; deprecated keys name replacements (pass --context)\n" + " EXAMPLES Sample values seen for the attribute (pass --context)\n\n" + - "Target specification:\n" + - " sentry attribute list # auto-detect org from DSN or config\n" + - " sentry attribute list / # all projects in the organization\n" + - " sentry attribute list / # one project\n" + - " sentry attribute list # find project across all orgs\n\n" + + "Target patterns:\n" + + " sentry attribute list # auto-detect org from DSN or config\n" + + " sentry attribute list / # all projects in the organization\n" + + " sentry attribute list / # one project\n" + + " sentry attribute list # find project across all orgs\n\n" + + `${targetPatternExplanation()}\n\n` + "Filter (space-separated terms in one argument):\n" + " Attribute keys containing (case-sensitive)\n" + " has: Attributes seen on the same items as \n\n" + @@ -406,7 +408,13 @@ export const listCommand = buildListCommand("attribute", { positional: { kind: "tuple", parameters: [ - ...LIST_TARGET_POSITIONAL.parameters, + { + placeholder: "org/project", + brief: + "/ (all projects), /, or (search)", + parse: orgOptionalTarget, + optional: true, + }, { placeholder: "filter", brief: @@ -452,16 +460,12 @@ export const listCommand = buildListCommand("attribute", { async *func( this: SentryContext, flags: AttributeListFlags, - target?: string, + target: OrgOptionalTarget, filter?: string, ) { const types = parseAttributeTypes(flags.type); const { query, name } = parseAttributeFilter(filter); - const { org, project } = await resolveOrgOptionalFromArg( - target, - this.cwd, - COMMAND_NAME, - ); + const { org, project } = await target.resolved; const scope = project ? `${org}/${project}` : `${org}/`; // Normalized filter, for the cursor's context key and page hints const filterText = [name, query].filter(Boolean).join(" ") || undefined; diff --git a/packages/cli/test/commands/attribute/list.test.ts b/packages/cli/test/commands/attribute/list.test.ts index 67ae6019a..ee8bfd7c8 100644 --- a/packages/cli/test/commands/attribute/list.test.ts +++ b/packages/cli/test/commands/attribute/list.test.ts @@ -100,6 +100,7 @@ function createMockContext() { stdout: { write: stdoutWrite }, stderr: { write: stderrWrite }, cwd: "/tmp", + commandPrefix: ["sentry", "attribute", "list"], }, stdoutWrite, }; @@ -137,7 +138,7 @@ async function run( } const listSpy = vi.mocked(apiClient.listTraceItemAttributes); -const resolveSpy = vi.mocked(resolveTarget.resolveOrgOptionalFromArg); +const resolveSpy = vi.mocked(resolveTarget.resolveOrgOptionalTarget); /** * Serve `data` the way Sentry's attributes endpoint does: one type per @@ -327,6 +328,11 @@ describe("listCommand.func", () => { const output = await run({ json: true }, "my-org/backend"); + expect(resolveSpy).toHaveBeenCalledWith( + { type: "explicit", org: "my-org", project: "backend" }, + "/tmp", + "attribute list", + ); // One request per attribute type, each paginated on its own expect(listSpy).toHaveBeenCalledTimes(4); expect(listSpy).toHaveBeenCalledWith("my-org", { From 2c31f627614dd9ed5ed820ac34b3c816c65012ab Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Fri, 23 Oct 2026 22:53:34 +0000 Subject: [PATCH 09/10] ref(cli): Use named @sentry/api types for trace item attribute filters Switch TraceItemAttributeDataset and TraceItemAttributeValueType to the ListOrganizationTraceItemAttributesQuery{Dataset,AttributeType} exports added in @sentry/api (getsentry/toolkit#1643) instead of deriving them from the query shape. Co-Authored-By: BYK --- packages/cli/src/lib/api/traces.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/cli/src/lib/api/traces.ts b/packages/cli/src/lib/api/traces.ts index 20c69b22a..d29fa7da7 100644 --- a/packages/cli/src/lib/api/traces.ts +++ b/packages/cli/src/lib/api/traces.ts @@ -6,6 +6,8 @@ import { type ListOrganizationTraceItemAttributesData, + type ListOrganizationTraceItemAttributesQueryAttributeType, + type ListOrganizationTraceItemAttributesQueryDataset, type ListOrganizationTraceItemAttributesResponse, listOrganizationTraceItemAttributes, } from "@sentry/api"; @@ -262,14 +264,12 @@ type TraceItemAttributesQuery = NonNullable< >; /** Trace item dataset accepted by the attributes endpoint (e.g. "spans", "logs"). */ -export type TraceItemAttributeDataset = NonNullable< - TraceItemAttributesQuery["dataset"] ->; +export type TraceItemAttributeDataset = + ListOrganizationTraceItemAttributesQueryDataset; /** Attribute value type the attributes endpoint can filter on. */ -export type TraceItemAttributeValueType = NonNullable< - TraceItemAttributesQuery["attributeType"] ->[number]; +export type TraceItemAttributeValueType = + ListOrganizationTraceItemAttributesQueryAttributeType; /** * An attribute key available on a trace item dataset (name, type, and whether From 53c0fc27244dc0ee2b9e8cf4b04a3b5317dfb23d Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Fri, 23 Oct 2026 23:04:53 +0000 Subject: [PATCH 10/10] docs(cli): Mark attribute fragment cross-command example block The block shows attribute keys used by span list and explore, so it cannot move into attribute list's docs.examples. Mark it with allow-commands to satisfy the fragment check added in #1639. --- apps/cli-docs/src/fragments/commands/attribute.md | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/cli-docs/src/fragments/commands/attribute.md b/apps/cli-docs/src/fragments/commands/attribute.md index e00cd87cb..a7b415cfd 100644 --- a/apps/cli-docs/src/fragments/commands/attribute.md +++ b/apps/cli-docs/src/fragments/commands/attribute.md @@ -4,6 +4,7 @@ Attributes are the typed key-value data attached to spans, logs, and metric samples. Their keys can be passed to other commands to filter, add columns, or group results: + ```bash # Filter spans that have the attribute sentry span list my-org/my-project -q 'has:gen_ai.request.model'