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/11] 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/11] 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/11] 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/11] 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/11] 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/11] 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/11] 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/11] 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/11] 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/11] 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/11] 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; }