Skip to content

Group CLI crash reports on structured error signals#7901

Merged
stephanie-shopify merged 7 commits into
mainfrom
fix/error-grouping-structured
Jul 6, 2026
Merged

Group CLI crash reports on structured error signals#7901
stephanie-shopify merged 7 commits into
mainfrom
fix/error-grouping-structured

Conversation

@stephanie-shopify

@stephanie-shopify stephanie-shopify commented Jun 23, 2026

Copy link
Copy Markdown
Contributor

WHY are these changes introduced?

Fixes #7891. which comes from resiliency issue

A single Bugsnag bucket (groupingHash 16864652937831232783) had become a catch-all for ~1,170 unrelated GraphQL/API errors spanning app/theme/store/hydrogen. That makes the bucket un-routable (five owning teams in one group), mixes expected/transient noise with genuine regressions, and manufactures false P1s.

The earlier fix attempt (#7894) addressed the symptom by regex-reparsing the stringified error message to reconstruct type information, and removed expected-error suppression entirely (reporting every AbortError). This PR is the redesign: it groups on the structured signals the error already carries.

The key insight: in sendErrorToBugsnag, only the reportable copy is flattened to new Error(error.message) — the original typed error is still in scope and already carries statusCode + errors[].extensions.code. So we group on facts, not on a string we just serialized.

WHAT is this pull request doing?

  • New error-grouping.ts — derives {httpStatus, code, errorClass} from the original error (GraphQLClientError and raw graphql-request ClientError) and maps them via an explicit decision table:
    • THROTTLED / 429rate_limit
    • 401authentication
    • 403 / ACCESS_DENIEDpermission
    • 5xxserver
    • else → shared keyword categorizer (fallback only)
    • unknown → leave groupingHash unset so Bugsnag's stack-trace grouping applies and distinct unknown bugs aren't merged.
  • error-handler.ts — sets event.groupingHash only when a real category resolves, and emits error_grouping metadata (slice_name, http_status, error_code, error_class) so backend dashboards/routing work regardless of CLI version (the hash is client-side; old versions keep the old hash until adoption rolls).
  • error.ts — raw ClientError with 401/429/THROTTLED is now treated as expected and kept out of crash reporting.
  • The shared error-categorizer.ts / storage.ts (Monorail analytics taxonomy) are untouched — their tests stay green, proving the error:${category}:${signature} events are unaffected.
  • A short error-grouping.DESIGN.md records the problem, constraints, and the three decisions.

How to test your changes?

pnpm vitest run packages/cli-kit/src/private/node/analytics/error-grouping.test.ts
pnpm vitest run packages/cli-kit/src/public/node/error-handler.test.ts
pnpm vitest run packages/cli-kit/src/public/node/error.test.ts
pnpm vitest run packages/cli-kit/src/private/node/analytics/   # categorizer + storage stay green

Decision-table matrix (asserted in tests): 403 ACCESS_DENIEDtheme:permission:http-403-access-denied; 401 → *:authentication:http-401; THROTTLED*:rate_limit:* and not reported; 5xx → server; raw ClientError; generic ErrorgroupingHash unset (stack grouping).

Rollout note

Setting groupingHash re-buckets all CLI errors once — a one-time grouping migration across CLI error dashboards. This is intended and an improvement, but should be flagged to whoever owns the Bugsnag/Observe dashboards before merge. The structured metadata tags give backend routing that works across all CLI versions immediately, which is the mitigation while client adoption rolls.

Follow-ups (out of scope)

  • At-source category/code on the FatalError hierarchy + API wrappers (the "proper" version of this).
  • Folding status/code grouping into error-categorizer.ts (needs a coordinated storage.ts update + pinned tests).
  • Alert on *:unknown:* and cli:* bucket growth so the catch-all surfaces itself.
  • Tie Bugsnag severity to SLO/error-budget burn rather than aggregate volume.

Checklist

  • I've considered possible cross-platform impacts (Mac, Linux, Windows) — pure TS, no platform-specific paths.
  • I've considered possible documentation changes — none needed (internal crash-reporting behavior).
  • I've considered analytics changes to measure impact — the shared Monorail taxonomy is deliberately untouched; new Bugsnag error_grouping metadata is additive.
  • The change is user-facing — patch bump added via changeset.

🤖 Generated with Claude Code

Replaces the single catch-all Bugsnag bucket (~1,170 unrelated GraphQL/API
errors across app/theme/store/hydrogen) with grouping derived from the original
typed error's HTTP status and GraphQL extensions.code, rather than regex-
reparsing a stringified message.

- New error-grouping.ts: structured-first decision table
  (THROTTLED/429->rate_limit, 401->authentication, 403/ACCESS_DENIED->permission,
  5xx->server), shared keyword categorizer as fallback, and undefined for unknown
  errors so Bugsnag stack-trace grouping is preserved.
- error-handler.ts: sets event.groupingHash only when a real category resolves,
  and emits error_grouping metadata (slice_name, http_status, error_code,
  error_class) so backend routing works regardless of CLI version.
- error.ts: raw graphql-request ClientError 401/429/THROTTLED is now treated as
  expected and kept out of crash reporting.

Shared error-categorizer.ts / storage.ts (Monorail taxonomy) are untouched.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@stephanie-shopify stephanie-shopify requested review from a team as code owners June 23, 2026 00:48
@github-actions github-actions Bot added the Area: @shopify/cli @shopify/cli package issues label Jun 23, 2026
Knip flagged the interface as an unused export — it is only used internally
as the return type of errorGroupingSignals; callers destructure inline.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@stephanie-shopify stephanie-shopify requested a review from mbarak June 24, 2026 18:55
Comment thread packages/cli-kit/src/private/node/analytics/error-grouping.DESIGN.md Outdated
Comment thread packages/cli-kit/src/private/node/analytics/error-grouping.ts Outdated
Comment thread packages/cli-kit/src/private/node/analytics/error-grouping.ts Outdated
Comment thread packages/cli-kit/src/private/node/analytics/error-grouping.ts
*/
function firstExtensionCode(errors: unknown): string | undefined {
if (!Array.isArray(errors)) return undefined
const code = (errors[0] as {extensions?: {code?: unknown}} | undefined)?.extensions?.code

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🐛 Bug: firstExtensionCode only reads errors[0].extensions.code, but GraphQL responses can include multiple errors and existing code in this package treats those arrays as multi-entry structures. A later error may carry THROTTLED, 429, or ACCESS_DENIED, and App Management errors can carry nested extensions.app_errors.errors[].category === 'access_denied'. Missing those shapes can route known API conditions to a fallback or default bucket instead of the intended structured grouping.

Suggestion: Consider replacing the first-error lookup with a small normalization helper that scans every GraphQL error, recognizes both THROTTLED and 429 as rate-limit signals, recognizes ACCESS_DENIED and nested App Management access_denied as permission signals, and is shared with the reporting-suppression logic where possible.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed. Replaced the first-error lookup with a shared scanner that walks every error and recognizes THROTTLED/429 (rate-limit) and ACCESS_DENIED plus the nested App Management extensions.app_errors.errors[].category === 'access_denied' (permission). It lives in a new dependency-free graphql-error-codes.ts so it can be shared with the suppression logic in error.tserror.ts can't import error-grouping.ts directly without creating an error.ts → headers.ts → error.ts cycle. Grouping picks a routable code (rate-limit, then permission) so a benign leading error can't mask one further down the array.

Comment thread packages/cli-kit/src/public/node/error-handler.ts Outdated
Comment thread packages/cli-kit/src/public/node/error.ts
Comment thread packages/cli-kit/src/public/node/error.ts Outdated
stephanie-shopify and others added 3 commits June 29, 2026 12:16
…r, suppression naming

Responds to Mbarak's review on #7901:
- Scan every GraphQL error (not just errors[0]) for routing codes, recognizing
  THROTTLED/429 and ACCESS_DENIED incl. nested App Management app_errors
  access_denied. Extracted into a dependency-free graphql-error-codes module
  shared by grouping and crash-report suppression (avoids the error.ts ->
  headers.ts -> error.ts import cycle).
- Add the string GraphQL code "429" as a rate-limit signal, matching
  errorsIncludeStatus429 in private/node/api.ts.
- Merge object signals over message-derived signals field-by-field with `??`
  so an absent object code/status can no longer erase a recovered value.
- Single resolveErrorGrouping() returns {hash, category, signals}; the reporter
  drives both event.groupingHash and the error_grouping metadata from it, so the
  metadata now carries category and message-derived signals.
- Rename isTransientApiError -> isExpectedApiError and document why 401
  (expired/invalid credentials) is expected, distinct from rate limiting.
- Document the deliberate 401-over-ACCESS_DENIED precedence and pin it + the new
  shapes with tests. Remove the issue-specific DESIGN.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Wrap the two resolveErrorGrouping return objects across lines (prettier/prettier)
and flatten the isExpectedApiError jsdoc to remove indented continuation lines
(jsdoc/check-indentation, enforced on public API files).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…TH_CONDITION_TYPES

Incidental codegen drift cleanup, not part of the error-grouping logic. The
admin schema gained this OnlineStoreThemeFilesUserErrorsCode enum value upstream;
the committed generated types were stale. The codegen check only runs when a PR
touches graphql-pathed files, so this PR's new graphql-error-codes.ts un-skipped
it and surfaced the pre-existing drift. Reproduces codegen output byte-for-byte
(no local schema fetch available).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: This helper is not really analytics-specific. It encodes GraphQL/API error semantics used for crash-report suppression and routing.

Recommendation: not required for this PR, but if this grows, move it closer to the API layer, e.g.

  packages/cli-kit/src/private/node/api/graphql-error-codes.ts

or

  packages/cli-kit/src/private/node/api/error-codes.ts

That would make the dependency direction easier to understand: API error shape → reporting decisions, rather than analytics → public error handling.

if (groupingHash) {
event.groupingHash = groupingHash
}
event.addMetadata('error_grouping', {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: This shape being constructed directly in error-handler is fine for now, but this PR explicitly says backend dashboards/routing should rely on these tags. Once external dashboards depend on them, the metadata shape becomes a contract.

Recommendation: follow-up only. Consider a small typed formatter near resolveErrorGrouping, e.g. errorGroupingMetadata(resolution, sliceName), so future changes to hash/category/signal behavior update metadata in one place.

Comment thread .changeset/structured-error-grouping.md Outdated

Group CLI crash reports on structured error signals instead of one catch-all bucket

Bugsnag error grouping now derives a stable `slice:category:signature` hash from the original typed error's HTTP status and GraphQL `extensions.code` (falling back to the shared keyword categorizer, and to Bugsnag's stack-trace grouping for genuinely unknown errors). The same signals are emitted as `error_grouping` metadata so backend routing works regardless of CLI version. Known-transient API errors (HTTP 401/429 and `THROTTLED`) are now treated as expected and kept out of crash reporting.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is not an user-facing change, please remove the changeset 🙏

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

addressed

const status = error.response?.status
if (status !== undefined) signals.httpStatus = status
const code = routableCode(error.response?.errors)
if (code !== undefined) signals.code = code

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

you don't really need to check for not-undefined here no? assigning an undefined value should be safe

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It would be safe with the current field-by-field ?? merge, but I’m keeping the guard to preserve this helper’s invariant: it only emits fields that are actually present. That avoids reintroducing “present but undefined hammers fallback” class of bug if this object is later spread/merged elsewhere.

return typeof error === 'string' ? error : ''
}

function slugify(value: string): string {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

an slugify function already exists at '@shopify/cli-kit/common/string', let's reuse that.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

addressed

@@ -28,6 +29,14 @@ import {realpath} from 'fs/promises'
// Hardcoded list per product slices to keep analytics consistent.
const ALLOWED_SLICE_NAMES = new Set<string>(['app', 'theme', 'hydrogen', 'store'])

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please add organization to this list, that's a new slice of commands added recently

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

addressed

@github-actions github-actions Bot added no-changelog This PR doesn't include a changeset entry. Is an internal only change not relevant to end users. and removed Area: @shopify/cli @shopify/cli package issues labels Jul 6, 2026
@github-actions

github-actions Bot commented Jul 6, 2026

Copy link
Copy Markdown
Contributor

Differences in type declarations

We detected differences in the type declarations generated by Typescript for this branch compared to the baseline ('main' branch). Please, review them to ensure they are backward-compatible. Here are some important things to keep in mind:

  • Some seemingly private modules might be re-exported through public modules.
  • If the branch is behind main you might see odd diffs, rebase main into this branch.

New type declarations

packages/cli-kit/dist/private/node/analytics/error-grouping.d.ts
/**
 * Structured signals extracted from an error, used to group it into a meaningful Bugsnag bucket.
 *
 * These are read from the *original* typed error (before it is flattened to a generic `Error`
 * for reporting), so we group on facts the error already carries rather than by re-parsing a
 * stringified message.
 */
interface ErrorGroupingSignals {
    httpStatus?: number;
    code?: string;
    errorClass?: string;
}
/**
 * The fully resolved grouping decision for an error: the Bugsnag grouping hash (or `undefined` to
 * fall back to stack-trace grouping), the semantic category, and the structured signals that drove
 * the decision. The reporter uses a single resolution for both `event.groupingHash` and the
 * `error_grouping` metadata so they can never disagree.
 */
interface ResolvedErrorGrouping {
    hash?: string;
    category?: string;
    signals: ErrorGroupingSignals;
}
/**
 * Extracts structured grouping signals from an error object.
 *
 * Only assigns a field when a value is actually present, so the result can be safely merged over
 * message-derived signals without an explicit `undefined` erasing a recovered value.
 *
 * @param error - The original error (any type).
 * @returns The HTTP status, GraphQL error code, and error class name when available.
 */
export declare function errorGroupingSignals(error: unknown): ErrorGroupingSignals;
/**
 * Resolves the full grouping decision for an error: hash, category, and the signals behind it.
 *
 * Categories are resolved structured-first (HTTP status / GraphQL code), falling back to the
 * shared keyword categorizer only for untyped errors. When no meaningful category can be
 * resolved, `hash` is `undefined` so the caller leaves `event.groupingHash` unset and Bugsnag's
 * default stack-trace grouping applies — this avoids merging genuinely distinct unknown bugs.
 *
 * @param error - The original error (any type).
 * @param sliceName - The product slice (`app`, `theme`, `store`, `hydrogen`, or `cli`).
 * @returns The resolved hash (or `undefined`), category, and structured signals.
 */
export declare function resolveErrorGrouping(error: unknown, sliceName: string): ResolvedErrorGrouping;
/**
 * Builds a Bugsnag grouping hash of the form `${slice}:${category}:${signature}`.
 *
 * Thin wrapper over {@link resolveErrorGrouping} for callers that only need the hash.
 *
 * @param error - The original error (any type).
 * @param sliceName - The product slice (`app`, `theme`, `store`, `hydrogen`, or `cli`).
 * @returns The grouping hash, or `undefined` to fall back to stack-trace grouping.
 */
export declare function errorGroupingHash(error: unknown, sliceName: string): string | undefined;
export {};
packages/cli-kit/dist/private/node/analytics/graphql-error-codes.d.ts
/**
 * Pure helpers for reading routing-relevant codes out of a GraphQL error response.
 *
 * This module is intentionally dependency-free: it is imported by both the grouping logic
 * (`error-grouping.ts`) and the crash-report suppression logic (`../../public/node/error.ts`).
 * `error.ts` cannot import `error-grouping.ts` directly — that would create an
 * `error.ts → headers.ts → error.ts` import cycle — so the shared scanning logic lives here,
 * where it imports nothing from cli-kit.
 */
/**
 * Collects every routing-relevant code from a GraphQL `errors` value.
 *
 * Scans *all* errors (not just the first), reading both the top-level `extensions.code` and the
 * nested App Management shape `extensions.app_errors.errors[].category`. The API can also return
 * `errors` as a string (e.g. some 401s), which yields no codes.
 *
 * @param errors - The `errors` value from a GraphQL response (array, string, or undefined).
 * @returns Every string code/category found, in document order.
 */
export declare function graphQLErrorCodes(errors: unknown): string[];
/**
 * Whether a single code is a rate-limit signal (`THROTTLED` or `429`).
 *
 * Mirrors the established shape detected by `errorsIncludeStatus429` in `private/node/api.ts`,
 * where `extensions.code === '429'` signals rate limiting even at HTTP 200.
 */
export declare function isRateLimitCode(code: string | undefined): boolean;
/**
 * Whether a single code/category is a permission / access-denied signal.
 */
export declare function isPermissionCode(code: string | undefined): boolean;
/**
 * Whether any GraphQL error carries a rate-limit code. Convenience for suppression logic that only
 * needs a boolean over the raw `errors` value.
 *
 * @param errors - The `errors` value from a GraphQL response.
 * @returns True when a rate-limit code is present in any error.
 */
export declare function hasRateLimitCode(errors: unknown): boolean;

Existing type declarations

We found no diffs with existing type declarations

@stephanie-shopify

Copy link
Copy Markdown
Contributor Author

/merge

@stephanie-shopify stephanie-shopify added this pull request to the merge queue Jul 6, 2026
Merged via the queue into main with commit 5fc5fda Jul 6, 2026
29 checks passed
@stephanie-shopify stephanie-shopify deleted the fix/error-grouping-structured branch July 6, 2026 20:18
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

no-changelog This PR doesn't include a changeset entry. Is an internal only change not relevant to end users.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

split observe analytics group for cleaner resiliency tracking

5 participants