diff --git a/.changeset/angry-apes-share.md b/.changeset/angry-apes-share.md new file mode 100644 index 000000000000..a3e765bdf6b0 --- /dev/null +++ b/.changeset/angry-apes-share.md @@ -0,0 +1,5 @@ +--- +"wrangler": patch +--- + +Allow WRANGLER_SEND_ERROR_REPORTS env var to override whether to report Wrangler crashes to Sentry diff --git a/packages/wrangler/src/__tests__/sentry.test.ts b/packages/wrangler/src/__tests__/sentry.test.ts index 9d38af82d14a..80f58552585c 100644 --- a/packages/wrangler/src/__tests__/sentry.test.ts +++ b/packages/wrangler/src/__tests__/sentry.test.ts @@ -123,6 +123,31 @@ describe("sentry", () => { expect(sentryRequests?.length).toEqual(0); }); + it("should not hit sentry (or even ask) after reportable error if WRANGLER_SEND_ERROR_REPORTS is explicitly false", async () => { + // Trigger an API error + msw.use( + http.get( + `https://api.cloudflare.com/client/v4/user`, + async () => { + return HttpResponse.error(); + }, + { once: true } + ), + http.get("*/user/tokens/verify", () => { + return HttpResponse.json(createFetchResult([])); + }) + ); + await expect( + runWrangler("whoami", { WRANGLER_SEND_ERROR_REPORTS: "false" }) + ).rejects.toMatchInlineSnapshot(`[TypeError: Failed to fetch]`); + expect(std.out).toMatchInlineSnapshot(` + "Getting User settings... + + If you think this is a bug then please create an issue at https://github.com/cloudflare/workers-sdk/issues/new/choose" + `); + expect(sentryRequests?.length).toEqual(0); + }); + it("should hit sentry after reportable error when permission provided", async () => { // Trigger an API error msw.use( @@ -428,6 +453,308 @@ describe("sentry", () => { }, }); }); + + it("should hit sentry after reportable error (without confirmation) if WRANGLER_SEND_ERROR_REPORTS is explicitly true", async () => { + // Trigger an API error + msw.use( + http.get( + `https://api.cloudflare.com/client/v4/user`, + async () => { + return HttpResponse.error(); + }, + { once: true } + ), + http.get("*/user/tokens/verify", () => { + return HttpResponse.json(createFetchResult([])); + }) + ); + await expect( + runWrangler("whoami", { WRANGLER_SEND_ERROR_REPORTS: "true" }) + ).rejects.toMatchInlineSnapshot(`[TypeError: Failed to fetch]`); + expect(std.out).toMatchInlineSnapshot(` + "Getting User settings... + + If you think this is a bug then please create an issue at https://github.com/cloudflare/workers-sdk/issues/new/choose" + `); + + // Sentry sends multiple HTTP requests to capture breadcrumbs + expect(sentryRequests?.length).toBeGreaterThan(0); + assert(sentryRequests !== undefined); + + // Check requests don't include PII + const envelopes = sentryRequests.map(({ envelope }) => { + const parts = envelope.split("\n").map((line) => JSON.parse(line)); + expect(parts).toHaveLength(3); + return { header: parts[0], type: parts[1], data: parts[2] }; + }); + const event = envelopes.find(({ type }) => type.type === "event"); + assert(event !== undefined); + + // Redact fields with random contents we know don't contain PII + event.header.event_id = ""; + event.header.sent_at = ""; + event.header.trace.trace_id = ""; + event.header.trace.release = ""; + for (const exception of event.data.exception.values) { + for (const frame of exception.stacktrace.frames) { + if ( + frame.filename.startsWith("C:\\Project\\") || + frame.filename.startsWith("/project/") + ) { + frame.filename = "/project/..."; + } + frame.function = ""; + frame.lineno = 0; + frame.colno = 0; + frame.in_app = false; + frame.pre_context = []; + frame.context_line = ""; + frame.post_context = []; + } + } + event.data.event_id = ""; + event.data.contexts.trace.trace_id = ""; + event.data.contexts.trace.span_id = ""; + event.data.contexts.runtime.version = ""; + event.data.contexts.app.app_start_time = ""; + event.data.contexts.app.app_memory = 0; + event.data.contexts.os = {}; + event.data.contexts.device = {}; + event.data.timestamp = 0; + event.data.release = ""; + for (const breadcrumb of event.data.breadcrumbs) { + breadcrumb.timestamp = 0; + } + + const fakeInstallPath = "/wrangler/"; + for (const exception of event.data.exception?.values ?? []) { + for (const frame of exception.stacktrace?.frames ?? []) { + if (frame.module.startsWith("@mswjs")) { + frame.module = + "@mswjs.interceptors.src.interceptors.fetch:index.ts"; + } + if (frame.filename === undefined) { + continue; + } + + const wranglerPackageIndex = frame.filename.indexOf( + path.join("packages", "wrangler", "src") + ); + if (wranglerPackageIndex === -1) { + continue; + } + frame.filename = + fakeInstallPath + + frame.filename + .substring(wranglerPackageIndex) + .replaceAll("\\", "/"); + continue; + } + } + + // If more data is included in the Sentry request, we'll need to verify it + // couldn't contain PII and update this snapshot + expect(event).toStrictEqual({ + data: { + breadcrumbs: [ + { + level: "log", + message: "wrangler whoami", + timestamp: 0, + }, + ], + contexts: { + app: { + app_memory: 0, + app_start_time: "", + }, + cloud_resource: {}, + device: {}, + os: {}, + runtime: { + name: "node", + version: "", + }, + trace: { + span_id: "", + trace_id: "", + }, + }, + environment: "production", + event_id: "", + exception: { + values: [ + { + mechanism: { + handled: true, + type: "generic", + }, + stacktrace: { + frames: [ + { + colno: 0, + context_line: "", + filename: expect.any(String), + function: "", + in_app: false, + lineno: 0, + module: expect.any(String), + post_context: [], + pre_context: [], + }, + { + colno: 0, + context_line: "", + filename: expect.any(String), + function: "", + in_app: false, + lineno: 0, + module: expect.any(String), + post_context: [], + pre_context: [], + }, + { + colno: 0, + context_line: "", + filename: expect.any(String), + function: "", + in_app: false, + lineno: 0, + module: expect.any(String), + post_context: [], + pre_context: [], + }, + { + colno: 0, + context_line: "", + filename: expect.any(String), + function: "", + in_app: false, + lineno: 0, + module: expect.any(String), + post_context: [], + pre_context: [], + }, + { + colno: 0, + context_line: "", + filename: expect.any(String), + function: "", + in_app: false, + lineno: 0, + module: expect.any(String), + post_context: [], + pre_context: [], + }, + { + colno: 0, + context_line: "", + filename: expect.any(String), + function: "", + in_app: false, + lineno: 0, + module: expect.any(String), + post_context: [], + pre_context: [], + }, + { + colno: 0, + context_line: "", + filename: expect.any(String), + function: "", + in_app: false, + lineno: 0, + module: expect.any(String), + post_context: [], + pre_context: [], + }, + { + colno: 0, + context_line: "", + filename: expect.any(String), + function: "", + in_app: false, + lineno: 0, + module: expect.any(String), + post_context: [], + pre_context: [], + }, + { + colno: 0, + context_line: "", + filename: "/project/...", + function: "", + in_app: false, + lineno: 0, + module: + "@mswjs.interceptors.src.interceptors.fetch:index.ts", + post_context: [], + pre_context: [], + }, + { + colno: 0, + context_line: "", + filename: "/project/...", + function: "", + in_app: false, + lineno: 0, + module: + "@mswjs.interceptors.src.interceptors.fetch:index.ts", + post_context: [], + pre_context: [], + }, + ], + }, + type: "TypeError", + value: "Failed to fetch", + }, + ], + }, + modules: {}, + platform: "node", + release: "", + sdk: { + integrations: [ + "InboundFilters", + "FunctionToString", + "LinkedErrors", + "Console", + "OnUncaughtException", + "OnUnhandledRejection", + "ContextLines", + "Context", + "Modules", + ], + name: "sentry.javascript.node", + packages: [ + { + name: "npm:@sentry/node", + version: "7.87.0", + }, + ], + version: "7.87.0", + }, + timestamp: 0, + }, + header: { + event_id: "", + sdk: { + name: "sentry.javascript.node", + version: "7.87.0", + }, + sent_at: "", + trace: { + environment: "production", + public_key: "9edbb8417b284aa2bbead9b4c318918b", + release: "", + trace_id: "", + }, + }, + type: { + type: "event", + }, + }); + }); }); }); diff --git a/packages/wrangler/src/environment-variables/factory.ts b/packages/wrangler/src/environment-variables/factory.ts index 2b98655f043d..81d2412e2c23 100644 --- a/packages/wrangler/src/environment-variables/factory.ts +++ b/packages/wrangler/src/environment-variables/factory.ts @@ -88,6 +88,8 @@ type VariableNames = | "WRANGLER_C3_COMMAND" /** Enable/disable telemetry data collection. */ | "WRANGLER_SEND_METRICS" + /** Enable/disable error reporting to Sentry. */ + | "WRANGLER_SEND_ERROR_REPORTS" /** CI branch name (internal use). */ | "WORKERS_CI_BRANCH" /** CI tag matching configuration (internal use). */ diff --git a/packages/wrangler/src/environment-variables/misc-variables.ts b/packages/wrangler/src/environment-variables/misc-variables.ts index 8a1c19c06281..5b1a6e0f9bf9 100644 --- a/packages/wrangler/src/environment-variables/misc-variables.ts +++ b/packages/wrangler/src/environment-variables/misc-variables.ts @@ -45,6 +45,14 @@ export const getWranglerSendMetricsFromEnv = variableName: "WRANGLER_SEND_METRICS", }); +/** + * `WRANGLER_SEND_ERROR_REPORTS` can override whether we attempt to send error reports to Sentry. + */ +export const getWranglerSendErrorReportsFromEnv = + getBooleanEnvironmentVariableFactory({ + variableName: "WRANGLER_SEND_ERROR_REPORTS", + }); + /** * Set `WRANGLER_API_ENVIRONMENT` environment variable to "staging" to tell Wrangler to hit the staging APIs rather than production. */ diff --git a/packages/wrangler/src/sentry/index.ts b/packages/wrangler/src/sentry/index.ts index a40b7098cc8d..a614aabceb55 100644 --- a/packages/wrangler/src/sentry/index.ts +++ b/packages/wrangler/src/sentry/index.ts @@ -3,6 +3,7 @@ import { rejectedSyncPromise } from "@sentry/utils"; import { fetch } from "undici"; import { version as wranglerVersion } from "../../package.json"; import { confirm } from "../dialogs"; +import { getWranglerSendErrorReportsFromEnv } from "../environment-variables/misc-variables"; import { logger } from "../logger"; import type { BaseTransportOptions, TransportRequest } from "@sentry/types"; import type { RequestInit } from "undici"; @@ -150,10 +151,14 @@ export function addBreadcrumb( // consent if not already granted. export async function captureGlobalException(e: unknown) { if (typeof SENTRY_DSN !== "undefined") { - sentryReportingAllowed = await confirm( - "Would you like to report this error to Cloudflare? Wrangler's output and the error details will be shared with the Wrangler team to help us diagnose and fix the issue.", - { fallbackValue: false } - ); + const sendErrorReportsEnvVar = getWranglerSendErrorReportsFromEnv(); + sentryReportingAllowed = + sendErrorReportsEnvVar !== undefined + ? sendErrorReportsEnvVar + : await confirm( + "Would you like to report this error to Cloudflare? Wrangler's output and the error details will be shared with the Wrangler team to help us diagnose and fix the issue.", + { fallbackValue: false } + ); if (!sentryReportingAllowed) { logger.debug(`Sentry: Reporting disabled - would have sent ${e}.`);