From 4058eb15b140db509ee5bb82033e40431a1384e4 Mon Sep 17 00:00:00 2001 From: Aditya Mathur <57684218+MathurAditya724@users.noreply.github.com> Date: Wed, 6 May 2026 23:36:02 +0000 Subject: [PATCH 01/12] fix(explore): validate metrics aggregate format and fix wrong examples sentry explore --dataset metrics sends metricsEnhanced to the Events API, which requires the tracemetrics format: aggregation(value,metric_name,metric_type,unit). Without validation, standard aggregates like count() or avg(measurements.fcp) silently fail with opaque 400 errors from the API. - Add validateMetricsFields() that detects non-tracemetrics aggregates and throws a ValidationError with format guidance and working examples - Fix the wrong metrics example in explore.ts fullDescription (was using span-style avg(measurements.fcp) which doesn't work with metricsEnhanced) - Fix the wrong metrics example in docs fragment with correct tracemetrics examples including LLM token usage and tag breakdown patterns --- docs/src/fragments/commands/explore.md | 16 ++++++- src/commands/explore.ts | 59 +++++++++++++++++++++++++- 2 files changed, 71 insertions(+), 4 deletions(-) diff --git a/docs/src/fragments/commands/explore.md b/docs/src/fragments/commands/explore.md index 0767c41a6..078b53267 100644 --- a/docs/src/fragments/commands/explore.md +++ b/docs/src/fragments/commands/explore.md @@ -44,9 +44,21 @@ sentry explore my-org/cli -F span.op -F "count()" \ ### Metrics +Metrics aggregates use the tracemetrics format: `aggregation(value,metric_name,metric_type,unit)`. + ```bash -# Custom metric aggregations -sentry explore my-org/cli -F transaction -F "avg(measurements.fcp)" \ +# Sum a custom metric (e.g., LLM token usage) across an org +sentry explore my-org/ -F "sum(value,llm.token_usage,distribution,none)" \ + --dataset metrics --period 7d + +# Break down by a tag column (e.g., model name) +sentry explore my-org/seer -F gen_ai.request.model \ + -F "sum(value,llm.token_usage,distribution,none)" \ + --dataset metrics --period 7d + +# Average a distribution metric +sentry explore my-org/cli -F transaction \ + -F "avg(value,http.response_time,distribution,millisecond)" \ --dataset metrics --period 24h ``` diff --git a/src/commands/explore.ts b/src/commands/explore.ts index 61b6b411e..d62fcd0f9 100644 --- a/src/commands/explore.ts +++ b/src/commands/explore.ts @@ -356,6 +356,53 @@ function findFirstAggregate(fieldList: string[]): string | undefined { return fieldList.find((f) => f.includes("(") && f.includes(")")); } +/** True when the field looks like an aggregate call: `fn(...)`. */ +function isAggregate(field: string): boolean { + return field.includes("(") && field.endsWith(")"); +} + +/** + * True when the aggregate uses the tracemetrics comma-separated format: + * `aggregation(value,metric_name,metric_type,unit)`. + */ +function isTracemetricsAggregate(aggregate: string): boolean { + const parenIdx = aggregate.indexOf("("); + if (parenIdx < 0) { + return false; + } + const inner = aggregate.slice(parenIdx + 1, -1); + return inner.startsWith("value,") && inner.split(",").length === 4; +} + +/** + * Validate that aggregate fields use the tracemetrics format when querying + * the `metricsEnhanced` dataset. Standard aggregates like `count()` or + * `avg(measurements.fcp)` are invalid — the API requires the four-part + * comma-separated format: `aggregation(value,metric_name,metric_type,unit)`. + */ +function validateMetricsFields(fieldList: string[]): void { + const badAggs = fieldList.filter( + (f) => isAggregate(f) && !isTracemetricsAggregate(f) + ); + if (badAggs.length === 0) { + return; + } + + throw new ValidationError( + `Invalid metrics aggregate${badAggs.length > 1 ? "s" : ""}: ${badAggs.join(", ")}\n\n` + + "The metrics dataset requires the format: aggregation(value,metric_name,metric_type,unit)\n\n" + + "Examples:\n" + + ' sentry explore my-org/ -F "sum(value,llm.token_usage,distribution,none)" --dataset metrics\n' + + ' sentry explore my-org/ -F gen_ai.request.model -F "avg(value,cache.hit_rate,distribution,none)" --dataset metrics\n\n' + + "Parameters:\n" + + ' - value: literal string "value"\n' + + " - metric_name: the metric name emitted by the SDK (e.g., llm.token_usage)\n" + + " - metric_type: distribution, gauge, counter, or set\n" + + " - unit: none, byte, second, millisecond, etc.", + "field" + ); +} + // --------------------------------------------------------------------------- // Dataset configuration // --------------------------------------------------------------------------- @@ -508,7 +555,7 @@ export const exploreCommand = buildListCommand("explore", { "Datasets:\n" + " errors Error events (default)\n" + " spans Span data\n" + - " metrics Custom metrics\n" + + " metrics Custom metrics (tracemetrics format)\n" + " logs Log entries\n" + " replays Session replay search\n\n" + "Targets:\n" + @@ -523,7 +570,11 @@ export const exploreCommand = buildListCommand("explore", { "--dataset spans\n" + " sentry explore my-org/cli --dataset replays -F id -F user.email -F count_errors\n" + ' sentry explore -F span.op -F "count()" --dataset spans --period 1h\n' + - " sentry explore --json", + " sentry explore --json\n\n" + + "Metrics format:\n" + + " Metrics aggregates use: aggregation(value,metric_name,metric_type,unit)\n" + + ' sentry explore my-org/ -F "sum(value,llm.token_usage,distribution,none)" --dataset metrics\n' + + ' sentry explore my-org/seer -F gen_ai.request.model -F "sum(value,llm.token_usage,distribution,none)" --dataset metrics --period 7d', }, output: { human: formatExploreHuman, @@ -616,6 +667,10 @@ export const exploreCommand = buildListCommand("explore", { const timeRange = flags.period; const environment = parseReplayEnvironmentFilter(flags.environment); + if (dataset === "metricsEnhanced") { + validateMetricsFields(fieldList); + } + const config = resolveDatasetConfig({ dataset, fieldList, From 401fb61797269a07615f21883409f4babe7b8528 Mon Sep 17 00:00:00 2001 From: Aditya Mathur <57684218+MathurAditya724@users.noreply.github.com> Date: Wed, 6 May 2026 23:41:09 +0000 Subject: [PATCH 02/12] fix: require explicit fields for metrics dataset and add test coverage - Detect when user runs --dataset metrics without -F and throw a helpful ValidationError instead of sending incompatible defaults to the API - Add 4 tests: rejects standard aggregates, accepts tracemetrics format, requires explicit fields, allows non-aggregate tag fields --- AGENTS.md | 109 +++++++++++++++++----------------- src/commands/explore.ts | 12 +++- test/commands/explore.test.ts | 74 +++++++++++++++++++++++ 3 files changed, 138 insertions(+), 57 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index a36c3ec76..7c6572dbc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1068,89 +1068,86 @@ mock.module("./some-module", () => ({ ### Architecture - -* **Issue resolve --in grammar: release + @next + @commit sentinels**: \`sentry issue resolve --in\` grammar: (a) omitted→immediate resolve, (b) \`\\`→\`inRelease\` (monorepo \`spotlight@1.2.3\` pass-through), (c) \`@next\`→\`inNextRelease\`, (d) \`@commit\`→auto-detect git HEAD + match Sentry repos, (e) \`@commit:\@\\`→explicit. Sentinel matching case-insensitive; unknown \`@\`-prefixed tokens throw \`ValidationError\`. \`parseResolveSpec\` splits on LAST \`@\` to handle scoped names like \`@acme/web\`. \`resolveCommitSpec\` uses \`getHeadCommit\`/\`getRepositoryName\` from \`src/lib/git.ts\`, matching Sentry repo \`externalSlug\` or \`name\` via \`listRepositoriesCached\`. API requires \`statusDetails.inCommit: {commit, repository}\` — not bare SHA. + +* **Dashboard widget interval computed from terminal width and layout before API calls**: Dashboard widget interval from terminal width: \`computeOptimalInterval()\` in \`src/lib/api/dashboards.ts\` calculates chart interval before API calls. Formula: \`colWidth = floor(layout.w / 6 \* termWidth)\`, \`chartWidth = colWidth - 4 - gutterW\`, \`idealSeconds = periodSeconds / chartWidth\`. Snaps to nearest Sentry bucket (1m–1d). \`periodToSeconds()\` parses \`"24h"\`, \`"7d"\` etc. \`queryWidgetTimeseries\` uses \`params.interval ?? widget.interval\`. - -* **npm bundle requires Node.js >= 22 due to node:sqlite polyfill**: The npm package (dist/bin.cjs) requires Node.js >= 22 because the bun:sqlite polyfill uses \`node:sqlite\`. A runtime version guard in the esbuild banner catches this early. When writing esbuild banner strings in TS template literals, double-escape: \`\\\\\\\n\` in TS → \`\\\n\` in output → newline at runtime. Single \`\\\n\` produces a literal newline inside a JS string, causing SyntaxError. + +* **DSN org prefix normalization in arg-parsing.ts**: DSN/numeric org prefix normalization — four code paths must all convert to slugs before API calls (many endpoints reject numeric org IDs with 404/403): (1) \`extractOrgIdFromHost\` strips \`o\` prefix during DSN parsing. (2) \`stripDsnOrgPrefix()\` handles user-typed \`o1081365/\` in \`parseOrgProjectArg()\`. (3) \`normalizeNumericOrg()\` in \`resolve-target.ts\` resolves bare numeric IDs via DB cache or uncached API call. (4) Dashboard's \`resolveOrgFromTarget()\` pipes through \`resolveEffectiveOrg()\`, also used by \`tryResolveRecoveryOrg()\` in hex-id-recovery. - -* **repo\_cache SQLite table for offline Sentry repo lookups**: Schema v14 adds \`repo\_cache\` table in \`src/lib/db/schema.ts\` + helpers in \`src/lib/db/repo-cache.ts\` (7-day TTL). \`listAllRepositories(org)\` in \`src/lib/api/repositories.ts\` paginates through \`listRepositoriesPaginated\` using \`API\_MAX\_PER\_PAGE\` and \`MAX\_PAGINATION\_PAGES\` — never use the unpaginated \`listRepositories\` for cache-backed lookups (silently caps at ~25). \`listRepositoriesCached(org)\` wraps it with cache-first lookup and a try/catch around \`setCachedRepos\` so read-only databases (macOS \`sudo brew install\`) don't crash commands whose API fetch already succeeded. Used by \`@commit\` resolver to match git origin \`owner/repo\` against Sentry repo \`externalSlug\` or \`name\`. + +* **env-registry.ts drives --help env var section + docs**: \`src/lib/env-registry.ts\` (\`ENV\_VAR\_REGISTRY\`) is the single source for all env vars the CLI honors. Entries have \`{name, description, example?, defaultValue?, installOnly?, topLevel?, briefDescription?}\`. \`topLevel: true\` + \`briefDescription\` surfaces in \`sentry --help\` Environment Variables section (via \`formatEnvVarsSection()\` in \`help.ts\`) and in \`sentry help --json\` as \`envVars\` array on the full-tree envelope. Docs generator consumes the full registry for \`configuration.md\`. When adding a new env var, add it here with \`installOnly: true\` if install-script-only. Reserve \`topLevel: true\` for core-path vars only (auth, targeting, URL, key display/logging). - -* **Response cache hit invisibility — synthetic Response carries no marker**: Response cache hit invisibility — synthetic Response from \`getCachedResponse()\` in \`src/lib/response-cache.ts\` is indistinguishable from network. Solved via module-level \`lastCacheHitAgeMs\`: set on hit, cleared at top of \`authenticatedFetch()\` per-call (single-process CLI = race-free). \`src/lib/cache-hint.ts\` provides \`formatCacheHint()\` (\`"cached · 3m ago · use -f to refresh"\`) and \`appendCacheHint(existingHint)\` (joins with \` | \`). Wired in \`buildCommand\` (\`src/lib/command.ts\`): \`appendCacheHint(returned?.hint)\` runs only when generator returns a \`CommandReturn\` — bare \`return;\` paths (e.g. \`--web\`) skip the hint. Same chokepoint can host future cross-cutting hint decorators. Test-only \`\_setLastCacheHitAgeForTesting(ms)\` exposes state. + +* **Issue list auto-pagination beyond API's 100-item cap**: Sentry API silently caps \`limit\` at 100 per request. \`listIssuesAllPages()\` auto-paginates using Link headers, bounded by MAX\_PAGINATION\_PAGES (50). \`API\_MAX\_PER\_PAGE\` constant is shared across all paginated consumers. \`--limit\` means total results everywhere (max 1000, default 25). Org-all mode uses \`fetchOrgAllIssues()\`; explicit \`--cursor\` does single-page fetch to preserve cursor chain. - -* **Seer trial prompt uses middleware layering in bin.ts error handling chain**: Seer trial prompt via error middleware layering: \`bin.ts\` chain is \`main() → executeWithAutoAuth() → executeWithSeerTrialPrompt() → runCommand()\`. Seer trial prompts (\`no\_budget\`/\`not\_enabled\`) caught by inner wrapper; auth errors bubble to outer. Trial API: \`GET /api/0/customers/{org}/\` → \`productTrials\[]\` (prefer \`seerUsers\`, fallback \`seerAutofix\`). Start: \`PUT /api/0/customers/{org}/product-trial/\`. SaaS-only; self-hosted 404s gracefully. \`ai\_disabled\` excluded. \`startSeerTrial\` accepts \`category\` from trial object — don't hardcode. + +* **resolveProjectBySlug carries full projectData to avoid redundant getProject calls**: resolveProjectBySlug carries full projectData to skip redundant API calls: Returns \`{ org, project, projectData: SentryProject }\` from \`findProjectsBySlug()\`. \`ResolvedOrgProject\`/\`ResolvedTarget\` have optional \`projectData?\` (populated only in project-search path). Downstream commands use \`resolved.projectData ?? await getProject(org, project)\` to save ~500-800ms. -### Decision - - -* **Raw markdown output for non-interactive terminals, rendered for TTY**: Markdown-first output pipeline: custom renderer in \`src/lib/formatters/markdown.ts\` walks \`marked\` tokens to produce ANSI-styled output. Commands build CommonMark using helpers (\`mdKvTable()\`, \`mdRow()\`, \`colorTag()\`, \`escapeMarkdownCell()\`, \`safeCodeSpan()\`) and pass through \`renderMarkdown()\`. \`isPlainOutput()\` precedence: \`SENTRY\_PLAIN\_OUTPUT\` > \`NO\_COLOR\` > \`FORCE\_COLOR\` > \`!isTTY\`. \`--json\` always outputs JSON. Colors defined in \`COLORS\` object in \`colors.ts\`. Tests run non-TTY so assertions match raw CommonMark; use \`stripAnsi()\` helper for rendered-mode assertions. + +* **Sentry CLI markdown-first formatting pipeline replaces ad-hoc ANSI**: Formatters build CommonMark strings; \`renderMarkdown()\` renders to ANSI for TTY or raw markdown for non-TTY. Key helpers: \`colorTag()\`, \`mdKvTable()\`, \`mdRow()\`, \`mdTableHeader()\` (\`:\` suffix = right-aligned), \`renderTextTable()\`. \`isPlainOutput()\` checks \`SENTRY\_PLAIN\_OUTPUT\` > \`NO\_COLOR\` > \`!isTTY\`. Batch path: \`formatXxxTable()\`. Streaming path: \`StreamingTable\` (TTY) or raw markdown rows (plain). Both share \`buildXxxRowCells()\`. -### Gotcha + +* **Sentry dashboard API rejects discover/transaction-like widget types — use spans**: Sentry API dataset gotchas: (1) Events/Explore API accepts \`spans\`, \`transactions\`, \`logs\`, \`errors\`, \`discover\`; \`spansIndexed\` is INVALID (500). Valid list in \`EVENTS\_API\_DATASETS\`. (2) Dashboard \`widgetType\`: \`discover\` and \`transaction-like\` rejected as deprecated — use \`spans\`. \`WIDGET\_TYPES\` (active) vs \`ALL\_WIDGET\_TYPES\` (includes deprecated for parsing). Tests use \`error-events\` not \`discover\`. (3) \`sort\` param only on \`spans\` dataset. (4) \`tracemetrics\` uses comma-separated aggregates; only line/area/bar/table/big\_number displays. - -* **--json schema stability: collapse=organization drops nested org fields**: --json schema + response cache gotchas: (1) \`?collapse=organization\` shrinks \`organization\` to \`{id, slug}\` — silent --json regression. \`jsonTransform\` re-hydrates \`organization.name\` via \`resolveOrgDisplayName\` against \`org\_regions\` cache. (2) \`buildCacheKey()\` normalizes URL with sorted query params, so \`invalidateCachedResponse(baseUrl)\` misses entries with query suffixes. Use \`invalidateCachedResponsesMatching(prefix)\` (raw \`startsWith()\`); \`buildApiUrl()\` always emits trailing slash → safe prefix. (3) When \`jsonTransform\` is set, \`jsonExclude\` and \`filterFields\` are NOT applied — transform must call \`filterFields(result, fields)\` and omit excluded keys itself. + +* **Sentry issue stats field: time-series controlled by groupStatsPeriod**: Issue stats and list layout: \`stats\` depends on \`groupStatsPeriod\` (\`""\`, \`"14d"\`, \`"24h"\`, \`"auto"\`). Critical: \`count\` is period-scoped — use \`lifetime.count\` for true total. \`--compact\` is tri-state (\`optional: true\`): explicit overrides, \`undefined\` triggers \`shouldAutoCompact(rowCount)\` — compact if \`3N + 3 > termHeight\`. TREND column hidden < 100 cols. Stricli boolean flags with \`optional: true\` produce \`boolean | undefined\` enabling this auto-detect pattern. - -* **@sentry/api SDK passes Request object to custom fetch — headers lost on Node.js**: @sentry/api SDK calls \`\_fetch(request)\` with no init object. In \`authenticatedFetch\`, \`init\` is undefined → \`prepareHeaders\` creates empty headers, stripping Content-Type on Node.js (HTTP 415). Fix: fall back to \`input.headers\` when \`init\` is undefined. Use \`unwrapPaginatedResult\` (not \`unwrapResult\`) to access Link header for pagination. \`per\_page\` not in SDK types — cast query at runtime. SDK returns \`data={}\` (not \`\[]\`) for empty/204/missing Content-Type responses — always guard with \`Array.isArray(data)\` before \`.map()\`. Self-hosted instances behind reverse proxies commonly trigger this. + +* **Sentry log IDs are UUIDv7 — enables deterministic retention checks**: Sentry log IDs are UUIDv7 (first 12 hex = ms timestamp, version char \`7\` at pos 13). Traces/event IDs are NOT v7. \`decodeUuidV7Timestamp()\` and \`ageInDaysFromUuidV7()\` in \`src/lib/hex-id.ts\` return null for non-v7, safe to call unconditionally. Enables deterministic 'past retention' messages; wired in \`recoverHexId\` and \`log/view.ts#throwNotFoundError\`. \`RETENTION\_DAYS.log = 90\` in \`src/lib/retention.ts\`; traces/events are \`null\` (plan-dependent). \`LOG\_RETENTION\_PERIOD\` is DERIVED as \`\` \`${RETENTION\_DAYS.log}d\` \`\` — never hardcode \`'90d'\`. Shared hex primitives (\`HEX\_ID\_RE\`, \`SPAN\_ID\_RE\`, \`UUID\_DASH\_RE\`, \`LEADING\_HEX\_RE\`, \`MIDDLE\_ELLIPSIS\_RE\`, \`HexEntityType\`) live in \`hex-id.ts\`. - -* **API tests must use useTestConfigDir to isolate disk response cache**: API tests that mock \`globalThis.fetch\` MUST call \`useTestConfigDir()\` from \`test/helpers.ts\` + \`setAuthToken()\`. The \`authenticatedFetch\` singleton in \`src/lib/sentry-client.ts\` checks a filesystem-based response cache (\`~/.sentry/cache/responses/\`, see \`response-cache.ts\`) BEFORE calling fetch. Without per-test config dirs, test N's API response gets cached to disk and served to test N+1 — fetch mock never fires, assertion sees stale data. TTL tiers in \`classifyUrl()\`: stable=5min (default), volatile=60s (issues, logs), immutable=24h (events/traces by ID). Symptom: test expects fresh mock value, receives prior test's value. Reference: \`test/lib/api/issues.test.ts\` (correct pattern), \`test/lib/api/repositories.test.ts\` regression fixed by adding \`useTestConfigDir("repo-cache-")\` + \`setAuthToken("test-token", 3600, "test-refresh")\` in beforeEach. + +* **Stricli route errors are uninterceptable — only post-run detection works**: Stricli error gaps: (1) Route failures uninterceptable — Stricli writes stderr and returns \`ExitCode.UnknownCommand\` (-5 / 251 in Bun); only post-\`run()\` \`process.exitCode\` check works. (2) \`OutputError\` calls \`process.exit()\` immediately, bypassing telemetry. (3) \`defaultCommand: 'help'\` bypasses built-in fuzzy matching — fixed by \`resolveCommandPath()\` in \`introspect.ts\` using \`fuzzyMatch()\` (up to 3 suggestions); JSON includes \`suggestions\`. (4) Plural alias detection in \`app.ts\`. - -* **Biome noUselessUndefined also rejects () => {} empty arrow callbacks**: Biome lint traps: (1) \`noUselessUndefined\` rejects \`() => undefined\` AND \`noEmptyBlockStatements\` rejects \`() => {}\` — use top-level \`function noop(): void {}\`. (2) \`noExcessiveCognitiveComplexity\` caps at 15. (3) \`expect(() => fn()).toThrow(X)\` must be one line. (4) Plugin forbids raw \`metadata\` table queries — use \`getMetadata\`/\`setMetadata\`/\`clearMetadata\`. (5) Also enforced: \`useBlockStatements\`, \`noNestedTernary\`, \`useAtIndex\`, \`noStaticOnlyClass\`, \`useSimplifiedLogicExpression\`, \`noShadow\`. Namespace imports forbidden. (6) \`useYield\` fires on \`async \*func()\` with statements but not empty bodies — only add \`biome-ignore\` to generators with statements. \`lint:fix\` differs from CI \`lint\`: auto-fix hides \`noPrecisionLoss\` on >2^53 literals, \`noIncrementDecrement\`, import ordering. Always \`bun run lint\` before pushing. + +* **Three Sentry APIs for span custom attributes with different capabilities**: Three Sentry span APIs with different attribute capabilities: (1) \`/trace/{traceId}/\` — hierarchical tree; \`additional\_attributes\` enumerates requested attrs; returns \`measurements\` (zero-filled on non-browser, stripped by \`filterSpanMeasurements()\`). (2) \`/projects/{org}/{project}/trace-items/{itemId}/\` — single span full detail; ALL attributes as \`{name,type,value}\[]\` automatically. (3) \`/events/?dataset=spans\&field=X\` — list/search; explicit \`field\` params. \`--fields\` flag filters JSON output AND requests extra API fields via \`extractExtraApiFields()\`; \`FIELD\_GROUP\_ALIASES\` supports shorthand expansion. - -* **Bun --isolate coverage inflates LF count for files with verbose comments/JSDoc**: Bun --isolate coverage inflates LF count: under \`bun test --isolate --parallel\` (CI's \`test:unit\`), Bun's coverage instrumentation counts comments, blank lines, type annotations, and closing braces as 'executable'. E.g. \`zstd-transport.ts\` LF=165 locally → 210 under --isolate, dropping coverage 99%→78%. Codecov sees inflated number. Workaround: trim verbose inline comments inside function bodies (move rationale to JSDoc above function or module-level doc). Statement coverage stays 100% — 'missing' lines are non-executable. +### Decision - -* **Bun 1.3.11 tty.ReadStream leaks libuv handle — process.stdin.unref is undefined**: Bun 1.3.11 macOS TTY bug: \`process.stdin\` via kqueue \`EVFILT\_READ\` on reopened non-session-leader TTY fd fails to deliver keystrokes when fd 0 inherited via \`exec bin \ +* **400 Bad Request from Sentry API indicates a CLI bug, not a user error**: Telemetry 400 convention: 400 = CLI bug (capture to Sentry), 401-499 = user error (skip). \`isUserApiError()\` uses \`> 400\` (exclusive). \`isExpectedUserError()\` guard in \`app.ts\` skips ContextError, ResolutionError, ValidationError, SeerError, 401-499 ApiErrors. Captures 400, 5xx, unknown. Skipped errors → breadcrumbs. For \`ApiError\`, call \`Sentry.setContext('api\_error', {...})\` before \`captureException\` — SDK doesn't auto-capture custom properties. - -* **MastraClient has no dispose API — use AbortController for cleanup**: MastraClient has no \`close()\`/\`dispose()\` API — cleanup via \`ClientOptions.abortSignal\` (constructor) or per-prompt \`signal\`. Without explicit abort, Bun's fetch dispatcher keep-alive sockets hold the event loop alive past natural exit. Pattern in \`src/lib/init/wizard-runner.ts\`: create \`AbortController\` per \`runWizard\`, pass \`abortSignal: controller.signal\` to \`new MastraClient(...)\`, abort via \`using \_ = { \[Symbol.dispose]: () => controller.abort() }\`. Custom \`fetch\` wrapper must preserve \`init.signal\` via spread. Tests capture \`ClientOptions\` via \`spyOn(MastraClient.prototype, 'getWorkflow').mockImplementation(function() { capturedOpts.push(this.options); ... })\`. + +* **CLI UX philosophy: auto-recover when intent is clear, warn gently**: UX principle: don't fail when intent is clear — do the intent and nudge via \`log.warn()\` to stderr. Keep errors in Sentry telemetry for visibility (e.g., SeerError for upsell tracking). Two recovery tiers: (1) auto-correct when semantics identical (AND→space), (2) auto-recover with warning when semantics differ (OR→space, warn about union→intersection). Only throw when intent can't be fulfilled. Model after \`gh\` CLI. AI agents are primary consumers constructing natural OR/AND queries. - -* **Multi-region fan-out: distinguish all-403 from empty orgs with hasSuccessfulRegion flag**: In \`listOrganizationsUncached\` (\`src/lib/api/organizations.ts\`), \`Promise.allSettled\` collects multi-region results. Don't use \`flatResults.length === 0\` to detect all-regions-failed — a region returning 200 OK with zero orgs pushes nothing into \`flatResults\`. Track a \`hasSuccessfulRegion\` boolean on any \`"fulfilled"\` settlement. Only re-throw 403 \`ApiError\` when \`!hasSuccessfulRegion && lastScopeError\`. + +* **Trace-related commands must handle project consistently across CLI**: Trace/log commands project scoping: \`getDetailedTrace\` accepts optional numeric \`projectId\` (not hardcoded \`-1\`); resolve slug→ID via \`getProject()\`. \`formatSimpleSpanTree\` shows orphan annotation only when \`projectFiltered\` is set. \`buildProjectQuery()\` in \`arg-parsing.ts\` prepends \`project:\\` to queries (used by \`trace/logs.ts\`, \`log/list.ts\`). Multi-project: \`--query 'project:\[cli,backend]'\`. Trace-logs endpoint (\`/organizations/{org}/trace-logs/\`) is org-scoped — uses \`resolveOrg()\`. Endpoint is PRIVATE (no \`@sentry/api\` types); hand-written \`TraceLogSchema\` in \`src/types/sentry.ts\` required. -### Pattern +### Gotcha - -* **CLI-1D3 Windows download visibility race: poll statSync with exponential backoff**: Windows upgrade download visibility race (CLI-1D3): \`waitForBinaryVisible\` in \`src/lib/upgrade.ts\` polls \`statSync\` with exponential backoff (6 attempts, 5 sleeps: 100+200+400+800+1600 = 3.1s). Loop breaks BEFORE final sleep — \`VERIFY\_MAX\_ATTEMPTS=N\` yields N-1 sleeps (off-by-one trap). Covers Windows + Bun 1.3.9 race where \`Bun.file().writer().end()\` returns before OS surfaces file by path → opaque \`Executable not found in $PATH\` from \`Bun.spawn\`. Safety net \`isEnoentSpawnError()\` in \`src/commands/cli/upgrade.ts\` detects both \`code==='ENOENT'\` and Bun's path-string error → \`UpgradeError('execution\_failed')\`. Race-free delayed-write tests: writer must POLL until bad state exists THEN overwrite. + +* **Biome lint differs between local lint:fix and CI lint**: Biome \`lint:fix\` (local) differs from CI \`lint\` — auto-fix can hide issues CI still catches: (1) \`noPrecisionLoss\` on integer literals >2^53, (2) \`noIncrementDecrement\` on \`count++\`, (3) import ordering when a named import follows non-import runtime code. Formatter rewrites multi-line imports to single-line when they fit. Always run \`bun run lint\` before pushing. Use \`for...of\` destructuring or \`i += 1\` instead of \`++\`; use \`Number(string)\` or split literals instead of \`1\_735\_689\_600\_000\_000\_001\`. - -* **Cross-compile sentry-cli with patched Bun: drop compile.target to use selfExePath**: Cross-compile sentry-cli with patched Bun: \`Bun.build({compile})\` downloads stock Bun from npm when \`compile.target\` is set. Workaround in \`script/build.ts\`: omit \`target\` entirely so Bun hits \`isDefault()\` branch → uses \`selfExePath()\` = the running Bun as embed runtime. Only works when host OS/arch matches desired output. Escape hatch: place file at \`$CWD/bun-\-\-v\\` (e.g. \`bun-darwin-arm64-v1.3.13\`) picked up via \`bun.FD.cwd().existsAt(version\_str)\` in \`src/compile\_target.zig:exePath\`. Build also requires \`SENTRY\_CLIENT\_ID\` env var. + +* **Bun mock.module for node:tty requires default export and class stubs**: Bun testing gotchas: (1) \`mock.module()\` for CJS built-ins (e.g. \`node:tty\`) needs \`default\` re-export plus named exports, declared top-level BEFORE \`await import()\`; lives in \`test/isolated/\`. (2) Destructuring imports capture binding at load; verify via call-count > 0. (3) \`Bun.mmap()\` always opens PROT\_WRITE — use \`new Uint8Array(await Bun.file(path).arrayBuffer())\` for read-only. (4) Wrap \`Bun.which()\` with optional \`pathEnv\` for deterministic testing. (5) Mocking \`@sentry/node-core/light\`: \`startSpan\` must pass mock span to callback — \`startSpan: (\_, fn) => fn({ setStatus(){}, setAttribute(){}, end(){} })\`. - -* **Dedupe resolved entity IDs in batch operations before API call**: Batch issue merge (src/commands/issue/merge.ts): (1) Dedupe by resolved numeric ID after \`Promise.all(args.map(resolveIssue))\`, not raw input (users pass same entity as \`CLI-K9\`, \`my-org/CLI-K9\`, \`123\`). Throw ValidationError if \`new Set(ids).size < 2\`. (2) Reject undefined orgs in cross-org check — bare numeric IDs without DSN/config resolve with \`org: undefined\`; filtering them out lets mixed-org merges slip through. (3) Pass \`--into\` through \`resolveIssue()\` for alias/org-qualified parity; compare by numeric \`id\`, not \`shortId\`. (4) Sentry bulk merge API picks canonical parent by event count — \`--into\` is preference only; warn when API's \`parent\` differs. Empty results return 204. +### Pattern - -* **findProjectsByPattern as fuzzy fallback for exact slug misses**: When \`findProjectsBySlug\` returns empty (no exact match), use \`findProjectsByPattern\` as a fallback to suggest similar projects. \`findProjectsByPattern\` does bidirectional word-boundary matching (\`matchesWordBoundary\`) against all projects in all orgs — the same logic used for directory name inference. In the \`project-search\` handler, call it after the exact miss, format matches as \`\/\\` suggestions in the \`ResolutionError\`. This avoids a dead-end error for typos like 'patagonai' when 'patagon-ai' exists. Note: \`findProjectsByPattern\` makes additional API calls (lists all projects per org), so only call it on the failure path. + +* **403 scope extraction via api-scope.ts helpers**: \`src/lib/api-scope.ts\` \`extractRequiredScopes(detail)\` scans Sentry 403 response detail (string or structured) for scope-like tokens (e.g. \`event:read\`, \`project:admin\`). Matches free-text and structured \`required\`/\`required\_scopes\` fields. Use in 403-enrichment paths instead of hardcoded generic scope lists; fall back to generic hint only when extraction returns empty. Wired into \`issue list\` \`build403Detail()\`, \`organizations.ts\` \`enrich403Error()\`. - -* **Grouped widget --limit auto-default via applyGroupLimitAutoDefault helper**: Dashboard widget flag normalization: (1) Dataset aliases (errors→error-events) normalize ONCE at top of \`func()\` via \`normalizeDataset()\` in \`src/commands/dashboard/resolve.ts\`. In \`edit.ts\`, pass \`normalizedFlags\` to \`buildReplacement\` — \`validateAggregateNames\` reads \`flags.dataset\` and rejects valid aggregates like \`failure\_rate\` if it sees raw alias. (2) Grouped widgets need \`limit\` (API rejects). \`applyGroupLimitAutoDefault\` defaults to \`DEFAULT\_GROUP\_BY\_LIMIT=5\` only when user passed \`--group-by\` without \`--limit\`; skip for auto-defaulted columns like \`\["issue"]\`. (3) Tests asserting \`--limit\` >10 survives into PUT body must use \`display: "line"\` — \`prepareWidgetQueries\` clamps bar/table to max=10. + +* **buildApiUrl helper for safe Sentry API URL construction**: \`buildApiUrl(regionUrl, ...segments)\` in \`src/lib/api/infrastructure.ts\` composes Sentry API URLs. Owns \`/api/0/\` prefix, trailing slash, per-segment \`encodeURIComponent\`. Safety: slugs containing \`/\` get encoded correctly. Zero segments → \`base/api/0/\`. Replaces error-prone \`${base}/api/0/organizations/${encodeURIComponent(org)}/...\` patterns. Use for all URL-composition sites in domain API modules. Since #788 (cache identity scoping), all cache invalidation prefix construction uses it. \`stripTrailingSlash\` is no longer exported. - -* **Hidden --org/--project compat flags via mergeGlobalFlags**: Hidden global \`--org\`/\`--project\` flags accept old \`sentry-cli\` syntax. Defined in \`GLOBAL\_FLAGS\` (global-flags.ts) so argv-hoist relocates them. \`mergeGlobalFlags()\` in command.ts injects hidden flag shapes (skip if command owns the flag — e.g. \`release create --project -p\`) and returns \`stripKeys\` set used by \`cleanRawFlags\`. \`applyOrgProjectFlags()\` writes values to \`SENTRY\_ORG\`/\`SENTRY\_PROJECT\` via \`getEnv()\` before auth guard, overwriting existing env vars (explicit CLI > env var). Resolution chain in resolve-target.ts picks them up at priority #2. No short aliases (\`-p\` conflicts). The helper extraction was needed to keep \`buildCommand\` under Biome's cognitive complexity limit of 15. + +* **fetchWithTimeout uses bare fetch reference for test mockability**: \`fetchWithTimeout\` in \`src/lib/sentry-client.ts\` calls \`fetch(input, ...)\` as a bare global reference — this is load-bearing for tests that swap \`globalThis.fetch\`. Do NOT refactor to capture \`fetch\` at module load (via destructuring or aliasing) — all tests using \`mockFetch()\` would silently fall through to real network. \`resetAuthenticatedFetch()\` in test \`beforeEach\` clears the authenticated-fetch singleton (for auth state), NOT the fetch mock itself. If refactoring, add explicit \`// must remain bare fetch() for test mockability\` comment. - -* **Preserve ApiError type so classifySilenced can silence 4xx errors**: Preserve ApiError type for classifySilenced: \`classifySilenced\` (src/lib/error-reporting.ts) only silences \`ApiError\` with status 401-499 — wrapping in generic \`CliError\` loses \`status\` and causes 403s to be captured. Re-throw via \`new ApiError(msg, error.status, error.detail, error.endpoint)\` with terse message (\`ApiError.format()\` appends detail/endpoint). \`ValidationError\` without \`field\` collapses unfielded errors into one fingerprint; always pass \`field\`. Fingerprint rule changes don't retroactively re-fingerprint — manually merge new groups into canonical old parents. \`ApiError\` rule keys by \`api\_status + command\`. + +* **I/O concurrency limits belong at the call site, not in generic combinators**: I/O concurrency limits belong at the call site, not in generic combinators. Pattern: module-scoped \`pLimit()\` with named constant (e.g., \`STAT\_CONCURRENCY = 32\` in \`project-root.ts\`, \`CACHE\_IO\_CONCURRENCY\` in \`response-cache.ts\`, \`pLimit(50)\` in \`code-scanner.ts\`). Keeps combinators pure, makes budget explicit at I/O boundary. stat() lighter than full reads — ~32 for stats vs ~50 for reads, well below macOS's 256 FD ceiling. - -* **Sentry SDK tree-shaking patches must be regenerated via bun patch workflow**: Sentry SDK tree-shaking via bun patch: \`patchedDependencies\` in \`package.json\` strips unused exports from \`@sentry/core\` and \`@sentry/node-core\`. Non-light root of \`@sentry/node-core\` pulls uninstalled \`@opentelemetry/instrumentation\` — \*\*always import from \`@sentry/node-core/light\`\*\* (subpaths: \`.\`, \`./light\`, \`./light/otlp\`, \`./init\`, \`./loader\`, \`./import\`). No supported import for \`HttpsProxyAgent\`. Bumping SDK: remove old patches, \`rm -rf ~/.bun/install/cache/@sentry\`, \`bun install\`, \`bun patch @sentry/core\`, edit, \`bun patch --commit\`; repeat for node-core. Preserved: \`\_INTERNAL\_safeUnref\`, \`\_INTERNAL\_safeDateNow\`, \`nodeRuntimeMetricsIntegration\`. Before stripping any core export, grep \`node-core/build/{cjs,esm}/light/sdk.js\` for runtime usage (e.g. \`spanStreamingIntegration\` when \`traceLifecycle === 'stream'\`). Remove \`.bun-tag-\*\` hunks from generated patches. Manual \`git diff\` patches fail. + +* **Identity-scoped response cache via fingerprint mixin**: Identity-scoped response cache: \`buildCacheKey(method, url)\` mixes in memoized \`getIdentityFingerprint()\` (MD5 of \`kind|secret\` truncated to 16 hex; CodeQL dismissed — namespacing, not auth). \`CacheEntry\` persists identity so \`invalidateCachedResponsesMatching(prefix)\` skips other identities. Invalidation centralized at \`authenticatedFetch\` in \`sentry-client.ts\` — after 2xx non-GET, runs \`computeInvalidationPrefixes(fullUrl, getApiBaseUrl())\` walking hierarchy up to \`organizations/{org}/\` plus cross-endpoint rules via \`extra\`/\`extraAbsolute\` (control-silo vs region-silo). \*\*Contract: never throws\*\* — wrapped in try/catch. \`SKIP\_INVALIDATION\_PATTERNS\` short-circuits chunk-upload/assemble. \`clearAuth()\` dynamically imports \`clearResponseCache\` to break cycle. Always use prefix-match with trailing slash; exact-match removed. URL-only hook can't decode bulk mutations with IDs in query params (e.g. \`mergeIssues\`) — invalidate per-ID at caller. - -* **Shared pagination infrastructure: buildPaginationContextKey and parseCursorFlag**: Bidirectional pagination via cursor stack in \`src/lib/db/pagination.ts\`. \`resolveCursor(flag, key, contextKey)\` maps keywords (next/prev/first/last) to \`{cursor, direction}\`. \`advancePaginationState\` manages stack — back-then-forward truncates stale entries. \`hasPreviousPage\` checks \`page\_index > 0\`. \`paginationHint()\` builds nav strings. All list commands use this. Critical: \`resolveCursor()\` must be called inside \`org-all\` override closures, not before \`dispatchOrgScopedList\`. + +* **Isolated adapter coverage via fetch mocking in test/lib/**: To get CodeCov coverage on API-calling functions (e.g., hex-id-recovery adapters, api-client functions), write tests in \`test/lib/\*.coverage.test.ts\` or \`test/lib/\*.adapters.test.ts\` that mock \`globalThis.fetch\` via \`mockFetch()\` from \`test/helpers.js\`, call \`setAuthToken()\` + \`setOrgRegion()\` in \`beforeEach\`, and invoke the REAL function. Tests in \`test/e2e/\` or tests that stub the exports via \`spyOn\`/\`mock.module\` give ZERO coverage to the mocked function body. Use \`useTestConfigDir()\` for DB isolation. Pattern example: \`test/lib/api-client.coverage.test.ts\` and \`test/lib/hex-id-recovery.adapters.test.ts\`. Mock responses must include ALL Zod-required fields — minimal stubs fail schema validation with a noisy \`ApiError\`. - -* **Telemetry instrumentation pattern: withTracingSpan + captureException for handled errors**: For graceful-fallback operations, use \`withTracingSpan\` from \`src/lib/telemetry.ts\` for child spans and \`captureException\` from \`@sentry/bun\` (named import — Biome forbids namespace imports) with \`level: 'warning'\` for non-fatal errors. \`withTracingSpan\` uses \`onlyIfParent: true\` — no-op without active transaction. User-visible fallbacks use \`log.warn()\` not \`log.debug()\`. Several commands bypass telemetry by importing \`buildCommand\` from \`@stricli/core\` directly instead of \`../../lib/command.js\` (trace/list, trace/view, log/view, api.ts, help.ts). + +* **Memoize identity fingerprint with test-reset hook + setAuthToken invalidation**: Memoize + test-reset pattern in src/lib/db/auth.ts: \`getIdentityFingerprint()\`, \`getAuthToken()\` (as \`cachedAuthToken\`), and the full auth row used by \`refreshToken()\` (as \`cachedAuthRow\`) are all memoized at module scope. Use wrapper-object sentinels \`{ value }\` to distinguish 'not cached' from 'cached as undefined' (logged out). Invalidate via \`reset\*Cache()\` exports at the only mutation points: \`setAuthToken()\` and \`clearAuth()\`. Safe under OAuth rotation (refresh\_token preserved) and 401 refresh (routes through setAuthToken). Tests mutating \`process.env.SENTRY\_AUTH\_TOKEN\` bypass the mutation hooks — must call reset functions manually in beforeEach and inside property-test bodies. \`useTestConfigDir\` calls all three resets in beforeEach/afterEach to prevent cross-file pollution in Bun's sequential test runner. Same memo+reset pattern mirrors \`resetUpdateNotificationState\`, \`resetCacheState\`, \`resetAuthHintState\`. Fixed N+1 SQL hits per API request (CLI-13V). - -* **Testing Stricli command func() bodies via spyOn mocking**: Testing Stricli command func() bodies: (1) \`const func = await cmd.loader(); func.call(mockContext, flags, ...args)\` with mock \`stdout\`, \`stderr\`, \`cwd\`, \`setContext\`. \`loader()\` return type union causes \`.call()\` LSP false-positives that pass \`tsc --noEmit\`. (2) When API functions are renamed, update both spy target AND mock return shape. (3) \`normalizeSlug\` replaces \`\_\`→\`-\` but does NOT lowercase. (4) Bun \`mockFetch()\` replaces \`globalThis.fetch\` — use one unified mock dispatching by URL. (5) \`mock.module()\` pollutes module registry for ALL subsequent files — put in \`test/isolated/\` and run via \`test:isolated\`. (6) For \`Bun.spawn\`, use direct property assignment in \`beforeEach\`/\`afterEach\`. + +* **Stricli parse functions can perform validation and sanitization at flag-parse time**: Stricli's \`parse\` fn on \`kind: "parsed"\` flags runs during argument parsing before \`func()\`. Can throw (including \`ValidationError\`) and log warnings. Uses: \`parseCursorFlag\`, \`sanitizeQuery\`, \`parsePeriod\` (returns \`TimeRange\`), \`parseSort\`/\`parseSortFlag\`, \`numberParser\`/\`parseLimit\`. Optional period flags: \`flags.period\` is \`TimeRange | undefined\` — commands default to \`TIME\_RANGE\_\*\` constants. \`formatTimeRangeFlag()\` converts back; \`appendPeriodHint()\` in \`time-range.ts\` encapsulates hint-building across 4+ commands. ### Preference - -* **Bot review triage: distinguish real bugs from SDK-mirroring false positives**: When Sentry Seer or Cursor Bugbot flags 'unusual' code that intentionally mirrors upstream SDK behavior (e.g., \`http\_proxy\` as last-resort fallback for HTTPS URLs — deliberate in \`@sentry/node-core\` \`applyNoProxyOption\`), decline with a written rationale referencing the SDK source rather than silently changing behavior. Removing the mirror creates a divergence where users get different proxy semantics from our transport vs. the SDK default. BYK's pattern: verify against \`node\_modules/@sentry/node-core/build/esm/transports/http.js\`, post a reply explaining the precedent, and resolve the thread. Real bugs (uppercase env var support, whitespace trimming, wildcard handling) get fixed; SDK-mirroring 'bugs' get explained and dismissed. + +* **Code review style: BYK values brevity; trim JSDoc essays aggressively**: BYK code-review style — brevity first: terse 1-3 line JSDoc; remove comments that restate code; don't wrap try/catch around no-throw helpers (but DO wrap post-success housekeeping like cache invalidation — defense-in-depth); MD5 over HMAC for non-auth hashing; no lazy imports without documented reason. Prefer \`\[...new Set(items)]\` over hand-rolled dedupe; \`toSpliced\` over spread+new-array; spread/slice over \`.unshift()\` on returned API objects. Direct questions drive simplification ('inputs never change, why not memoize?' → memoize+reset). Dismiss CodeQL false positives via \`gh api\` with rationale. 'Centralized mechanism' → file follow-up issue, not scope creep. Implement trivial reviewer suggestions in-PR rather than deferring. Run subagent self-review on merge-ready PRs — typical yield 1-3 items (stale PR descriptions, CI-only lint, doc drift). Take bot findings (Cursor Bugbot, Seer) seriously even after self-review approval — expect 4-6 rounds on subtle Unicode/regex/error-handling PRs. diff --git a/src/commands/explore.ts b/src/commands/explore.ts index d62fcd0f9..d3d542f30 100644 --- a/src/commands/explore.ts +++ b/src/commands/explore.ts @@ -660,14 +660,24 @@ export const exploreCommand = buildListCommand("explore", { ); const dataset = flags.dataset; + const userSuppliedFields = flags.field && flags.field.length > 0; let fieldList = [...defaultFieldsForDataset(dataset)]; - if (flags.field && flags.field.length > 0) { + if (userSuppliedFields) { fieldList = flags.field; } const timeRange = flags.period; const environment = parseReplayEnvironmentFilter(flags.environment); if (dataset === "metricsEnhanced") { + if (!userSuppliedFields) { + throw new ValidationError( + "The metrics dataset requires explicit --field flags with tracemetrics format.\n\n" + + "Format: aggregation(value,metric_name,metric_type,unit)\n\n" + + "Example:\n" + + ' sentry explore my-org/ -F "sum(value,llm.token_usage,distribution,none)" --dataset metrics', + "field" + ); + } validateMetricsFields(fieldList); } diff --git a/test/commands/explore.test.ts b/test/commands/explore.test.ts index ff3922486..97a193eb6 100644 --- a/test/commands/explore.test.ts +++ b/test/commands/explore.test.ts @@ -491,6 +491,80 @@ describe("sentry explore", () => { }); }); + describe("metrics dataset validation", () => { + test("rejects standard aggregates on metrics dataset", async () => { + resolveTargetSpy.mockResolvedValue({ org: "test-org" }); + const { context } = createContext(); + + const promise = func.call( + context, + { + ...DEFAULT_FLAGS, + dataset: "metricsEnhanced", + field: ["title", "count()"], + }, + "test-org/" + ); + + await expect(promise).rejects.toThrow(ValidationError); + await expect(promise).rejects.toThrow(/Invalid metrics aggregate/); + }); + + test("accepts valid tracemetrics aggregate format", async () => { + resolveTargetSpy.mockResolvedValue({ org: "test-org" }); + const { context } = createContext(); + + await func.call( + context, + { + ...DEFAULT_FLAGS, + dataset: "metricsEnhanced", + field: [ + "gen_ai.request.model", + "sum(value,llm.token_usage,distribution,none)", + ], + }, + "test-org/" + ); + + expect(queryEventsSpy).toHaveBeenCalledWith( + "test-org", + expect.objectContaining({ dataset: "metricsEnhanced" }) + ); + }); + + test("requires explicit --field flags for metrics dataset", async () => { + resolveTargetSpy.mockResolvedValue({ org: "test-org" }); + const { context } = createContext(); + + const promise = func.call( + context, + { ...DEFAULT_FLAGS, dataset: "metricsEnhanced" }, + "test-org/" + ); + + await expect(promise).rejects.toThrow(ValidationError); + await expect(promise).rejects.toThrow(/requires explicit --field flags/); + }); + + test("allows non-aggregate fields without tracemetrics format", async () => { + resolveTargetSpy.mockResolvedValue({ org: "test-org" }); + const { context } = createContext(); + + await func.call( + context, + { + ...DEFAULT_FLAGS, + dataset: "metricsEnhanced", + field: ["gen_ai.request.model"], + }, + "test-org/" + ); + + expect(queryEventsSpy).toHaveBeenCalled(); + }); + }); + describe("output", () => { test("renders human-readable table with results", async () => { resolveTargetSpy.mockResolvedValue({ org: "test-org" }); From 983201301e5726776fd324ecb3e3952af84775c8 Mon Sep 17 00:00:00 2001 From: Aditya Mathur <57684218+MathurAditya724@users.noreply.github.com> Date: Thu, 7 May 2026 05:02:43 +0000 Subject: [PATCH 03/12] feat(explore): add --metric flag for auto-resolving tracemetrics format MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add --metric (-m) flag that auto-discovers a metric's type and unit via the Events API, then constructs the tracemetrics aggregate format automatically. This eliminates the need to know the arcane aggregation(value,name,type,unit) syntax. Example: sentry explore my-org/seer -F gen_ai.request.model -m llm.token_usage \ --dataset metrics --period 7d Instead of: sentry explore my-org/seer -F gen_ai.request.model \ -F "sum(value,llm.token_usage,distribution,none)" \ --dataset metrics --period 7d - Add queryMetricsMeta() to discover.ts — queries Events API with metric.name/type/unit fields (same technique as Sentry Explore UI) - Add src/lib/metrics-transform.ts with resolveMetricField() and makeTracemetricsAggregate() helpers - Add --agg flag (default: sum) to control aggregation function - Wire auto mode into explore func() — grouping fields from -F are preserved alongside the auto-constructed aggregate - Update error message for bare --dataset metrics to mention --metric - Add 10 unit tests for metrics-transform, 3 integration tests for --metric flag in explore --- docs/src/fragments/commands/explore.md | 20 +++--- src/commands/explore.ts | 60 ++++++++++++++--- src/lib/api-client.ts | 3 +- src/lib/api/discover.ts | 43 ++++++++++++ src/lib/metrics-transform.ts | 74 +++++++++++++++++++++ test/commands/explore.test.ts | 92 +++++++++++++++++++++++++- test/lib/metrics-transform.test.ts | 92 ++++++++++++++++++++++++++ 7 files changed, 365 insertions(+), 19 deletions(-) create mode 100644 src/lib/metrics-transform.ts create mode 100644 test/lib/metrics-transform.test.ts diff --git a/docs/src/fragments/commands/explore.md b/docs/src/fragments/commands/explore.md index 078b53267..e926a9541 100644 --- a/docs/src/fragments/commands/explore.md +++ b/docs/src/fragments/commands/explore.md @@ -44,22 +44,26 @@ sentry explore my-org/cli -F span.op -F "count()" \ ### Metrics -Metrics aggregates use the tracemetrics format: `aggregation(value,metric_name,metric_type,unit)`. +Use `--metric` (`-m`) to query metrics by name. The CLI auto-resolves the metric's type and unit. ```bash # Sum a custom metric (e.g., LLM token usage) across an org -sentry explore my-org/ -F "sum(value,llm.token_usage,distribution,none)" \ - --dataset metrics --period 7d +sentry explore my-org/ -m llm.token_usage --dataset metrics --period 7d # Break down by a tag column (e.g., model name) sentry explore my-org/seer -F gen_ai.request.model \ + -m llm.token_usage --dataset metrics --period 7d + +# Use a different aggregation (default is sum) +sentry explore my-org/ -m cache.hit_rate --agg avg --dataset metrics +``` + +You can also use the raw tracemetrics format: `aggregation(value,metric_name,metric_type,unit)`. + +```bash +sentry explore my-org/ \ -F "sum(value,llm.token_usage,distribution,none)" \ --dataset metrics --period 7d - -# Average a distribution metric -sentry explore my-org/cli -F transaction \ - -F "avg(value,http.response_time,distribution,millisecond)" \ - --dataset metrics --period 24h ``` ### Logs diff --git a/src/commands/explore.ts b/src/commands/explore.ts index d3d542f30..61eb947f4 100644 --- a/src/commands/explore.ts +++ b/src/commands/explore.ts @@ -11,6 +11,7 @@ import { isReplaySortValue, listReplays, queryEvents, + queryMetricsMeta, } from "../lib/api-client.js"; import { buildProjectQuery, validateLimit } from "../lib/arg-parsing.js"; import { @@ -33,6 +34,7 @@ import { paginationHint, } from "../lib/list-command.js"; import { logger } from "../lib/logger.js"; +import { resolveMetricField } from "../lib/metrics-transform.js"; import { withProgress } from "../lib/polling.js"; import { DEFAULT_REPLAY_EXPLORE_FIELDS, @@ -123,6 +125,8 @@ const API_TO_USER_DATASET = new Map( type ExploreFlags = { readonly field?: string[]; + readonly metric?: string; + readonly agg: string; readonly dataset: string; readonly environment?: readonly string[]; readonly query?: string; @@ -571,10 +575,10 @@ export const exploreCommand = buildListCommand("explore", { " sentry explore my-org/cli --dataset replays -F id -F user.email -F count_errors\n" + ' sentry explore -F span.op -F "count()" --dataset spans --period 1h\n' + " sentry explore --json\n\n" + - "Metrics format:\n" + - " Metrics aggregates use: aggregation(value,metric_name,metric_type,unit)\n" + - ' sentry explore my-org/ -F "sum(value,llm.token_usage,distribution,none)" --dataset metrics\n' + - ' sentry explore my-org/seer -F gen_ai.request.model -F "sum(value,llm.token_usage,distribution,none)" --dataset metrics --period 7d', + "Metrics (auto mode — resolves type/unit automatically):\n" + + " sentry explore my-org/ -m llm.token_usage --dataset metrics\n" + + " sentry explore my-org/seer -F gen_ai.request.model -m llm.token_usage --dataset metrics --period 7d\n" + + " sentry explore my-org/ -m cache.hit_rate --agg avg --dataset metrics", }, output: { human: formatExploreHuman, @@ -602,6 +606,19 @@ export const exploreCommand = buildListCommand("explore", { variadic: true, optional: true, }, + metric: { + kind: "parsed", + parse: String, + brief: + "Metric name for --dataset metrics. Auto-resolves type/unit via API.", + optional: true, + }, + agg: { + kind: "parsed", + parse: String, + brief: "Aggregation for --metric (sum, avg, count, p50, p95, etc.)", + default: "sum", + }, dataset: { kind: "parsed", parse: parseDataset, @@ -645,6 +662,7 @@ export const exploreCommand = buildListCommand("explore", { ...PERIOD_ALIASES, e: "environment", F: "field", + m: "metric", d: "dataset", q: "query", s: "sort", @@ -668,12 +686,38 @@ export const exploreCommand = buildListCommand("explore", { const timeRange = flags.period; const environment = parseReplayEnvironmentFilter(flags.environment); - if (dataset === "metricsEnhanced") { + // --metric auto mode: resolve metric name → tracemetrics aggregate + if (flags.metric) { + if (dataset !== "metricsEnhanced") { + log.warn("--metric implies --dataset metrics; switching dataset."); + } + + const metrics = await withProgress( + { + message: `Discovering metric '${flags.metric}'...`, + json: flags.json, + }, + () => + queryMetricsMeta(org, { + statsPeriod: "7d", + project, + }) + ); + + const aggField = resolveMetricField(flags.metric, flags.agg, metrics); + // Prepend any user-supplied grouping fields, then the resolved aggregate + const groupByFields = userSuppliedFields + ? fieldList.filter((f) => !isAggregate(f)) + : []; + fieldList = [...groupByFields, aggField]; + } else if (dataset === "metricsEnhanced") { if (!userSuppliedFields) { throw new ValidationError( - "The metrics dataset requires explicit --field flags with tracemetrics format.\n\n" + - "Format: aggregation(value,metric_name,metric_type,unit)\n\n" + - "Example:\n" + + "The metrics dataset requires --metric or explicit --field flags.\n\n" + + "Auto mode (recommended):\n" + + " sentry explore my-org/ -m llm.token_usage --dataset metrics\n" + + " sentry explore my-org/ -m llm.token_usage --agg avg --dataset metrics\n\n" + + "Manual mode (tracemetrics format):\n" + ' sentry explore my-org/ -F "sum(value,llm.token_usage,distribution,none)" --dataset metrics', "field" ); diff --git a/src/lib/api-client.ts b/src/lib/api-client.ts index fa59e9d67..c58beef66 100644 --- a/src/lib/api-client.ts +++ b/src/lib/api-client.ts @@ -27,7 +27,8 @@ export { queryAllWidgets, updateDashboard, } from "./api/dashboards.js"; -export { queryEvents } from "./api/discover.js"; +export type { MetricMeta } from "./api/discover.js"; +export { queryEvents, queryMetricsMeta } from "./api/discover.js"; export { findEventAcrossOrgs, getEvent, diff --git a/src/lib/api/discover.ts b/src/lib/api/discover.ts index afb6f47a3..6e5a48e79 100644 --- a/src/lib/api/discover.ts +++ b/src/lib/api/discover.ts @@ -86,6 +86,49 @@ async function fetchEventsPage( return { data, nextCursor }; } +/** Metric metadata returned by {@link queryMetricsMeta}. */ +export type MetricMeta = { + name: string; + type: string; + unit: string; +}; + +/** + * Discover available metrics for an org via the Events API. + * + * Queries `dataset=metricsEnhanced` with meta-fields (`metric.name`, etc.) + * — the same technique the Sentry Explore Metrics UI uses. + */ +export async function queryMetricsMeta( + orgSlug: string, + options?: { + statsPeriod?: string; + project?: string; + } +): Promise { + const regionUrl = await resolveOrgRegion(orgSlug); + const query = options?.project ? `project:${options.project}` : undefined; + + const { data } = await fetchEventsPage( + regionUrl, + orgSlug, + { + fields: ["metric.name", "metric.type", "metric.unit"], + dataset: "metricsEnhanced", + query, + statsPeriod: options?.statsPeriod ?? "7d", + limit: 100, + }, + 100 + ); + + return data.data.map((row) => ({ + name: String(row["metric.name"] ?? ""), + type: String(row["metric.type"] ?? "distribution"), + unit: String(row["metric.unit"] ?? "none"), + })); +} + /** * Query the Explore/Events endpoint for aggregate or tabular event data. * diff --git a/src/lib/metrics-transform.ts b/src/lib/metrics-transform.ts new file mode 100644 index 000000000..4215ab930 --- /dev/null +++ b/src/lib/metrics-transform.ts @@ -0,0 +1,74 @@ +/** + * Tracemetrics aggregate construction from simple metric names. + * + * Transforms user-friendly metric names (e.g., `llm.token_usage`) into the + * four-part tracemetrics format required by the Sentry Events API when + * querying `dataset=metricsEnhanced`: `aggregation(value,name,type,unit)`. + */ + +import type { MetricMeta } from "./api/discover.js"; +import { ResolutionError } from "./errors.js"; + +/** Valid tracemetrics aggregation functions. */ +const VALID_AGGS = new Set([ + "sum", + "avg", + "count", + "min", + "max", + "p50", + "p75", + "p90", + "p95", + "p99", + "count_unique", +]); + +/** Build a tracemetrics aggregate string from parts. */ +export function makeTracemetricsAggregate( + agg: string, + name: string, + type: string, + unit: string +): string { + return `${agg}(value,${name},${type},${unit})`; +} + +/** + * Resolve a simple metric name against discovered metadata and build + * the tracemetrics aggregate field. + * + * @throws {ResolutionError} when the metric name isn't found + */ +export function resolveMetricField( + metricName: string, + agg: string, + metrics: MetricMeta[] +): string { + if (!VALID_AGGS.has(agg)) { + throw new ResolutionError( + `Aggregation '${agg}'`, + `not recognized. Valid aggregations: ${[...VALID_AGGS].join(", ")}`, + `sentry explore my-org/ -m ${metricName} --agg sum --dataset metrics` + ); + } + + const match = metrics.find((m) => m.name === metricName); + if (!match) { + const suggestions = metrics + .filter((m) => m.name.includes(metricName) || metricName.includes(m.name)) + .slice(0, 5) + .map((m) => m.name); + + throw new ResolutionError( + `Metric '${metricName}'`, + "not found in this project", + `sentry explore my-org/ -m ${metricName} --dataset metrics --period 7d`, + suggestions.length > 0 + ? [`Similar metrics: ${suggestions.join(", ")}`] + : ["Use a wider --period to search for older metrics"] + ); + } + + return makeTracemetricsAggregate(agg, match.name, match.type, match.unit); +} diff --git a/test/commands/explore.test.ts b/test/commands/explore.test.ts index 97a193eb6..00e0834bb 100644 --- a/test/commands/explore.test.ts +++ b/test/commands/explore.test.ts @@ -91,12 +91,18 @@ const MOCK_REPLAYS_RESPONSE = [ ]; let queryEventsSpy: ReturnType; +let queryMetricsMetaSpy: ReturnType; let listReplaysSpy: ReturnType; let resolveTargetSpy: ReturnType; let resolveCursorSpy: ReturnType; let advancePaginationStateSpy: ReturnType; let hasPreviousPageSpy: ReturnType; +const MOCK_METRICS_META = [ + { name: "llm.token_usage", type: "distribution", unit: "none" }, + { name: "cache.hit_rate", type: "distribution", unit: "none" }, +]; + beforeEach(async () => { func = (await exploreCommand.loader()) as unknown as ExploreFunc; @@ -105,6 +111,8 @@ beforeEach(async () => { data: MOCK_EVENTS_RESPONSE, nextCursor: undefined, }); + queryMetricsMetaSpy = spyOn(apiClient, "queryMetricsMeta"); + queryMetricsMetaSpy.mockResolvedValue(MOCK_METRICS_META); listReplaysSpy = spyOn(apiClient, "listReplays"); listReplaysSpy.mockResolvedValue({ data: MOCK_REPLAYS_RESPONSE, @@ -130,6 +138,7 @@ beforeEach(async () => { afterEach(() => { queryEventsSpy.mockRestore(); + queryMetricsMetaSpy.mockRestore(); listReplaysSpy.mockRestore(); resolveTargetSpy.mockRestore(); resolveCursorSpy.mockRestore(); @@ -139,6 +148,7 @@ afterEach(() => { const DEFAULT_FLAGS = { limit: 25, + agg: "sum", dataset: "errors", period: parsePeriod("24h"), json: false, @@ -533,7 +543,7 @@ describe("sentry explore", () => { ); }); - test("requires explicit --field flags for metrics dataset", async () => { + test("requires --metric or --field for metrics dataset", async () => { resolveTargetSpy.mockResolvedValue({ org: "test-org" }); const { context } = createContext(); @@ -544,7 +554,9 @@ describe("sentry explore", () => { ); await expect(promise).rejects.toThrow(ValidationError); - await expect(promise).rejects.toThrow(/requires explicit --field flags/); + await expect(promise).rejects.toThrow( + /requires --metric or explicit --field/ + ); }); test("allows non-aggregate fields without tracemetrics format", async () => { @@ -563,6 +575,82 @@ describe("sentry explore", () => { expect(queryEventsSpy).toHaveBeenCalled(); }); + + test("--metric auto-resolves metric type and unit", async () => { + resolveTargetSpy.mockResolvedValue({ org: "test-org" }); + const { context } = createContext(); + + await func.call( + context, + { + ...DEFAULT_FLAGS, + dataset: "metricsEnhanced", + metric: "llm.token_usage", + }, + "test-org/" + ); + + expect(queryMetricsMetaSpy).toHaveBeenCalledWith("test-org", { + statsPeriod: "7d", + project: undefined, + }); + expect(queryEventsSpy).toHaveBeenCalledWith( + "test-org", + expect.objectContaining({ + fields: ["sum(value,llm.token_usage,distribution,none)"], + dataset: "metricsEnhanced", + }) + ); + }); + + test("--metric with -F preserves grouping fields", async () => { + resolveTargetSpy.mockResolvedValue({ org: "test-org" }); + const { context } = createContext(); + + await func.call( + context, + { + ...DEFAULT_FLAGS, + dataset: "metricsEnhanced", + metric: "llm.token_usage", + field: ["gen_ai.request.model"], + }, + "test-org/" + ); + + expect(queryEventsSpy).toHaveBeenCalledWith( + "test-org", + expect.objectContaining({ + fields: [ + "gen_ai.request.model", + "sum(value,llm.token_usage,distribution,none)", + ], + }) + ); + }); + + test("--metric with --agg uses specified aggregation", async () => { + resolveTargetSpy.mockResolvedValue({ org: "test-org" }); + const { context } = createContext(); + + await func.call( + context, + { + ...DEFAULT_FLAGS, + dataset: "metricsEnhanced", + metric: "cache.hit_rate", + agg: "avg", + }, + "test-org/" + ); + + expect(queryEventsSpy).toHaveBeenCalledWith( + "test-org", + expect.objectContaining({ + fields: ["avg(value,cache.hit_rate,distribution,none)"], + }) + ); + }); }); describe("output", () => { diff --git a/test/lib/metrics-transform.test.ts b/test/lib/metrics-transform.test.ts new file mode 100644 index 000000000..f258f3753 --- /dev/null +++ b/test/lib/metrics-transform.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, test } from "bun:test"; +import type { MetricMeta } from "../../src/lib/api/discover.js"; +import { ResolutionError } from "../../src/lib/errors.js"; +import { + makeTracemetricsAggregate, + resolveMetricField, +} from "../../src/lib/metrics-transform.js"; + +const SAMPLE_METRICS: MetricMeta[] = [ + { name: "llm.token_usage", type: "distribution", unit: "none" }, + { name: "cache.hit_rate", type: "distribution", unit: "none" }, + { name: "http.response_time", type: "distribution", unit: "millisecond" }, + { name: "request.count", type: "counter", unit: "none" }, +]; + +describe("makeTracemetricsAggregate", () => { + test("builds standard format", () => { + expect( + makeTracemetricsAggregate( + "sum", + "llm.token_usage", + "distribution", + "none" + ) + ).toBe("sum(value,llm.token_usage,distribution,none)"); + }); + + test("preserves unit", () => { + expect( + makeTracemetricsAggregate( + "avg", + "http.response_time", + "distribution", + "millisecond" + ) + ).toBe("avg(value,http.response_time,distribution,millisecond)"); + }); + + test("works with p50 aggregation", () => { + expect( + makeTracemetricsAggregate("p50", "cache.hit_rate", "distribution", "none") + ).toBe("p50(value,cache.hit_rate,distribution,none)"); + }); +}); + +describe("resolveMetricField", () => { + test("resolves known metric with default agg", () => { + expect(resolveMetricField("llm.token_usage", "sum", SAMPLE_METRICS)).toBe( + "sum(value,llm.token_usage,distribution,none)" + ); + }); + + test("resolves with custom agg", () => { + expect(resolveMetricField("cache.hit_rate", "avg", SAMPLE_METRICS)).toBe( + "avg(value,cache.hit_rate,distribution,none)" + ); + }); + + test("preserves metric unit from metadata", () => { + expect( + resolveMetricField("http.response_time", "p95", SAMPLE_METRICS) + ).toBe("p95(value,http.response_time,distribution,millisecond)"); + }); + + test("throws ResolutionError for unknown metric", () => { + expect(() => + resolveMetricField("nonexistent.metric", "sum", SAMPLE_METRICS) + ).toThrow(ResolutionError); + }); + + test("suggests similar metrics when not found", () => { + try { + resolveMetricField("llm.token", "sum", SAMPLE_METRICS); + expect.unreachable("should have thrown"); + } catch (err) { + expect(err).toBeInstanceOf(ResolutionError); + expect((err as ResolutionError).message).toContain("llm.token_usage"); + } + }); + + test("throws ResolutionError for invalid aggregation", () => { + expect(() => + resolveMetricField("llm.token_usage", "invalid_agg", SAMPLE_METRICS) + ).toThrow(ResolutionError); + }); + + test("resolves counter-type metric", () => { + expect(resolveMetricField("request.count", "sum", SAMPLE_METRICS)).toBe( + "sum(value,request.count,counter,none)" + ); + }); +}); From 1477279b51343539e07d43a33a5f3add401f070b Mon Sep 17 00:00:00 2001 From: Aditya Mathur <57684218+MathurAditya724@users.noreply.github.com> Date: Thu, 7 May 2026 05:31:22 +0000 Subject: [PATCH 04/12] fix: auto-switch dataset when --metric used without --dataset metrics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two bugs caught during review: 1. --metric without --dataset metrics warned but didn't actually set dataset to metricsEnhanced, sending the tracemetrics aggregate to the wrong dataset 2. Metadata discovery hardcoded 7d statsPeriod, ignoring the user's --period flag — older metrics wouldn't appear in discovery results --- src/commands/explore.ts | 7 +++++-- test/commands/explore.test.ts | 25 ++++++++++++++++++++++++- 2 files changed, 29 insertions(+), 3 deletions(-) diff --git a/src/commands/explore.ts b/src/commands/explore.ts index 61eb947f4..383fd6883 100644 --- a/src/commands/explore.ts +++ b/src/commands/explore.ts @@ -677,7 +677,7 @@ export const exploreCommand = buildListCommand("explore", { "explore" ); - const dataset = flags.dataset; + let dataset = flags.dataset; const userSuppliedFields = flags.field && flags.field.length > 0; let fieldList = [...defaultFieldsForDataset(dataset)]; if (userSuppliedFields) { @@ -690,8 +690,11 @@ export const exploreCommand = buildListCommand("explore", { if (flags.metric) { if (dataset !== "metricsEnhanced") { log.warn("--metric implies --dataset metrics; switching dataset."); + dataset = "metricsEnhanced"; } + // Use the user's --period for metadata discovery so older metrics are found + const metaParams = timeRangeToApiParams(timeRange); const metrics = await withProgress( { message: `Discovering metric '${flags.metric}'...`, @@ -699,7 +702,7 @@ export const exploreCommand = buildListCommand("explore", { }, () => queryMetricsMeta(org, { - statsPeriod: "7d", + statsPeriod: metaParams.statsPeriod, project, }) ); diff --git a/test/commands/explore.test.ts b/test/commands/explore.test.ts index 00e0834bb..0e248cd1b 100644 --- a/test/commands/explore.test.ts +++ b/test/commands/explore.test.ts @@ -591,7 +591,7 @@ describe("sentry explore", () => { ); expect(queryMetricsMetaSpy).toHaveBeenCalledWith("test-org", { - statsPeriod: "7d", + statsPeriod: "24h", project: undefined, }); expect(queryEventsSpy).toHaveBeenCalledWith( @@ -651,6 +651,29 @@ describe("sentry explore", () => { }) ); }); + + test("--metric without --dataset metrics auto-switches to metricsEnhanced", async () => { + resolveTargetSpy.mockResolvedValue({ org: "test-org" }); + const { context } = createContext(); + + await func.call( + context, + { + ...DEFAULT_FLAGS, + dataset: "errors", + metric: "llm.token_usage", + }, + "test-org/" + ); + + expect(queryEventsSpy).toHaveBeenCalledWith( + "test-org", + expect.objectContaining({ + dataset: "metricsEnhanced", + fields: ["sum(value,llm.token_usage,distribution,none)"], + }) + ); + }); }); describe("output", () => { From f645f2a92fd8be03744a7b5ac6c1c9f080aafd19 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 7 May 2026 05:32:09 +0000 Subject: [PATCH 05/12] chore: regenerate docs --- .../skills/sentry-cli/references/explore.md | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/plugins/sentry-cli/skills/sentry-cli/references/explore.md b/plugins/sentry-cli/skills/sentry-cli/references/explore.md index 33a521ad2..b222dba65 100644 --- a/plugins/sentry-cli/skills/sentry-cli/references/explore.md +++ b/plugins/sentry-cli/skills/sentry-cli/references/explore.md @@ -17,6 +17,8 @@ Query aggregate event data (Explore) **Flags:** - `-F, --field ... - API field or aggregate (repeatable). E.g., title, "count()", "p50(transaction.duration)"` +- `-m, --metric - Metric name for --dataset metrics. Auto-resolves type/unit via API.` +- `--agg - Aggregation for --metric (sum, avg, count, p50, p95, etc.) - (default: "sum")` - `-d, --dataset - Dataset to query (errors, spans, metrics, logs, replays) - (default: "errors")` - `-q, --query - Search query (Sentry search syntax)` - `-s, --sort - Sort field (prefix with - for desc, e.g., "-count()")` @@ -57,9 +59,19 @@ sentry explore my-org/cli -F span.op -F "p50(span.duration)" \ sentry explore my-org/cli -F span.op -F "count()" \ --dataset spans --sort "-count()" -# Custom metric aggregations -sentry explore my-org/cli -F transaction -F "avg(measurements.fcp)" \ - --dataset metrics --period 24h +# Sum a custom metric (e.g., LLM token usage) across an org +sentry explore my-org/ -m llm.token_usage --dataset metrics --period 7d + +# Break down by a tag column (e.g., model name) +sentry explore my-org/seer -F gen_ai.request.model \ + -m llm.token_usage --dataset metrics --period 7d + +# Use a different aggregation (default is sum) +sentry explore my-org/ -m cache.hit_rate --agg avg --dataset metrics + +sentry explore my-org/ \ + -F "sum(value,llm.token_usage,distribution,none)" \ + --dataset metrics --period 7d # Log severity counts in the last hour sentry explore my-org/cli -F severity -F "count()" \ From 320e10accb67e37476b9b7f5322c0edac266b555 Mon Sep 17 00:00:00 2001 From: Aditya Mathur <57684218+MathurAditya724@users.noreply.github.com> Date: Thu, 7 May 2026 06:24:57 +0000 Subject: [PATCH 06/12] fix: pass absolute time range to queryMetricsMeta When --period specifies an absolute range (e.g. 2026-04-01..2026-05-01), timeRangeToApiParams returns {start, end} with no statsPeriod. Previously only statsPeriod was forwarded to queryMetricsMeta, causing it to silently fall back to 7d and potentially miss the target metric. --- src/commands/explore.ts | 2 +- src/lib/api/discover.ts | 9 ++++++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/src/commands/explore.ts b/src/commands/explore.ts index 383fd6883..4e07b3393 100644 --- a/src/commands/explore.ts +++ b/src/commands/explore.ts @@ -702,7 +702,7 @@ export const exploreCommand = buildListCommand("explore", { }, () => queryMetricsMeta(org, { - statsPeriod: metaParams.statsPeriod, + ...metaParams, project, }) ); diff --git a/src/lib/api/discover.ts b/src/lib/api/discover.ts index 6e5a48e79..33a94fe90 100644 --- a/src/lib/api/discover.ts +++ b/src/lib/api/discover.ts @@ -103,6 +103,8 @@ export async function queryMetricsMeta( orgSlug: string, options?: { statsPeriod?: string; + start?: string; + end?: string; project?: string; } ): Promise { @@ -116,7 +118,12 @@ export async function queryMetricsMeta( fields: ["metric.name", "metric.type", "metric.unit"], dataset: "metricsEnhanced", query, - statsPeriod: options?.statsPeriod ?? "7d", + statsPeriod: + options?.start || options?.end + ? undefined + : (options?.statsPeriod ?? "7d"), + start: options?.start, + end: options?.end, limit: 100, }, 100 From 6ea7639f3a11f12d42a23849c6f10f8c96bc3201 Mon Sep 17 00:00:00 2001 From: Aditya Mathur <57684218+MathurAditya724@users.noreply.github.com> Date: Thu, 7 May 2026 06:29:26 +0000 Subject: [PATCH 07/12] fix: include --metric and --agg in pagination hints --- src/commands/explore.ts | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/src/commands/explore.ts b/src/commands/explore.ts index 4e07b3393..a38b23742 100644 --- a/src/commands/explore.ts +++ b/src/commands/explore.ts @@ -315,7 +315,15 @@ function appendFlagHints( base: string, flags: Pick< ExploreFlags, - "dataset" | "environment" | "sort" | "query" | "period" | "field" | "limit" + | "dataset" + | "environment" + | "sort" + | "query" + | "period" + | "field" + | "limit" + | "metric" + | "agg" > ): string { const parts: string[] = []; @@ -327,6 +335,12 @@ function appendFlagHints( API_TO_USER_DATASET.get(flags.dataset) ?? flags.dataset; parts.push(`--dataset ${displayDataset}`); } + if (flags.metric) { + parts.push(`-m "${flags.metric}"`); + if (flags.agg !== "sum") { + parts.push(`--agg ${flags.agg}`); + } + } appendSortHint(parts, flags.sort, defaultSort); appendQueryHint(parts, flags.query); // Include --field flags when non-default From f73cf75529699ebaf367de63b8bc581d5aeb3e11 Mon Sep 17 00:00:00 2001 From: Aditya Mathur <57684218+MathurAditya724@users.noreply.github.com> Date: Thu, 7 May 2026 06:35:17 +0000 Subject: [PATCH 08/12] fix: paginate queryMetricsMeta to handle orgs with >100 metrics Previously queryMetricsMeta fetched a single page (100 results) and discarded the cursor, silently truncating the metric list for large orgs. Now uses the same pagination loop as queryEvents, bounded by MAX_PAGINATION_PAGES. --- src/lib/api/discover.ts | 50 ++++++++++++++++++++++++++--------------- 1 file changed, 32 insertions(+), 18 deletions(-) diff --git a/src/lib/api/discover.ts b/src/lib/api/discover.ts index 33a94fe90..d7fada926 100644 --- a/src/lib/api/discover.ts +++ b/src/lib/api/discover.ts @@ -98,6 +98,9 @@ export type MetricMeta = { * * Queries `dataset=metricsEnhanced` with meta-fields (`metric.name`, etc.) * — the same technique the Sentry Explore Metrics UI uses. + * + * Auto-paginates to collect all available metrics (bounded by + * {@link MAX_PAGINATION_PAGES} to prevent runaway loops). */ export async function queryMetricsMeta( orgSlug: string, @@ -111,25 +114,36 @@ export async function queryMetricsMeta( const regionUrl = await resolveOrgRegion(orgSlug); const query = options?.project ? `project:${options.project}` : undefined; - const { data } = await fetchEventsPage( - regionUrl, - orgSlug, - { - fields: ["metric.name", "metric.type", "metric.unit"], - dataset: "metricsEnhanced", - query, - statsPeriod: - options?.start || options?.end - ? undefined - : (options?.statsPeriod ?? "7d"), - start: options?.start, - end: options?.end, - limit: 100, - }, - 100 - ); + const baseOptions: ExploreQueryOptions = { + fields: ["metric.name", "metric.type", "metric.unit"], + dataset: "metricsEnhanced", + query, + statsPeriod: + options?.start || options?.end + ? undefined + : (options?.statsPeriod ?? "7d"), + start: options?.start, + end: options?.end, + }; + + const allRows: Record[] = []; + let cursor: string | undefined; + + for (let page = 0; page < MAX_PAGINATION_PAGES; page += 1) { + const result = await fetchEventsPage( + regionUrl, + orgSlug, + { ...baseOptions, cursor }, + API_MAX_PER_PAGE + ); + + allRows.push(...result.data.data); + + if (!result.nextCursor) break; + cursor = result.nextCursor; + } - return data.data.map((row) => ({ + return allRows.map((row) => ({ name: String(row["metric.name"] ?? ""), type: String(row["metric.type"] ?? "distribution"), unit: String(row["metric.unit"] ?? "none"), From fda1fcd9320c5959a98c97c6d6cc85266e8d78c9 Mon Sep 17 00:00:00 2001 From: Aditya Mathur <57684218+MathurAditya724@users.noreply.github.com> Date: Thu, 7 May 2026 06:39:58 +0000 Subject: [PATCH 09/12] fix: filter aggregate fields from pagination hint when --metric is active appendFlagHints was reading the original flags.field, which included aggregate fields that get silently dropped from the query when --metric is set. The hint now mirrors the same filtering, so it accurately reflects the executed query. --- src/commands/explore.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/commands/explore.ts b/src/commands/explore.ts index a38b23742..682069679 100644 --- a/src/commands/explore.ts +++ b/src/commands/explore.ts @@ -343,8 +343,12 @@ function appendFlagHints( } appendSortHint(parts, flags.sort, defaultSort); appendQueryHint(parts, flags.query); - // Include --field flags when non-default - const fieldList = flags.field ?? []; + // Include --field flags when non-default. + // When --metric is active, aggregates are dropped from the query — mirror that here. + const rawFields = flags.field ?? []; + const fieldList = flags.metric + ? rawFields.filter((f) => !isAggregate(f)) + : rawFields; const currentFieldStr = fieldList.join(","); if ( currentFieldStr !== defaultFieldsForDataset(flags.dataset).join(",") && From 801861945af787a2aa946104da04da2fa32e7bfc Mon Sep 17 00:00:00 2001 From: Aditya Mathur <57684218+MathurAditya724@users.noreply.github.com> Date: Thu, 7 May 2026 06:42:25 +0000 Subject: [PATCH 10/12] fix: propagate resolved dataset to pagination hint flags When --metric auto-switches dataset to metricsEnhanced, the pagination hints now reflect the actual dataset so subsequent paginated requests include --dataset metrics. --- src/commands/explore.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/commands/explore.ts b/src/commands/explore.ts index 682069679..fbc5d343b 100644 --- a/src/commands/explore.ts +++ b/src/commands/explore.ts @@ -786,11 +786,18 @@ export const exploreCommand = buildListCommand("explore", { const hasMore = !!nextCursor; const baseTarget = project ? `${org}/${project}` : `${org}/`; + const hintFlags = { ...flags, dataset }; const nav = paginationHint({ hasPrev, hasMore, - prevHint: appendFlagHints(`sentry explore ${baseTarget} -c prev`, flags), - nextHint: appendFlagHints(`sentry explore ${baseTarget} -c next`, flags), + prevHint: appendFlagHints( + `sentry explore ${baseTarget} -c prev`, + hintFlags + ), + nextHint: appendFlagHints( + `sentry explore ${baseTarget} -c next`, + hintFlags + ), }); const hint = buildResultHint(response.data.length, nav); From da5e3d4d5de6b67fce245480b0592978280902fe Mon Sep 17 00:00:00 2001 From: Aditya Mathur <57684218+MathurAditya724@users.noreply.github.com> Date: Thu, 7 May 2026 23:24:02 +0000 Subject: [PATCH 11/12] fix: resolve lint errors in explore.ts and discover.ts Extract appendMetricHints and appendFieldHints from appendFlagHints to bring cognitive complexity from 18 down to under the 15 limit. Add block braces to single-line if/break in queryMetricsMeta. --- src/commands/explore.ts | 56 +++++++++++++++++++++++++---------------- src/lib/api/discover.ts | 4 ++- 2 files changed, 38 insertions(+), 22 deletions(-) diff --git a/src/commands/explore.ts b/src/commands/explore.ts index fbc5d343b..ca02b9140 100644 --- a/src/commands/explore.ts +++ b/src/commands/explore.ts @@ -310,6 +310,39 @@ function defaultFieldsForDataset(dataset: string): readonly string[] { return dataset === "replays" ? DEFAULT_REPLAY_EXPLORE_FIELDS : DEFAULT_FIELDS; } +/** Append --metric / --agg flags to hint parts */ +function appendMetricHints( + parts: string[], + metric: string | undefined, + agg: string +): void { + if (metric) { + parts.push(`-m "${metric}"`); + if (agg !== "sum") { + parts.push(`--agg ${agg}`); + } + } +} + +/** Append non-default --field flags to hint parts */ +function appendFieldHints( + parts: string[], + rawFields: string[] | undefined, + dataset: string, + metricActive: boolean +): void { + const fields = rawFields ?? []; + const fieldList = metricActive + ? fields.filter((f) => !isAggregate(f)) + : fields; + const defaults = defaultFieldsForDataset(dataset).join(","); + if (fieldList.join(",") !== defaults && fieldList.length > 0) { + for (const f of fieldList) { + parts.push(`-F "${f}"`); + } + } +} + /** Append active non-default flags to a base command string */ function appendFlagHints( base: string, @@ -335,29 +368,10 @@ function appendFlagHints( API_TO_USER_DATASET.get(flags.dataset) ?? flags.dataset; parts.push(`--dataset ${displayDataset}`); } - if (flags.metric) { - parts.push(`-m "${flags.metric}"`); - if (flags.agg !== "sum") { - parts.push(`--agg ${flags.agg}`); - } - } + appendMetricHints(parts, flags.metric, flags.agg); appendSortHint(parts, flags.sort, defaultSort); appendQueryHint(parts, flags.query); - // Include --field flags when non-default. - // When --metric is active, aggregates are dropped from the query — mirror that here. - const rawFields = flags.field ?? []; - const fieldList = flags.metric - ? rawFields.filter((f) => !isAggregate(f)) - : rawFields; - const currentFieldStr = fieldList.join(","); - if ( - currentFieldStr !== defaultFieldsForDataset(flags.dataset).join(",") && - fieldList.length > 0 - ) { - for (const f of fieldList) { - parts.push(`-F "${f}"`); - } - } + appendFieldHints(parts, flags.field, flags.dataset, !!flags.metric); if (flags.limit !== DEFAULT_LIMIT) { parts.push(`--limit ${flags.limit}`); } diff --git a/src/lib/api/discover.ts b/src/lib/api/discover.ts index d7fada926..cdd73522a 100644 --- a/src/lib/api/discover.ts +++ b/src/lib/api/discover.ts @@ -139,7 +139,9 @@ export async function queryMetricsMeta( allRows.push(...result.data.data); - if (!result.nextCursor) break; + if (!result.nextCursor) { + break; + } cursor = result.nextCursor; } From fa3f0d162460ec35c0ec42a520765d03176f9dd5 Mon Sep 17 00:00:00 2001 From: Aditya Mathur <57684218+MathurAditya724@users.noreply.github.com> Date: Tue, 12 May 2026 09:24:19 +0000 Subject: [PATCH 12/12] fix(explore): use fuzzyMatch for metric suggestions, filter empty names The API can return metrics with empty names. Substring matching with metricName.includes('') is always true, causing empty strings in suggestions. Use fuzzyMatch from src/lib/fuzzy.ts instead. --- src/lib/metrics-transform.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/src/lib/metrics-transform.ts b/src/lib/metrics-transform.ts index 4215ab930..5dd19ed6e 100644 --- a/src/lib/metrics-transform.ts +++ b/src/lib/metrics-transform.ts @@ -8,6 +8,7 @@ import type { MetricMeta } from "./api/discover.js"; import { ResolutionError } from "./errors.js"; +import { fuzzyMatch } from "./fuzzy.js"; /** Valid tracemetrics aggregation functions. */ const VALID_AGGS = new Set([ @@ -55,10 +56,12 @@ export function resolveMetricField( const match = metrics.find((m) => m.name === metricName); if (!match) { - const suggestions = metrics - .filter((m) => m.name.includes(metricName) || metricName.includes(m.name)) - .slice(0, 5) - .map((m) => m.name); + const candidateNames = metrics + .map((m) => m.name) + .filter((n) => n.length > 0); + const suggestions = fuzzyMatch(metricName, candidateNames, { + maxResults: 5, + }); throw new ResolutionError( `Metric '${metricName}'`,