diff --git a/apps/cli-docs/src/content/docs/contributing.md b/apps/cli-docs/src/content/docs/contributing.md index 473d573e7..e4ab98f30 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..a7b415cfd --- /dev/null +++ b/apps/cli-docs/src/fragments/commands/attribute.md @@ -0,0 +1,19 @@ + + +## 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/SKILL.md b/packages/cli/plugins/sentry-cli/skills/sentry-cli/SKILL.md index f5536126b..4f6440962 100644 --- a/packages/cli/plugins/sentry-cli/skills/sentry-cli/SKILL.md +++ b/packages/cli/plugins/sentry-cli/skills/sentry-cli/SKILL.md @@ -478,6 +478,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..3f342248f --- /dev/null +++ b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/attribute.md @@ -0,0 +1,64 @@ +--- +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)` +- `-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` +- `-c, --cursor - Navigate pages: "next", "prev", "first" (or raw cursor string)` + +**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 + +# 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 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 my-org/ --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 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/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..7a8fd3982 --- /dev/null +++ b/packages/cli/src/commands/attribute/list.ts @@ -0,0 +1,582 @@ +/** + * sentry attribute list + * + * List the attribute keys available on a trace item dataset (spans, logs, + * 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 { + 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, + ValidationError, +} from "../../lib/errors.js"; +import { + type AttributeListItem, + 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_DEFAULT_LIMIT, + LIST_MAX_LIMIT, + LIST_MIN_LIMIT, + LIST_PERIOD_FLAG, + PERIOD_ALIASES, + paginationHint, + targetPatternExplanation, +} from "../../lib/list-command.js"; +import { + jsonTransformListResult, + type ListResult, +} from "../../lib/org-list.js"; +import { isGlobFilter } from "../../lib/list-filter.js"; +import { withProgress } from "../../lib/polling.js"; +import { + type OrgOptionalTarget, + orgOptionalTarget, +} from "../../lib/target-positional.js"; +import { + appendPeriodHint, + serializeTimeRange, + type TimeRange, + timeRangeToApiParams, +} from "../../lib/time-range.js"; +import { shellQuote } from "../../lib/utils.js"; + +/** Cursor storage key for `-c next` / `-c prev` */ +const PAGINATION_KEY = "attribute-list"; + +/** Default dataset when `--dataset` is omitted */ +const DEFAULT_DATASET = "spans"; + +/** `--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[] = [ + "string", + "number", + "boolean", + "array", +]; + +type AttributeListFlags = { + readonly dataset: TraceItemAttributeDataset; + readonly type?: readonly string[]; + readonly period: TimeRange; + readonly context: boolean; + readonly limit: number; + readonly json: boolean; + readonly fresh: boolean; + readonly cursor?: string; + readonly fields?: string[]; +}; + +/** 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; +}; + +/** + * 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 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 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( + `Unsupported filter "${token.text.trim()}". The filter accepts a search term and/or has: terms (no values, negation, or OR).`, + ); + } + } + 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, + }; +} + +/** + * 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(parseFieldsList)) { + const value = raw.toLowerCase(); + if (!VALID_TYPES.includes(value as TraceItemAttributeValueType)) { + throw new ValidationError( + `Invalid attribute type "${raw}". 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) + ); +} + +/** + * 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") { + const target = project ? `${org}/${project}` : `${org}/`; + return `Tip: Group metrics with: sentry explore ${target} --dataset metrics -m -F ${shellQuote(key)}`; + } + const target = project ? `${org}/${project}` : `${org}/`; + 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)}`; +} + +/** 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 }; +} + +/** + * Pick up to `limit` rows across types, sorted by name for display. + * Taken in sorted order by name, then type. + * + * @internal Exported for testing + */ +export function selectAcrossTypes( + pages: readonly TypePage[], + limit: number, +): TraceItemAttributeDefinition[] { + 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); +} + +function pageHint( + direction: "next" | "prev", + scope: string, + flags: AttributeListFlags, + types: readonly string[] | undefined, + filter: string | undefined, +): string { + 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(",")}`); + } + 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 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" + + "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, + jsonTransform: (result: ListResult, fields?: string[]) => + jsonTransformListResult(result, fields), + }, + parameters: { + positional: { + kind: "tuple", + parameters: [ + { + placeholder: "org/project", + brief: + "/ (all projects), /, or (search)", + parse: orgOptionalTarget, + optional: true, + }, + { + 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", + 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, + }, + 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, + }, + }, + aliases: { + ...PERIOD_ALIASES, + d: "dataset", + n: "limit", + }, + }, + async *func( + this: SentryContext, + flags: AttributeListFlags, + target: OrgOptionalTarget, + filter?: string, + ) { + const types = parseAttributeTypes(flags.type); + const { query, name } = parseAttributeFilter(filter); + 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; + + // 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(","), + filter: filterText, + 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: name, + query, + context: flags.context, + ...timeRangeToApiParams(flags.period), + }; + + 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 ( + query && + error instanceof ApiError && + isSearchQueryParseError(error) + ) { + throw new ValidationError( + `Sentry rejected the filter "${query}"${error.detail ? `: ${error.detail}` : ""}`, + ); + } + throw error; + }), + ); + + // 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 datasetLabel = datasetDisplayName(flags.dataset); + const plural = (n: number) => `attribute${n === 1 ? "" : "s"}`; + const countText = + 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, filterText), + nextHint: pageHint("next", scope, flags, types, filterText), + }); + + const items = shown.map(toAttributeListItem); + 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 }; + }, +}); diff --git a/packages/cli/src/commands/explore.ts b/packages/cli/src/commands/explore.ts index f9d21594e..51a537f9f 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); @@ -726,8 +675,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-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 97da21964..d29fa7da7 100644 --- a/packages/cli/src/lib/api/traces.ts +++ b/packages/cli/src/lib/api/traces.ts @@ -4,6 +4,13 @@ * Functions for retrieving detailed traces, listing transactions, and listing spans. */ +import { + type ListOrganizationTraceItemAttributesData, + type ListOrganizationTraceItemAttributesQueryAttributeType, + type ListOrganizationTraceItemAttributesQueryDataset, + type ListOrganizationTraceItemAttributesResponse, + listOrganizationTraceItemAttributes, +} from "@sentry/api"; import pLimit from "p-limit"; import { @@ -30,9 +37,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"); @@ -246,6 +255,119 @@ 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 = + ListOrganizationTraceItemAttributesQueryDataset; + +/** Attribute value type the attributes endpoint can filter on. */ +export type TraceItemAttributeValueType = + ListOrganizationTraceItemAttributesQueryAttributeType; + +/** + * 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; + /** Pagination cursor from a previous page's `nextCursor` */ + cursor?: string; +}; + +/** + * 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, + cursor: options.cursor, + }; + 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/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/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/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 new file mode 100644 index 000000000..ee8bfd7c8 --- /dev/null +++ b/packages/cli/test/commands/attribute/list.test.ts @@ -0,0 +1,640 @@ +/** + * Attribute List Command Tests + * + * Tests for `sentry attribute list` in src/commands/attribute/list.ts: + * 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"; +import { + buildUsageTip, + compareAttributes, + listCommand, + parseAttributeTypes, + parseAttributeFilter, + selectAcrossTypes, +} 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", + commandPrefix: ["sentry", "attribute", "list"], + }, + stdoutWrite, + }; +} + +/** Flags as Stricli would hand them to func (defaults applied). */ +function flags(overrides: Record = {}) { + return { + dataset: "spans", + period: parsePeriod("7d"), + 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, + filter?: string, +): Promise { + const { context, stdoutWrite } = createMockContext(); + const func = (await listCommand.loader()) as unknown as ListFunc; + await func.call(context, flags(flagOverrides), target, filter); + return stdoutWrite.mock.calls.map((c) => c[0]).join(""); +} + +const listSpy = vi.mocked(apiClient.listTraceItemAttributes); +const resolveSpy = vi.mocked(resolveTarget.resolveOrgOptionalTarget); + +/** + * 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("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("parseAttributeFilter", () => { + test("keeps has: terms (deduplicated, typed keys intact) as the query and free text as the name", () => { + expect( + 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("returns nothing without a filter", () => { + expect(parseAttributeFilter(undefined)).toEqual({}); + expect(parseAttributeFilter(" ")).toEqual({}); + }); + + 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 globs, since Sentry matches the text literally", () => { + expect(() => parseAttributeFilter("gen_ai.*")).toThrow( + /Globs aren't supported/, + ); + }); + + 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/, + ); + }); +}); + +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 -m -F 'tags[cart.total,number]'", + ); + }); + + test("returns nothing for an empty page", () => { + expect(buildUsageTip("spans", "my-org", undefined, [])).toBeUndefined(); + }); +}); + +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); + }); +}); + +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 7d, sorted by name", async () => { + resolveSpy.mockResolvedValue({ org: "my-org", project: "backend" }); + mockAttributes(ATTRIBUTES); + + 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", { + dataset: "spans", + project: ["backend"], + attributeTypes: ["string"], + query: undefined, + context: false, + perPage: 25, + cursor: undefined, + statsPeriod: "7d", + }); + 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, the has: filter, and limit through for an org-wide listing", async () => { + resolveSpy.mockResolvedValue({ org: "my-org" }); + mockAttributes([]); + + await run( + { + json: true, + dataset: "logs", + type: ["string,boolean"], + period: parsePeriod("24h"), + limit: 50, + }, + "my-org/", + "has:gen_ai.request.model has:http.method", + ); + + expect(listSpy).toHaveBeenCalledTimes(2); + for (const type of ["boolean", "string"]) { + expect(listSpy).toHaveBeenCalledWith("my-org", { + dataset: "logs", + project: undefined, + attributeTypes: [type], + 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" }); + mockAttributes(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" }); + mockAttributes(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 -c next with the same flags when the server has more", async () => { + resolveSpy.mockResolvedValue({ org: "my-org" }); + mockAttributes([HTTP_METHOD, attr("http.route"), attr("http.status")]); + + const output = await run( + { dataset: "logs", type: ["string"], limit: 2 }, + "my-org/", + ); + + expect(output).toContain("TYPE"); + expect(output).toContain("SOURCE"); + expect(output).toContain("http.method"); + 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 -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. + mockAttributes([attr("a"), attr("b"), attr("c")]); + + 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 across types to --limit, then -c next resumes the rows it cut", async () => { + resolveSpy.mockResolvedValue({ org: "my-org" }); + // 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 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"]); + }); + + 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" }); + mockAttributes(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" }); + mockAttributes([]); + + 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" }); + mockAttributes([{ ...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" }); + mockAttributes(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" }); + mockAttributes([CONTEXT_ATTRIBUTE]); + 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 filter 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({}, "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 () => { + 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..98634bca6 --- /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(() => { + originalFetch = globalThis.fetch; + capturedUrl = undefined; + resetAuthenticatedFetch(); + 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/); + }); +}); 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"]); + }); +}); 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", () => {