-
Notifications
You must be signed in to change notification settings - Fork 272
Split CLI error analytics grouping #7894
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| --- | ||
| '@shopify/cli-kit': patch | ||
| --- | ||
|
|
||
| Split CLI error analytics grouping by command slice and normalized error signature. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -12,6 +12,7 @@ import { | |
| } from './error.js' | ||
| import {outputDebug, outputInfo} from './output.js' | ||
| import {getEnvironmentData} from '../../private/node/analytics.js' | ||
| import {categorizeError, formatErrorMessage} from '../../private/node/analytics/error-categorizer.js' | ||
| import {isLocalEnvironment} from '../../private/node/context/service.js' | ||
| import {bugsnagApiKey, reportingRateLimit} from '../../private/node/constants.js' | ||
| import {CLI_KIT_VERSION} from '../common/version.js' | ||
|
|
@@ -28,6 +29,72 @@ 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']) | ||
|
|
||
| function sliceNameFromStartCommand(startCommand: string | undefined): string { | ||
| if (!startCommand) return 'cli' | ||
|
|
||
| const firstWord = startCommand.trim().split(/\s+/)[0] ?? 'cli' | ||
| return ALLOWED_SLICE_NAMES.has(firstWord) ? firstWord : 'cli' | ||
| } | ||
|
|
||
| interface GraphQLErrorPayload { | ||
| response?: { | ||
| status?: number | ||
| errors?: { | ||
| message?: string | ||
| extensions?: { | ||
| code?: string | ||
| } | ||
| }[] | ||
| } | ||
| } | ||
|
|
||
| function errorForGrouping(error: Error): Error { | ||
| const graphQLError = graphQLErrorForGrouping(error.message) | ||
| if (graphQLError) return graphQLError | ||
|
|
||
| const messageWithoutJsonDump = error.message.split(': {')[0] ?? error.message | ||
| return new Error(messageWithoutJsonDump) | ||
| } | ||
|
|
||
| function graphQLErrorForGrouping(message: string): Error | undefined { | ||
| const jsonStart = message.indexOf('{') | ||
| if (jsonStart === -1) return undefined | ||
|
|
||
| let payload: GraphQLErrorPayload | ||
| try { | ||
| payload = JSON.parse(message.slice(jsonStart)) as GraphQLErrorPayload | ||
| // eslint-disable-next-line no-catch-all/no-catch-all | ||
| } catch { | ||
| return undefined | ||
| } | ||
|
|
||
| const {response} = payload | ||
| if (!response) return undefined | ||
|
|
||
| const firstError = response.errors?.[0] | ||
| const code = firstError?.extensions?.code | ||
| const firstErrorMessage = firstError?.message?.replace(/too many requests/gi, 'rate limit exceeded') | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 💡 |
||
|
|
||
| const parts: string[] = [] | ||
| 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)) { | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Decide explicitly: if 403 should be
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 403 can be permission instead of forbidden |
||
| parts.push('Forbidden') | ||
| } | ||
| if (response.status) parts.push(`HTTP ${response.status}`) | ||
| if (code) parts.push(code) | ||
| if (firstErrorMessage) parts.push(firstErrorMessage) | ||
|
|
||
| return new Error(parts.length > 0 ? parts.join(' ') : (message.split(': {')[0] ?? message)) | ||
| } | ||
|
|
||
| function bugsnagGroupingHash(error: Error, sliceName: string): string { | ||
| const groupingError = errorForGrouping(error) | ||
| const category = categorizeError(groupingError) | ||
| return `${sliceName}:${category.toLowerCase()}:${formatErrorMessage(groupingError, category)}` | ||
| } | ||
|
|
||
| export async function errorHandler( | ||
| error: Error & {exitCode?: number | undefined}, | ||
| config?: Interfaces.Config, | ||
|
|
@@ -76,11 +143,6 @@ export async function sendErrorToBugsnag( | |
| return {reported: false, error, unhandled: undefined} | ||
| } | ||
|
|
||
| if (exitMode === 'expected_error') { | ||
| outputDebug(`Skipping Bugsnag report for expected error`) | ||
| return {reported: false, error, unhandled: undefined} | ||
| } | ||
|
|
||
| // If the error was unexpected, we flag it as "unhandled" in Bugsnag. This is a helpful distinction. | ||
| const unhandled = exitMode === 'unexpected_error' | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🚨 Critical / not in the summary: the diff removes the 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. |
||
|
|
||
|
|
@@ -147,9 +209,9 @@ export async function sendErrorToBugsnag( | |
| // Attach command metadata so we know which CLI command triggered the error | ||
| const {commandStartOptions} = metadata.getAllSensitiveMetadata() | ||
| const {startCommand} = commandStartOptions ?? {} | ||
| const sliceName = sliceNameFromStartCommand(startCommand) | ||
| event.groupingHash = bugsnagGroupingHash(reportableError, sliceName) | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Setting Consider skipping the override when |
||
| if (startCommand) { | ||
| const firstWord = startCommand.trim().split(/\s+/)[0] ?? 'cli' | ||
| const sliceName = ALLOWED_SLICE_NAMES.has(firstWord) ? firstWord : 'cli' | ||
| event.addMetadata('custom', {slice_name: sliceName}) | ||
| } | ||
| } | ||
|
|
@@ -192,7 +254,7 @@ export function cleanStackFrameFilePath({ | |
| ? currentFilePath | ||
| : path.joinPath(projectRoot, currentFilePath) | ||
|
|
||
| const matchingPluginPath = pluginLocations.find(({pluginPath}) => fullLocation.startsWith(pluginPath)) | ||
| const matchingPluginPath = pluginLocations.filter(({pluginPath}) => fullLocation.startsWith(pluginPath))[0] | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 💡 Unrelated change, and strictly worse: |
||
|
|
||
| if (matchingPluginPath !== undefined) { | ||
| // the plugin name (e.g. @shopify/cli-kit), plus the relative path of the error line from within the plugin's code (e.g. dist/something.js ) | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
💡
String.prototype.splitalways returns a non-empty array, so[0]is neverundefinedand the?? error.messagefallback is unreachable. Same dead fallback on thereturn new Error(... ?? message)line below. Harmless, just drop for clarity.