Skip to content

Split CLI error analytics grouping#7894

Closed
stephanie-shopify wants to merge 2 commits into
mainfrom
fix-observe-error-grouping
Closed

Split CLI error analytics grouping#7894
stephanie-shopify wants to merge 2 commits into
mainfrom
fix-observe-error-grouping

Conversation

@stephanie-shopify

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

Copy link
Copy Markdown
Contributor

Summary

  • set Bugsnag groupingHash for CLI error reports using command slice + analytics error taxonomy
  • normalize GraphQL ClientError payload dumps so access/auth/throttle errors don’t collapse into network buckets
  • add regression coverage for app/theme/store/cli grouping behavior

closes #7891 which stems from this resiliency issue which is currently an aggregate of a bunch of different issues so we can never properly address it

Testing

  • pnpm vitest run packages/cli-kit/src/public/node/error-handler.test.ts (passed in primary checkout)
  • pnpm nx type-check cli-kit (passed in primary checkout)
  • pnpm nx lint cli-kit (passed in primary checkout; existing warnings only)

context

https://shopify.slack.com/archives/C0AFPGQJENQ/p1782147409267669

@stephanie-shopify
stephanie-shopify requested review from a team as code owners June 22, 2026 19:49
@github-actions github-actions Bot added the Area: @shopify/cli @shopify/cli package issues label Jun 22, 2026
@stephanie-shopify
stephanie-shopify requested a review from mbarak June 22, 2026 20:45
@stephanie-shopify
stephanie-shopify marked this pull request as draft June 22, 2026 20:58

@stephanie-shopify stephanie-shopify left a comment

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.

Review focused on one critical behavior change plus a few correctness/cleanup items. The grouping mechanism itself is well-designed and the GraphQL normalization targets the right root cause — main blocker is the silent removal of expected-error suppression (see inline). Posting as comments since this is my own PR.

}

// If the error was unexpected, we flag it as "unhandled" in Bugsnag. This is a helpful distinction.
const unhandled = exitMode === 'unexpected_error'

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.

🚨 Critical / not in the summary: the diff removes the if (exitMode === 'expected_error') { return {reported:false…} } guard that used to sit right above this line, and flips the test from 'when error is expected' (not reported) to 'processes AbortErrors as handled' (reported: true).

Net effect: every AbortError now flows to Bugsnag as a handled event — these were deliberately suppressed before. This is unrelated to grouping, increases Bugsnag volume/cost by an unquantified amount, and runs opposite to the linked issue's own recommendation ("stop reporting known transient/user errors … so they leave crash reporting entirely").

Please either (a) revert this removal and keep the PR purely about grouping, or (b) split it out, justify it with projected event volume, and document it in both the summary and the changeset (which currently only mentions grouping). As written it's the riskiest line in the diff and the least visible.

const {commandStartOptions} = metadata.getAllSensitiveMetadata()
const {startCommand} = commandStartOptions ?? {}
const sliceName = sliceNameFromStartCommand(startCommand)
event.groupingHash = bugsnagGroupingHash(reportableError, sliceName)

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.

Setting groupingHash unconditionally re-buckets all CLI errors (known, acknowledged). The sharp edge is the Unknown category: formatGenericError keys on the first 50 slugified chars, so two genuinely distinct crashes in the same slice that share an opening message fragment will now merge into one bucket, discarding the stack-trace grouping that keeps them apart today.

Consider skipping the override when category === Unknown (let Bugsnag's stack grouping stand), or folding a short stack fingerprint into the hash for that case. That keeps the win for categorized buckets without coarsening the truly-unknown ones.

if (response.status === 401 && !firstErrorMessage?.match(/auth|credential|token|unauthorized/i)) {
parts.push('Unauthorized')
}
if (response.status === 403 && !firstErrorMessage?.match(/access|denied|forbidden|permission|unauthorized/i)) {

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.

⚠️ The injected 'Forbidden' is misleading given the taxonomy: 'forbidden' is an Authentication term, not a Permission term (['unauthorized','forbidden','auth','token','credential'] vs ['permission','denied','access','insufficient']). So a 403 with a generic message lands in :authentication:, even though 403 = permission semantically — and the issue explicitly frames App-Management 403 as a permission problem distinct from 401 auth.

Decide explicitly: if 403 should be permission, push 'access denied' instead of 'Forbidden'. If everything 4xx→authentication is fine, drop the misleading word and add a comment. Either way there's no test for the 403-with-JSON-payload path, so current behavior is unverified.

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.

403 can be permission instead of forbidden


const firstError = response.errors?.[0]
const code = firstError?.extensions?.code
const firstErrorMessage = firstError?.message?.replace(/too many requests/gi, 'rate limit exceeded')

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.

💡 'too many requests' is already a RateLimit term, so this rewrite doesn't change categorization — both forms categorize as rate_limit. If the goal is just a tidier signature, a one-line comment would help; otherwise it's removable.

const graphQLError = graphQLErrorForGrouping(error.message)
if (graphQLError) return graphQLError

const messageWithoutJsonDump = error.message.split(': {')[0] ?? error.message

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.

💡 String.prototype.split always returns a non-empty array, so [0] is never undefined and the ?? error.message fallback is unreachable. Same dead fallback on the return new Error(... ?? message) line below. Harmless, just drop for clarity.

: path.joinPath(projectRoot, currentFilePath)

const matchingPluginPath = pluginLocations.find(({pluginPath}) => fullLocation.startsWith(pluginPath))
const matchingPluginPath = pluginLocations.filter(({pluginPath}) => fullLocation.startsWith(pluginPath))[0]

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.

💡 Unrelated change, and strictly worse: .find(pred) short-circuits on first match; .filter(pred)[0] walks the whole array and allocates a throwaway array for an identical result. Recommend reverting to .find(...) unless there's a reason I'm missing.

@stephanie-shopify

Copy link
Copy Markdown
Contributor Author

generally dont like this approach. feels brittle to have the hard coded categories and there are some weird unnecessary changes. going back to drawing board

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Area: @shopify/cli @shopify/cli package issues

Projects

None yet

Development

Successfully merging this pull request may close these issues.

split observe analytics group for cleaner resiliency tracking

1 participant