diff --git a/packages/cli/AGENTS.md b/packages/cli/AGENTS.md index 3c149d4fb..8d8ed44e1 100644 --- a/packages/cli/AGENTS.md +++ b/packages/cli/AGENTS.md @@ -420,6 +420,7 @@ takes: | Comma-separated fixed values (`--scope`, `--features`) | `choiceListParser(values)` (or `(values, parse)`) | the next item of the list, leaving out the items already in it (`org:read,org:w` → `org:read,org:write`) | | Platform | `parsePlatform` | platform identifiers, common ones first | | Something that already exists in Sentry, as a flag value (`--environment`, `--release`, `--team`, `--owner`, `--integration`, `--app`, `--dsn`, `--workflow`, `--project`, `issue tags `) | `parseEnvironment`, `parseEnvironmentList`, `parseRelease`, `parseTeam`, `parseOwner` (`team:`), `parseIntegration`, `parseSentryApp`, `parseAppLinkField` (`name=value`), `parseDsn`, `parseWorkflowList`, `parseProjectSlug`, `parseProjectList`, `parseTagKey` | cached entities of the org the command's target names, else of every org, each once; the `*List` parsers complete the last item of a comma-separated list | +| Field or aggregate of a dataset (`explore --field`/`--sort`, `dashboard widget --group-by`/`--query`/`--sort`, `alert metrics --aggregate`) | `fieldParser`, `fieldOrAggregateParser`, `aggregateParser` (`fn(field)`), or `aggregateShorthandParser` (`fn:field`), given a `DatasetCompletion`: what completes on each dataset (start from `DATASET_FIELDS`), the command's `--dataset` aliases, and its default dataset | the fields of the dataset the `--dataset` typed names (else the default; every dataset when there is none): cached attribute keys of spans, logs, and trace metrics, cached issue tag keys for errors, or a fixed set (replays); then its aggregate functions, from `aggregateFunctionsOf` in `types/dashboard.ts`, as `fn()`, and `fn(field)` once a name is typed in full (`p95` → `p95(span.duration)`). A word starting with `-` completes flag names, so a descending sort's `-` is typed after completing | | Issue as a flag value (`issue merge --into`) | `parseIssueReference` | what `ISSUE_TARGET` completes | | Git ref, commit, or repository (`--base-ref`, `--head-sha`, `--repo`, `--commit REPO@SHA`, `set-commits --from`) | `parseVcsRef` (branch and tag names as the remote knows them), `parseGitRef` (refs git resolves locally), `parseGitCommit`, `parseGitRepo` (`owner/repo` of the remotes), `parseRepoCommitList` | read from git in the shell's directory; nothing outside a work tree | | A structured value with its own grammar (`issue archive --until`, `api `) | `parseArchiveUntil`, `parseApiEndpoint` | the grammar: `forever`, `auto`, `10x`, `10x/5m`, …; API schema paths one segment at a time, with cached orgs and projects for their placeholders | @@ -451,12 +452,14 @@ its module must not import `completion-target.ts`, or the two would load in a cycle. Cached entities (issues, dashboards, environments, releases, teams, monitors, alert workflows, integrations, Sentry Apps and their issue-link -form fields, DSNs, issue tag keys) come from the `completion_cache` table. The +form fields, DSNs, issue tag keys, attribute keys of each trace item +dataset) come from the `completion_cache` table. The authenticated fetch in `src/lib/sentry-client.ts` offers every API response to `src/lib/api/completion-seed.ts`, which stores entities from the `@sentry/api` -operations it lists (by URL template, type-checked against the SDK), so API -functions never seed by hand. To complete a new entity type, add it to -`CompletionEntityKind`, its operations in `completion-seed.ts` (one +operations it lists (by URL template, type-checked against the SDK, and the +query parameters that pick what a response holds, such as the attribute +keys' `dataset`), so API functions never seed by hand. To complete a new +entity type, add it to `CompletionEntityKind`, its operations in `completion-seed.ts` (one response may seed several kinds), a tagged parser, and an `entityCompleter` (target positionals) or `flagEntityCompleter` (flag values) entry in `src/lib/complete.ts`. To add a diff --git a/packages/cli/src/commands/alert/metrics/create.ts b/packages/cli/src/commands/alert/metrics/create.ts index a87e14aea..ea2e5651a 100644 --- a/packages/cli/src/commands/alert/metrics/create.ts +++ b/packages/cli/src/commands/alert/metrics/create.ts @@ -9,6 +9,7 @@ import { createMetricAlertRule } from "../../../lib/api-client.js"; import type { ParsedOrgProject } from "../../../lib/arg-parsing.js"; import { buildCommand, numberParser } from "../../../lib/command.js"; import { + aggregateParser, choiceParser, parseEnvironment, parseOwner, @@ -31,6 +32,7 @@ import { parseMetricTriggers, } from "../../../types/metric-alert.js"; import { + METRIC_ALERT_FIELD_COMPLETION, METRIC_TIME_WINDOW_VALUES, normalizeProjectList, parseJsonObjectList, @@ -144,7 +146,7 @@ export const createCommand = buildCommand({ }, aggregate: { kind: "parsed", - parse: parseText, + parse: aggregateParser(METRIC_ALERT_FIELD_COMPLETION), brief: "Aggregate expression (for example count(), p95(span.duration))", }, dataset: { diff --git a/packages/cli/src/commands/alert/metrics/edit.ts b/packages/cli/src/commands/alert/metrics/edit.ts index 6a45d24e1..47e66cf6e 100644 --- a/packages/cli/src/commands/alert/metrics/edit.ts +++ b/packages/cli/src/commands/alert/metrics/edit.ts @@ -11,6 +11,7 @@ import { } from "../../../lib/api-client.js"; import { buildCommand, numberParser } from "../../../lib/command.js"; import { + aggregateParser, choiceParser, parseEnvironment, parseOwner, @@ -28,6 +29,7 @@ import { parseMetricTriggers, } from "../../../types/metric-alert.js"; import { + METRIC_ALERT_FIELD_COMPLETION, METRIC_TIME_WINDOW_VALUES, normalizeMetricDataset, normalizeProjectList, @@ -243,7 +245,7 @@ export const editCommand = buildCommand({ }, aggregate: { kind: "parsed", - parse: parseText, + parse: aggregateParser(METRIC_ALERT_FIELD_COMPLETION), optional: true, brief: "Aggregate expression", }, diff --git a/packages/cli/src/commands/alert/mutation-utils.ts b/packages/cli/src/commands/alert/mutation-utils.ts index af03ba5b0..059522dd7 100644 --- a/packages/cli/src/commands/alert/mutation-utils.ts +++ b/packages/cli/src/commands/alert/mutation-utils.ts @@ -2,10 +2,16 @@ * Shared validation and parsing helpers for alert mutation commands (create/edit). */ +import { + DATASET_FIELDS, + type DatasetCompletion, + type DatasetFields, +} from "../../lib/completion-target.js"; import { ValidationError } from "../../lib/errors.js"; import { METRIC_ALERT_DATASET_ALIASES, METRIC_ALERT_DATASET_NAMES, + type MetricAlertDataset, metricAlertSource, } from "../../types/metric-alert.js"; @@ -14,6 +20,26 @@ export const ISSUE_MATCH_MODES = ["all", "any"] as const; /** Deprecated dataset names that now route to `spans` with `is_transaction:true`. */ const TRANSACTION_DATASET_NAMES = new Set(["transaction", "transactions"]); +/** + * What `--aggregate` completes, by the dataset `--dataset` names. Crash + * rate (`metrics`, `sessions`) aggregates are not fields of a dataset, so + * nothing completes for them; without `--dataset`, every dataset's. + */ +export const METRIC_ALERT_FIELD_COMPLETION: DatasetCompletion = { + datasets: { + errors: DATASET_FIELDS.errors, + events: DATASET_FIELDS.errors, + spans: DATASET_FIELDS.spans, + logs: DATASET_FIELDS.logs, + } satisfies Partial>, + aliases: { + ...METRIC_ALERT_DATASET_ALIASES, + ...Object.fromEntries( + [...TRANSACTION_DATASET_NAMES].map((name) => [name, "spans"]), + ), + }, +}; + /** Query filter appended when migrating a `transaction(s)` request to `spans`. */ const IS_TRANSACTION_FILTER = "is_transaction:true"; diff --git a/packages/cli/src/commands/dashboard/resolve.ts b/packages/cli/src/commands/dashboard/resolve.ts index 891a7931b..0001b9ee9 100644 --- a/packages/cli/src/commands/dashboard/resolve.ts +++ b/packages/cli/src/commands/dashboard/resolve.ts @@ -13,6 +13,11 @@ import { listDashboardsPaginated, } from "../../lib/api-client.js"; import type { ParsedOrgProject } from "../../lib/arg-parsing.js"; +import { + DATASET_FIELDS, + type DatasetCompletion, + type DatasetFields, +} from "../../lib/completion-target.js"; import { ApiError, ResolutionError, @@ -24,8 +29,11 @@ import { logger } from "../../lib/logger.js"; import { resolveOrgOnlyTarget } from "../../lib/resolve-target.js"; import { isAllDigits } from "../../lib/utils.js"; import { + AGGREGATE_ALIASES, + aggregateFunctionsOf, type DashboardWidget, DISPLAY_TYPES, + mapWidgetTypeToDataset, parseAggregate, parseSortExpression, parseWidgetInput, @@ -616,6 +624,44 @@ const DATASET_ALIASES: Record = { metricsenhanced: "tracemetrics", }; +/** + * What `--query`, `--sort`, and `--group-by` complete on a widget type: the + * fields of the dataset it queries, and the aggregate functions + * `validateAggregateNames` accepts on it, with the aliases that resolve to + * them. `undefined` for a widget type that queries no dataset. + */ +function widgetFields(type: string): DatasetFields | undefined { + const dataset = mapWidgetTypeToDataset(type); + if (!(dataset && dataset in DATASET_FIELDS)) { + return; + } + const functions = aggregateFunctionsOf(type); + return { + entity: DATASET_FIELDS[dataset as keyof typeof DATASET_FIELDS].entity, + functions: [ + ...functions, + ...Object.entries(AGGREGATE_ALIASES) + .filter(([, canonical]) => functions.includes(canonical)) + .map(([alias]) => alias), + ], + }; +} + +/** + * What `--query`, `--sort`, and `--group-by` complete, by the widget type + * `--dataset` names. Without `--dataset`, `widget edit` keeps the widget's, + * so it completes every type's; `widget add` sets the spans default. + */ +export const WIDGET_FIELD_COMPLETION: DatasetCompletion = { + datasets: Object.fromEntries( + WIDGET_TYPES.flatMap((type) => { + const fields = widgetFields(type); + return fields ? [[type, fields]] : []; + }), + ), + aliases: DATASET_ALIASES, +}; + /** * Normalize a user-provided `--dataset` value to the canonical widget type. * diff --git a/packages/cli/src/commands/dashboard/widget/add.ts b/packages/cli/src/commands/dashboard/widget/add.ts index a3c5846db..ed0caa772 100644 --- a/packages/cli/src/commands/dashboard/widget/add.ts +++ b/packages/cli/src/commands/dashboard/widget/add.ts @@ -8,8 +8,11 @@ import type { SentryContext } from "../../../context.js"; import { getDashboard, updateDashboard } from "../../../lib/api-client.js"; import { buildCommand, numberParser } from "../../../lib/command.js"; import { + aggregateShorthandParser, choiceParser, DASHBOARD_TARGET, + type DatasetCompletion, + fieldParser, parseText, } from "../../../lib/completion-target.js"; import { ValidationError } from "../../../lib/errors.js"; @@ -24,6 +27,7 @@ import { assignDefaultLayout, type DashboardDetail, type DashboardWidget, + DEFAULT_WIDGET_TYPE, DISPLAY_TYPES, FALLBACK_LAYOUT, GRID_COLUMN_POSITIONS, @@ -34,7 +38,6 @@ import { WIDGET_TYPES, type WidgetLayoutFlags, type WidgetLayoutMode, - AGGREGATE_FUNCTION_NAMES, } from "../../../types/dashboard.js"; import { type DashboardInput, @@ -47,9 +50,16 @@ import { withDashboardErrors, normalizeDataset, validateWidgetEnums, + WIDGET_FIELD_COMPLETION, type WidgetQueryFlags, } from "../resolve.js"; +/** What `--query`, `--sort`, and `--group-by` complete: a spans widget's by default. */ +const FIELD_COMPLETION: DatasetCompletion = { + ...WIDGET_FIELD_COMPLETION, + default: DEFAULT_WIDGET_TYPE, +}; + type AddFlags = WidgetQueryFlags & WidgetLayoutFlags & { readonly display: string; @@ -191,7 +201,7 @@ export const addCommand = buildCommand({ }, query: { kind: "parsed", - parse: choiceParser(AGGREGATE_FUNCTION_NAMES), + parse: aggregateShorthandParser(FIELD_COMPLETION), brief: "Aggregate expression (e.g. count, p95:span.duration)", variadic: true, optional: true, @@ -204,14 +214,14 @@ export const addCommand = buildCommand({ }, "group-by": { kind: "parsed", - parse: parseText, + parse: fieldParser(FIELD_COMPLETION), brief: "Group-by column (repeatable)", variadic: true, optional: true, }, sort: { kind: "parsed", - parse: choiceParser(AGGREGATE_FUNCTION_NAMES), + parse: aggregateShorthandParser(FIELD_COMPLETION), brief: "Order by (prefix - for desc, e.g. -count)", optional: true, }, diff --git a/packages/cli/src/commands/dashboard/widget/edit.ts b/packages/cli/src/commands/dashboard/widget/edit.ts index ea5ac34d1..44b080867 100644 --- a/packages/cli/src/commands/dashboard/widget/edit.ts +++ b/packages/cli/src/commands/dashboard/widget/edit.ts @@ -7,7 +7,12 @@ import type { SentryContext } from "../../../context.js"; import { getDashboard, updateDashboard } from "../../../lib/api-client.js"; import { buildCommand, numberParser } from "../../../lib/command.js"; -import { choiceParser, parseText } from "../../../lib/completion-target.js"; +import { + aggregateShorthandParser, + choiceParser, + fieldParser, + parseText, +} from "../../../lib/completion-target.js"; import { ValidationError } from "../../../lib/errors.js"; import { formatWidgetEdited } from "../../../lib/formatters/human.js"; import { CommandOutput } from "../../../lib/formatters/output.js"; @@ -29,7 +34,6 @@ import { validateWidgetLayout, WIDGET_TYPES, type WidgetLayoutFlags, - AGGREGATE_FUNCTION_NAMES, } from "../../../types/dashboard.js"; import { type DashboardArguments, dashboardArguments } from "../arguments.js"; import { @@ -39,6 +43,7 @@ import { resolveWidgetIndex, validateSortReferencesAggregate, validateWidgetEnums, + WIDGET_FIELD_COMPLETION, type WidgetQueryFlags, } from "../resolve.js"; @@ -268,7 +273,7 @@ export const editCommand = buildCommand({ }, query: { kind: "parsed", - parse: choiceParser(AGGREGATE_FUNCTION_NAMES), + parse: aggregateShorthandParser(WIDGET_FIELD_COMPLETION), brief: "Aggregate expression (e.g. count, p95:span.duration)", variadic: true, optional: true, @@ -281,14 +286,14 @@ export const editCommand = buildCommand({ }, "group-by": { kind: "parsed", - parse: parseText, + parse: fieldParser(WIDGET_FIELD_COMPLETION), brief: "Group-by column (repeatable)", variadic: true, optional: true, }, sort: { kind: "parsed", - parse: choiceParser(AGGREGATE_FUNCTION_NAMES), + parse: aggregateShorthandParser(WIDGET_FIELD_COMPLETION), brief: "Order by (prefix - for desc, e.g. -count)", optional: true, }, diff --git a/packages/cli/src/commands/explore.ts b/packages/cli/src/commands/explore.ts index 434340a2a..17b00df2f 100644 --- a/packages/cli/src/commands/explore.ts +++ b/packages/cli/src/commands/explore.ts @@ -16,6 +16,7 @@ import type { SentryContext } from "../context.js"; import { isReplaySortValue, listReplays, + REPLAY_SORT_FIELDS, queryEvents, queryEventsTimeSeries, queryMetricsMeta, @@ -23,6 +24,9 @@ import { import { validateLimit } from "../lib/arg-parsing.js"; import { choiceParser, + DATASET_FIELDS, + type DatasetCompletion, + fieldOrAggregateParser, parseEnvironmentList, parseText, textParser, @@ -112,6 +116,34 @@ const DATASETS = buildDatasetParser( [...LEGACY_TRANSACTION_DATASETS], ); +/** + * What `--field` completes on each dataset: attribute or tag keys and + * aggregates, or the replay fields the CLI renders. `transaction(s)` + * queries spans. + */ +const FIELD_COMPLETION: DatasetCompletion = { + datasets: { + ...DATASET_FIELDS, + replays: { fields: listSupportedReplayFields() }, + }, + aliases: { + ...DATASETS.aliases, + ...Object.fromEntries( + [...LEGACY_TRANSACTION_DATASETS].map((name) => [name, "spans"]), + ), + }, + default: DEFAULT_DATASET, +}; + +/** What `--sort` completes: `--field`'s, but replays sort by their own fields. */ +const SORT_COMPLETION: DatasetCompletion = { + ...FIELD_COMPLETION, + datasets: { + ...FIELD_COMPLETION.datasets, + replays: { fields: REPLAY_SORT_FIELDS }, + }, +}; + // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- @@ -773,7 +805,7 @@ export const exploreCommand = buildListCommand("explore", { flags: { field: { kind: "parsed", - parse: parseText, + parse: fieldOrAggregateParser(FIELD_COMPLETION), brief: 'API field or aggregate (repeatable). E.g., title, "count()", "p50(span.duration)"', variadic: true, @@ -806,7 +838,7 @@ export const exploreCommand = buildListCommand("explore", { }, sort: { kind: "parsed", - parse: parseText, + parse: fieldOrAggregateParser(SORT_COMPLETION), brief: 'Sort field (prefix with - for desc, e.g., "-count()")', optional: true, }, diff --git a/packages/cli/src/lib/api/completion-seed.ts b/packages/cli/src/lib/api/completion-seed.ts index 03ade6807..198d0ae62 100644 --- a/packages/cli/src/lib/api/completion-seed.ts +++ b/packages/cli/src/lib/api/completion-seed.ts @@ -7,7 +7,9 @@ * `src/lib/sentry-client.ts` is offered to {@link seedCompletions}, * which matches its path against {@link COMPLETION_SOURCES}: the * `@sentry/api` operations that return a completed entity (issues, - * dashboards, environments, releases, teams, …), by their URL templates. + * dashboards, environments, releases, teams, attribute keys, …), by their + * URL templates and, where a parameter picks what the response holds (the + * `dataset` of attribute keys), their query. * Any command that lists or fetches them therefore seeds the cache, without * the API functions having to remember to. * @@ -35,6 +37,7 @@ import type { ListOrganizationSentryAppComponentsData, ListOrganizationSentryAppInstallationsData, ListOrganizationTeamsData, + ListOrganizationTraceItemAttributesData, ListOrganizationWorkflowsData, ListProjectEnvironmentsData, ListProjectKeysData, @@ -62,6 +65,11 @@ type CompletionSource = { * parameter; `{organization_id_or_slug}` names the organization). */ url: string; + /** + * Query parameters the request must have, for an operation whose + * parameters decide what its response holds (`dataset=spans`). + */ + query?: Readonly>; /** Entities in the parsed JSON body, in display order. */ extract: (body: unknown) => CompletionEntity[]; /** Organization of a body whose path names none (unscoped endpoints). */ @@ -241,6 +249,36 @@ function dsnEntities(body: unknown): CompletionEntity[] { }); } +/** + * Attribute keys of a trace item dataset, the form `--field` and `--query` + * take (`tags[cart.total,number]` for a custom number), described by their + * type, after their display name when it differs. + */ +function attributeEntities(body: unknown): CompletionEntity[] { + return items(body).flatMap((attribute) => { + const value = stringField(attribute, "key"); + const name = stringField(attribute, "name"); + const type = stringField(attribute, "attributeType"); + return value + ? [{ value, label: name && name !== value ? `${name} (${type})` : type }] + : []; + }); +} + +/** Entity kind of the attribute keys of each trace item dataset. */ +const ATTRIBUTE_KINDS = { + spans: "span-attribute", + logs: "log-attribute", + tracemetrics: "metric-attribute", +} as const satisfies Partial< + Record< + NonNullable< + NonNullable["dataset"] + >, + CompletionEntityKind + > +>; + /** * Operations that return completed entities. Each `satisfies` ties a * template to the generated `url` of an `@sentry/api` operation, so an @@ -390,6 +428,12 @@ const COMPLETION_SOURCES: readonly CompletionSource[] = [ url: "/api/0/projects/{organization_id_or_slug}/{project_id_or_slug}/keys/" satisfies ListProjectKeysData["url"], extract: dsnEntities, }, + ...Object.entries(ATTRIBUTE_KINDS).map(([dataset, kind]) => ({ + kind, + url: "/api/0/organizations/{organization_id_or_slug}/trace-items/attributes/" satisfies ListOrganizationTraceItemAttributesData["url"], + query: { dataset }, + extract: attributeEntities, + })), ]; /** Path parameter in an `@sentry/api` URL template. */ @@ -421,16 +465,20 @@ type SourceMatch = { source: CompletionSource; orgSlug?: string }; * and `--owner` actors). */ function matchSources(url: string): SourceMatch[] { - let pathname: string; + let parsed: URL; try { - pathname = new URL(url).pathname; + parsed = new URL(url); } catch (error) { log.debug(`Not seeding completions from unparsable URL '${url}'`, error); return []; } return SOURCE_PATTERNS.flatMap(({ source, pattern }) => { - const match = pattern.exec(pathname); - if (!match) { + const match = pattern.exec(parsed.pathname); + const query = Object.entries(source.query ?? {}); + if ( + !match || + query.some(([name, value]) => parsed.searchParams.get(name) !== value) + ) { return []; } const org = match[1]; diff --git a/packages/cli/src/lib/complete.ts b/packages/cli/src/lib/complete.ts index 46cf430d1..ef7c09fbb 100644 --- a/packages/cli/src/lib/complete.ts +++ b/packages/cli/src/lib/complete.ts @@ -19,7 +19,11 @@ import { readdirSync, statSync } from "node:fs"; import { homedir, networkInterfaces } from "node:os"; import { join } from "node:path"; import generatedTargets from "../generated/completion-targets.json"; -import type { CompletionValues } from "./completion-target.js"; +import type { + CompletionValues, + DatasetCompletion, + DatasetFields, +} from "./completion-target.js"; import { getConfiguredSentryUrl } from "./constants.js"; import { type AccountSummary, @@ -150,8 +154,15 @@ const GENERATED = generatedTargets as { type CompleterContext = { /** Positionals before the word being completed, flags removed. */ positionals: string[]; + /** + * The value of each flag given before the word being completed, by its + * `--name` spelling (`-d spans` and `--dataset=spans` as `--dataset`). + */ + flagValues: Readonly>; /** The values a `choice` or `choice-list` argument offers. */ choices?: readonly string[]; + /** The fields and aggregates a field or aggregate flag completes. */ + datasets?: DatasetCompletion; }; /** What the word being completed is, and the positionals typed before it. */ @@ -164,6 +175,24 @@ type CompletionSlot = CompleterContext & { /** A flag's spelling without the value given with `=` (`--period=7d`). */ const FLAG_VALUE_RE = /=.*$/s; +/** Where a flag's spelling ends and its value begins (`--period=7d`). */ +const FLAG_VALUE_SEPARATOR = /=(.*)/s; + +/** The `--name` spelling of each of the command's flag spellings. */ +function flagNameOf( + command: CommandCompletions[string], +): (spelling: string) => string { + const names = new Map(); + for (const [name, { aliases = [] }] of Object.entries( + command.flagNames ?? {}, + )) { + for (const alias of aliases) { + names.set(alias, name); + } + } + return (spelling) => names.get(spelling) ?? spelling; +} + /** * The command's visible flags and their aliases, with their briefs, except * those already in `words`. Variadic flags may be given again. @@ -206,11 +235,18 @@ function findSlot(words: string[]): CompletionSlot | undefined { continue; } const positionals: string[] = []; + const flagValues: Record = {}; + const nameOf = flagNameOf(command); let flag: string | undefined; for (const word of words.slice(length)) { if (word.startsWith("-")) { - flag = word in command.flags ? word : undefined; + const [spelling = word, value] = word.split(FLAG_VALUE_SEPARATOR); + if (value !== undefined) { + flagValues[nameOf(spelling)] = value; + } + flag = value === undefined && word in command.flags ? word : undefined; } else if (flag !== undefined) { + flagValues[nameOf(flag)] = word; flag = undefined; } else { positionals.push(word); @@ -221,7 +257,9 @@ function findSlot(words: string[]): CompletionSlot | undefined { return { kind: command.flags[flag] ?? null, positionals, + flagValues, choices: command.choices?.[flag], + datasets: command.datasets?.[nameOf(flag)], flagNames, }; } @@ -236,6 +274,7 @@ function findSlot(words: string[]): CompletionSlot | undefined { return { kind: command.positionals[index] ?? null, positionals, + flagValues, choices, flagNames, }; @@ -381,6 +420,20 @@ const COMPLETERS = Object.freeze({ "bind-host": (partial) => completeBindHosts(partial), /** Cached tag keys of issues, described by their labels. */ "tag-key": flagEntityCompleter("tag-key"), + /** + * Fields of the dataset the command's `--dataset` names: cached attribute + * or tag keys, or a fixed set (replays). + */ + field: (partial, slot) => matchFields(partial, slot, datasetFieldsOf(slot)), + /** What `field` does, then `fn()` and `fn(field)` aggregates. */ + "field-or-aggregate": (partial, slot) => + completeAggregates(partial, slot, { fields: true }), + /** `fn()` and `fn(field)` aggregates of the `--dataset` given. */ + aggregate: (partial, slot) => + completeAggregates(partial, slot, { fields: false }), + /** Dashboard shorthand aggregates: `fn`, then `fn:field`. */ + "aggregate-shorthand": (partial, slot) => + completeShorthandAggregates(partial, slot), /** Servers of stored accounts. */ "account-server": (partial) => completeAccountServers(partial), /** @@ -864,6 +917,129 @@ function projectCompleter(options: { list: boolean }): Completer { }; } +/** + * What completes on the dataset the command's `--dataset` names, on its + * default dataset without one, or on every dataset when it has no default + * (`dashboard widget edit` keeps the widget's). + */ +function datasetFieldsOf(slot: CompleterContext): DatasetFields[] { + const completion = slot.datasets; + if (!completion) { + return []; + } + const given = slot.flagValues["--dataset"]?.toLowerCase(); + const name = + given === undefined + ? completion.default + : (completion.aliases?.[given] ?? given); + if (name === undefined) { + return Object.values(completion.datasets); + } + const fields = completion.datasets[name]; + return fields ? [fields] : []; +} + +/** + * The fields of `datasets` that match the partial: cached entities of the + * org the command's target names (else of every org), then fixed fields, + * each once. + */ +async function matchFields( + partial: string, + slot: CompleterContext, + datasets: readonly DatasetFields[], +): Promise { + const found = new Map(); + const orgs = datasets.some(({ entity }) => entity) + ? await flagOrgs(slot.positionals) + : []; + for (const { entity, fields = [] } of datasets) { + const cached = entity ? matchCachedEntities(entity, partial, orgs) : []; + for (const { value, label } of cached) { + if (!found.has(value)) { + found.set(value, label); + } + } + for (const value of fuzzyMatch(partial, fields, { mode: "search" })) { + if (!found.has(value)) { + found.set(value, undefined); + } + } + } + return [...found].map(([value, description]) => ({ value, description })); +} + +/** The aggregate function names of `datasets`, each once. */ +function functionsOf(datasets: readonly DatasetFields[]): string[] { + return [...new Set(datasets.flatMap(({ functions = [] }) => functions))]; +} + +/** + * Complete an aggregate call. Before the `(`, the function names that + * start with the partial, as `fn()`; a name typed in full also completes + * `fn(field)` for each field, so the shell inserts `fn(` and lists them. + * After the `(`, `fn(field)` for the fields that match. + * + * @param options.fields - The argument also takes a bare field + * (`explore --field title`): complete the fields before the functions + */ +async function completeAggregates( + partial: string, + slot: CompleterContext, + options: { fields: boolean }, +): Promise { + const datasets = datasetFieldsOf(slot); + const functions = functionsOf(datasets); + const calls = async (name: string, argument: string) => [ + ...(argument === "" ? [{ value: `${name}()` }] : []), + ...(await matchFields(argument, slot, datasets)).map( + ({ value, description }) => ({ value: `${name}(${value})`, description }), + ), + ]; + const open = partial.indexOf("("); + if (open !== -1) { + const name = partial.slice(0, open); + return functions.includes(name) + ? await calls(name, partial.slice(open + 1)) + : []; + } + const names = functions.filter((name) => name.startsWith(partial)); + return [ + ...(options.fields ? await matchFields(partial, slot, datasets) : []), + ...(names.includes(partial) ? await calls(partial, "") : []), + ...names + .filter((name) => name !== partial) + .map((name) => ({ value: `${name}()` })), + ]; +} + +/** + * Complete a dashboard shorthand aggregate: the function names that start + * with the partial, then, after `fn:`, `fn:field` for the fields that + * match (`p95:span.duration`). A word starting with `-` completes flag + * names, so a descending sort's `-` is typed after completing. + */ +async function completeShorthandAggregates( + partial: string, + slot: CompleterContext, +): Promise { + const datasets = datasetFieldsOf(slot); + const functions = functionsOf(datasets); + const colon = partial.indexOf(":"); + if (colon === -1) { + return functions + .filter((name) => name.startsWith(partial)) + .map((value) => ({ value })); + } + const name = partial.slice(0, colon); + if (!functions.includes(name)) { + return []; + } + return (await matchFields(partial.slice(colon + 1), slot, datasets)).map( + ({ value, description }) => ({ value: `${name}:${value}`, description }), + ); +} + const completeMonitors = flagEntityCompleter("monitor"); const completeAppLinkFields = flagEntityCompleter("app-link-field"); diff --git a/packages/cli/src/lib/completion-target.ts b/packages/cli/src/lib/completion-target.ts index 362c0abca..4ae387571 100644 --- a/packages/cli/src/lib/completion-target.ts +++ b/packages/cli/src/lib/completion-target.ts @@ -20,6 +20,7 @@ import { ISSUE_IS_VALUES } from "@sentry/toolkit-core/search-query"; import { numberParser } from "@stricli/core"; import type { CompletionTarget } from "./complete.js"; +import { aggregateFunctionsOf } from "../types/dashboard.js"; import { ISSUE_LEVELS } from "../types/sentry.js"; import { CURSOR_NAVIGATION, @@ -32,6 +33,7 @@ import { import { LOOPBACK_HOSTS } from "../commands/local/loopback.js"; import { INSTALLATION_METHODS, parseInstallationMethod } from "./binary.js"; import { parseLoginEmail } from "./db/accounts.js"; +import type { CompletionEntityKind } from "./db/completion-cache.js"; import { parseReleaseChannel, RELEASE_CHANNELS } from "./db/release-channel.js"; import { sanitizeIssueQuery, sanitizeQuery } from "./search-query.js"; import { parsePeriod, UNIT_SECONDS } from "./time-range.js"; @@ -136,6 +138,20 @@ export const DSN_TARGET = "dsn" satisfies CompletionTarget; /** Cached tag keys of issues. */ export const TAG_KEY_TARGET = "tag-key" satisfies CompletionTarget; +/** Field names of the dataset the command's `--dataset` names. */ +export const FIELD_TARGET = "field" satisfies CompletionTarget; + +/** {@link FIELD_TARGET} fields, or aggregates of them (`p95(span.duration)`). */ +export const FIELD_OR_AGGREGATE_TARGET = + "field-or-aggregate" satisfies CompletionTarget; + +/** Aggregates of {@link FIELD_TARGET} fields (`p95(span.duration)`). */ +export const AGGREGATE_TARGET = "aggregate" satisfies CompletionTarget; + +/** Dashboard shorthand aggregates (`count`, `p95:span.duration`). */ +export const AGGREGATE_SHORTHAND_TARGET = + "aggregate-shorthand" satisfies CompletionTarget; + /** Servers of stored accounts. */ export const ACCOUNT_SERVER_TARGET = "account-server" satisfies CompletionTarget; @@ -169,6 +185,48 @@ const COMPLETION_TARGET_KEY = Symbol("completionTarget"); const COMPLETION_CHOICES_KEY = Symbol("completionChoices"); +const COMPLETION_DATASETS_KEY = Symbol("completionDatasets"); + +/** What a field or aggregate argument completes on one dataset. */ +export type DatasetFields = { + /** Cached entities that name its fields: attribute keys, or tag keys */ + entity?: CompletionEntityKind; + /** Its fields, for a dataset whose set is fixed */ + fields?: readonly string[]; + /** Its aggregate function names */ + functions?: readonly string[]; +}; + +/** + * What a field or aggregate argument completes, by the dataset the + * command's `--dataset` names. + */ +export type DatasetCompletion = { + /** What completes on each dataset, by its name */ + datasets: Readonly>; + /** Other lowercase `--dataset` spellings, mapped to the name they mean */ + aliases?: Readonly>; + /** The dataset without `--dataset`; when omitted, every dataset */ + default?: string; +}; + +/** + * What fields and aggregates complete on each Explore dataset: the cached + * attribute keys of spans, logs, and trace metrics, and the tag keys seen + * on issues for errors, with the aggregate functions + * `validateAggregateNames` accepts. Trace metric aggregates name a metric + * (`sum(value,metric,type,unit)`), so only their fields complete. + */ +export const DATASET_FIELDS = { + errors: { + entity: "tag-key", + functions: aggregateFunctionsOf("error-events"), + }, + spans: { entity: "span-attribute", functions: aggregateFunctionsOf("spans") }, + logs: { entity: "log-attribute", functions: aggregateFunctionsOf("logs") }, + tracemetrics: { entity: "metric-attribute" }, +} as const satisfies Record; + /** * Tag a positional or flag parser with the completion kind it accepts. * @@ -310,6 +368,53 @@ export const parseGitRepo = identityParser(GIT_REPO_TARGET); /** Parses `REPO@SHA` commits; completes remotes, then recent commits. */ export const parseRepoCommitList = identityParser(REPO_COMMIT_LIST_TARGET); +/** + * A parser that returns its input unchanged, tagged with `kind` and the + * fields and aggregates it completes on each dataset. + */ +function datasetParser( + kind: CompletionTarget, + completion: DatasetCompletion, +): (input: string) => string { + const tagged = identityParser(kind); + Object.defineProperty(tagged, COMPLETION_DATASETS_KEY, { value: completion }); + return tagged; +} + +/** Parses a field name; completes those of the `--dataset` given. */ +export function fieldParser( + completion: DatasetCompletion, +): (input: string) => string { + return datasetParser(FIELD_TARGET, completion); +} + +/** + * Parses a field or an aggregate (`title`, `count()`, `p95(span.duration)`); + * completes the fields and aggregate functions of the `--dataset` given. + */ +export function fieldOrAggregateParser( + completion: DatasetCompletion, +): (input: string) => string { + return datasetParser(FIELD_OR_AGGREGATE_TARGET, completion); +} + +/** Parses an aggregate (`count()`, `p95(span.duration)`); completes them. */ +export function aggregateParser( + completion: DatasetCompletion, +): (input: string) => string { + return datasetParser(AGGREGATE_TARGET, completion); +} + +/** + * Parses a dashboard shorthand aggregate (`count`, `p95:span.duration`); + * completes function names, then a field after the `:`. + */ +export function aggregateShorthandParser( + completion: DatasetCompletion, +): (input: string) => string { + return datasetParser(AGGREGATE_SHORTHAND_TARGET, completion); +} + /** * Wrap a command's own parser of free text or numbers; completes nothing. * The value is still parsed and validated by `parse`. @@ -436,6 +541,22 @@ export function getCompletionChoices( ); } +/** + * Read the fields and aggregates a parser made by {@link fieldParser} and + * its siblings completes. + * + * @returns Them, or `undefined` for any other parser + */ +export function getCompletionDatasets( + parse: unknown, +): DatasetCompletion | undefined { + return typeof parse === "function" + ? (parse as { [COMPLETION_DATASETS_KEY]?: DatasetCompletion })[ + COMPLETION_DATASETS_KEY + ] + : undefined; +} + /** Fixed values the completers offer (see {@link COMPLETION_VALUES}). */ export type CompletionValues = { /** Issue search keys with a fixed set of values, for {@link ISSUE_QUERY_TARGET} */ diff --git a/packages/cli/src/lib/datasets.ts b/packages/cli/src/lib/datasets.ts index 349e5d2f6..1c29205c9 100644 --- a/packages/cli/src/lib/datasets.ts +++ b/packages/cli/src/lib/datasets.ts @@ -51,7 +51,9 @@ export function datasetDisplayName(dataset: string): string { * @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 + * @returns The parser, the user-facing names for help and errors, and + * the API dataset of each name and alias (lowercase), without the + * passthrough values */ export function buildDatasetParser< T extends ExploreDataset, @@ -59,8 +61,17 @@ export function buildDatasetParser< >( allowed: readonly T[], passthrough: readonly P[] = [], -): { parse: (value: string) => T | P; names: readonly string[] } { +): { + parse: (value: string) => T | P; + names: readonly string[]; + aliases: Readonly>; +} { const names = allowed.map(datasetDisplayName); + const aliases = Object.fromEntries( + Object.entries(ALIASES).filter(([, dataset]) => + (allowed as readonly ExploreDataset[]).includes(dataset), + ), + ) as Record; const parse = (value: string): T | P => { const lower = value.trim().toLowerCase(); const resolved = ALIASES[lower]; @@ -75,5 +86,5 @@ export function buildDatasetParser< "dataset", ); }; - return { parse, names }; + return { parse, names, aliases }; } diff --git a/packages/cli/src/lib/db/completion-cache.ts b/packages/cli/src/lib/db/completion-cache.ts index 67d0fcc74..205489f4e 100644 --- a/packages/cli/src/lib/db/completion-cache.ts +++ b/packages/cli/src/lib/db/completion-cache.ts @@ -39,6 +39,9 @@ import { runUpsert } from "./utils.js"; * form, and `name=value` for its fixed choices, with the app * - `dsn`: public DSN of a client key, with the key name * - `tag-key`: key of a tag seen on an issue, with its label + * - `span-attribute`, `log-attribute`, `metric-attribute`: key of an + * attribute of the spans, logs, or trace metrics dataset, as `--field` + * and `--query` take it, with its type */ export type CompletionEntityKind = | "issue" @@ -53,7 +56,10 @@ export type CompletionEntityKind = | "sentry-app" | "app-link-field" | "dsn" - | "tag-key"; + | "tag-key" + | "span-attribute" + | "log-attribute" + | "metric-attribute"; /** Most recently seen entries kept per kind and org. */ export const MAX_ENTITIES_PER_ORG = 500; diff --git a/packages/cli/src/lib/introspect.ts b/packages/cli/src/lib/introspect.ts index f9a2d3354..7b6f84381 100644 --- a/packages/cli/src/lib/introspect.ts +++ b/packages/cli/src/lib/introspect.ts @@ -15,7 +15,9 @@ import { type CommandExample, exampleSteps } from "./command-example.js"; import { CHOICE_TARGET, type CompletionTarget, + type DatasetCompletion, getCompletionChoices, + getCompletionDatasets, getCompletionTarget, } from "./completion-target.js"; import type { ApiReference, CommandApiReference } from "./api-schema.js"; @@ -427,6 +429,12 @@ type CommandCompletion = { * has none. */ choices?: Record; + /** + * Fields and aggregates of each field or aggregate flag, by the dataset + * its command's `--dataset` names, keyed by `--name` spelling only, since + * they are large. Omitted when the command has none. + */ + datasets?: Record; /** * Every visible flag by its `--name` spelling, booleans included, for * completing flag names. Omitted when the command has none. @@ -486,6 +494,7 @@ function getCommandCompletion(command: Command): CommandCompletion { return kind; }); const flagKinds: Record = {}; + const datasets: Record = {}; for (const [name, def] of Object.entries(flags)) { if (def.kind !== "boolean") { const { kind, values } = argumentCompletion( @@ -496,6 +505,10 @@ function getCommandCompletion(command: Command): CommandCompletion { if (values) { choices[`--${name}`] = values; } + const fields = getCompletionDatasets(def.parse); + if (fields) { + datasets[`--${name}`] = fields; + } } } const flagNames: Record = {}; @@ -528,6 +541,9 @@ function getCommandCompletion(command: Command): CommandCompletion { ...(Object.keys(choices).length > 0 ? { choices: sortedByKey(choices) } : {}), + ...(Object.keys(datasets).length > 0 + ? { datasets: sortedByKey(datasets) } + : {}), ...(Object.keys(flagNames).length > 0 ? { flagNames: sortedByKey(flagNames) } : {}), diff --git a/packages/cli/src/types/dashboard.ts b/packages/cli/src/types/dashboard.ts index 735527229..d38f6dd4d 100644 --- a/packages/cli/src/types/dashboard.ts +++ b/packages/cli/src/types/dashboard.ts @@ -295,16 +295,18 @@ export const ERROR_AGGREGATE_FUNCTIONS = [ export type ErrorAggregateFunction = (typeof ERROR_AGGREGATE_FUNCTIONS)[number]; /** - * The function names `--query` and `--sort` complete: those of every - * dataset's aggregates and their aliases, as {@link parseAggregate} reads - * them (`count`, then `p95:span.duration` once a field follows). A word - * starting with `-` completes flag names, so a descending sort's `-` is - * typed after the name is completed. + * The aggregate functions {@link validateAggregateNames} accepts on a widget + * dataset: none on `tracemetrics`, whose aggregates name a metric instead + * (`aggregation(value,metric_name,metric_type,unit)`). */ -export const AGGREGATE_FUNCTION_NAMES: readonly string[] = [ - ...ERROR_AGGREGATE_FUNCTIONS, - ...Object.keys(AGGREGATE_ALIASES), -]; +export function aggregateFunctionsOf(dataset?: string): readonly string[] { + if (dataset === "tracemetrics") { + return []; + } + return dataset === "error-events" + ? ERROR_AGGREGATE_FUNCTIONS + : SPAN_AGGREGATE_FUNCTIONS; +} /** Valibot schema for validating a span aggregate function name */ export const SpanAggregateFunctionSchema = picklist(SPAN_AGGREGATE_FUNCTIONS); @@ -442,10 +444,7 @@ export function validateAggregateNames( return; } - const validFunctions: readonly string[] = - dataset === "error-events" - ? ERROR_AGGREGATE_FUNCTIONS - : SPAN_AGGREGATE_FUNCTIONS; + const validFunctions = aggregateFunctionsOf(dataset); for (const agg of aggregates) { const fn = extractFunctionName(agg); diff --git a/packages/cli/test/lib/complete.test.ts b/packages/cli/test/lib/complete.test.ts index fe126bbfc..2dc474394 100644 --- a/packages/cli/test/lib/complete.test.ts +++ b/packages/cli/test/lib/complete.test.ts @@ -1194,8 +1194,15 @@ describe("getCompletions: fixed values from the modules that validate them", () "p95", "p99", ]); + // A spans widget unless --dataset names another expect( await values(["dashboard", "widget", "add", "--query"], "count"), + ).toEqual(["count", "count_unique"]); + expect( + await values( + ["dashboard", "widget", "add", "--dataset", "errors", "-q"], + "count", + ), ).toEqual(["count", "count_unique", "count_if", "count_at_least"]); expect( await values(["dashboard", "widget", "edit", "--sort"], "ep"), @@ -1217,3 +1224,139 @@ describe("getCompletions: fixed values from the modules that validate them", () ]); }); }); + +describe("getCompletions: fields and aggregates of the --dataset given", () => { + async function seedFields(): Promise { + await seedOrgs([ + { slug: "acme", name: "Acme" }, + { slug: "beta", name: "Beta" }, + ]); + cacheCompletionEntities("span-attribute", "acme", [ + { value: "span.duration", label: "number" }, + { value: "http.method", label: "string" }, + ]); + cacheCompletionEntities("span-attribute", "beta", [ + { value: "cache.hit", label: "boolean" }, + ]); + cacheCompletionEntities("log-attribute", "acme", [ + { value: "severity", label: "string" }, + ]); + cacheCompletionEntities("tag-key", "acme", [ + { value: "browser.name", label: "Browser.Name" }, + ]); + } + + test("explore --field completes the default dataset's tag keys and aggregates", async () => { + await seedFields(); + + expect(await getCompletions(["explore", "acme/", "-F"], "brow")).toEqual([ + { value: "browser.name", description: "Browser.Name" }, + ]); + expect(await values(["explore", "acme/", "--field"], "count_")).toEqual([ + "count_unique()", + "count_if()", + "count_at_least()", + ]); + }); + + test("the fields follow --dataset, by name, alias, or with =", async () => { + await seedFields(); + + expect( + await values(["explore", "acme/", "--dataset", "spans", "-F"], "span"), + ).toEqual(["span.duration"]); + expect( + await values(["explore", "acme/", "-d", "log", "--field"], ""), + ).toContain("severity"); + expect( + await values( + ["explore", "acme/", "--dataset=transactions", "-F"], + "http", + ), + ).toEqual(["http.method"]); + expect(await values(["explore", "acme/", "-d", "nope", "-F"], "")).toEqual( + [], + ); + }); + + test("a function typed in full completes calls with each field", async () => { + await seedFields(); + const field = ["explore", "acme/", "-d", "spans", "-F"]; + + expect(await values(field, "p95")).toEqual([ + "p95()", + "p95(span.duration)", + "p95(http.method)", + ]); + expect(await getCompletions(field, "p95(span.d")).toEqual([ + { value: "p95(span.duration)", description: "number" }, + ]); + expect(await values(field, "nope(")).toEqual([]); + }); + + test("fields are scoped to the target's org, else every org", async () => { + await seedFields(); + + const groupBy = ["dashboard", "widget", "add", "--group-by"]; + + expect( + await values(["dashboard", "widget", "add", "beta/", "D", "-g"], ""), + ).toEqual(["cache.hit"]); + expect((await values(groupBy, "")).sort()).toEqual([ + "cache.hit", + "http.method", + "span.duration", + ]); + }); + + test("replays complete the fields the CLI renders, and sort by their own", async () => { + const field = await values(["explore", "-d", "replays", "-F"], "count_e"); + expect(field).toContain("count_errors"); + expect(field.every((value) => !value.endsWith(")"))).toBe(true); + expect(await values(["explore", "-d", "replays", "-s"], "started")).toEqual( + ["started_at"], + ); + }); + + test("alert --aggregate completes calls, not bare fields", async () => { + await seedFields(); + const create = ["alert", "metrics", "create", "acme/", "--dataset", "span"]; + + expect(await values([...create, "--aggregate"], "span")).toEqual([]); + expect(await values([...create, "--aggregate"], "p9")).toEqual([ + "p90()", + "p95()", + "p99()", + ]); + expect(await values([...create, "--aggregate"], "avg(span.d")).toEqual([ + "avg(span.duration)", + ]); + // Crash rate aggregates are not fields of a dataset + expect( + await values( + ["alert", "metrics", "create", "--dataset", "sessions", "--aggregate"], + "", + ), + ).toEqual([]); + }); + + test("dashboard widgets complete shorthand aggregates and group-by columns", async () => { + await seedFields(); + const add = ["dashboard", "widget", "add", "acme/", "My Dashboard"]; + + expect(await values([...add, "--query"], "p95:span.d")).toEqual([ + "p95:span.duration", + ]); + expect(await values([...add, "-q"], "nope:")).toEqual([]); + expect(await values([...add, "--group-by"], "http")).toEqual([ + "http.method", + ]); + expect( + await values([...add, "--dataset", "error-events", "-g"], "brow"), + ).toEqual(["browser.name"]); + // Trace metric aggregates name a metric, so no function completes + expect(await values([...add, "--dataset", "metrics", "-q"], "")).toEqual( + [], + ); + }); +}); diff --git a/packages/cli/test/lib/db/completion-cache.test.ts b/packages/cli/test/lib/db/completion-cache.test.ts index 63e1112a4..b9846f25e 100644 --- a/packages/cli/test/lib/db/completion-cache.test.ts +++ b/packages/cli/test/lib/db/completion-cache.test.ts @@ -275,6 +275,54 @@ describe("completion seeding from API responses", () => { ]); }); + test("attribute keys are stored by the dataset the request names", async () => { + const attributes = (dataset: string) => + `${API}/trace-items/attributes/?dataset=${dataset}&attributeType=number`; + await seedCompletions( + attributes("spans"), + json([ + { + key: "span.duration", + name: "span.duration", + attributeType: "number", + }, + { + key: "tags[cart.total,number]", + name: "cart.total", + attributeType: "number", + }, + ]), + ); + await seedCompletions( + attributes("logs"), + json([{ key: "severity", name: "severity", attributeType: "string" }]), + ); + // Datasets nothing completes, and requests that name none, store nothing + await seedCompletions( + attributes("preprod"), + json([{ key: "app.size", name: "app.size", attributeType: "number" }]), + ); + await seedCompletions( + `${API}/trace-items/attributes/`, + json([{ key: "span.op", name: "span.op", attributeType: "string" }]), + ); + + expect(getCachedCompletionEntities("span-attribute", ["acme"])).toEqual([ + { orgSlug: "acme", value: "span.duration", label: "number" }, + { + orgSlug: "acme", + value: "tags[cart.total,number]", + label: "cart.total (number)", + }, + ]); + expect(getCachedCompletionEntities("log-attribute", ["acme"])).toEqual([ + { orgSlug: "acme", value: "severity", label: "string" }, + ]); + expect(getCachedCompletionEntities("metric-attribute", ["acme"])).toEqual( + [], + ); + }); + test("ignores other endpoints, failed responses, and bodies that are not JSON", async () => { const issue = { shortId: "CLI-1A", title: "Boom" }; await seedCompletions(`${API}/issues/123/events/`, json([issue]));