From ee0800694d501ff0596f7cc71bde42725e8cfdb2 Mon Sep 17 00:00:00 2001 From: lzhao-sentry Date: Tue, 20 Oct 2026 11:22:32 -0400 Subject: [PATCH 1/3] 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 2/3] 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 3/3] 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",