Skip to content

Commit

Permalink
Send Crash Reports to Sentry (#4571)
Browse files Browse the repository at this point in the history
* Initial sentry implementation

* Prompt every time

* Add tests

* Create pink-bags-push.md

* Clear event queue

* Make event queue non-const
  • Loading branch information
penalosa authored Dec 11, 2023
1 parent 609430b commit 3314dbd
Show file tree
Hide file tree
Showing 13 changed files with 346 additions and 22 deletions.
7 changes: 7 additions & 0 deletions .changeset/pink-bags-push.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"wrangler": minor
---

feat: When Wrangler crashes, send an error report to Sentry to aid in debugging.

When Wrangler's top-level exception handler catches an error thrown from Wrangler's application, it will offer to report the error to Sentry. This requires opt-in from the user every time.
1 change: 1 addition & 0 deletions .github/workflows/create-pullrequest-prerelease.yml
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ jobs:
NODE_ENV: "production"
ALGOLIA_APP_ID: ${{ secrets.ALGOLIA_APP_ID }}
ALGOLIA_PUBLIC_KEY: ${{ secrets.ALGOLIA_PUBLIC_KEY }}
SENTRY_DSN: "https://[email protected]/583"
CI_OS: ${{ runner.os }}

- name: Pack miniflare
Expand Down
2 changes: 2 additions & 0 deletions .github/workflows/prereleases.yml
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ jobs:
# this is the "test/staging" key for sparrow analytics
SPARROW_SOURCE_KEY: "5adf183f94b3436ba78d67f506965998"
ALGOLIA_APP_ID: ${{ secrets.ALGOLIA_APP_ID }}
SENTRY_DSN: "https://[email protected]/583"
ALGOLIA_PUBLIC_KEY: ${{ secrets.ALGOLIA_PUBLIC_KEY }}
working-directory: packages/wrangler

Expand Down Expand Up @@ -111,6 +112,7 @@ jobs:
NODE_ENV: "production"
ALGOLIA_APP_ID: ${{ secrets.ALGOLIA_APP_ID }}
ALGOLIA_PUBLIC_KEY: ${{ secrets.ALGOLIA_PUBLIC_KEY }}
SENTRY_DSN: "https://[email protected]/583"
CI_OS: ${{ runner.os }}

- name: Build & Publish Prerelease Registry
Expand Down
2 changes: 2 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,8 @@ jobs:
NPM_PUBLISH_TOKEN: ${{ secrets.NPM_PUBLISH_TOKEN }}
ALGOLIA_APP_ID: ${{ secrets.ALGOLIA_APP_ID }}
ALGOLIA_PUBLIC_KEY: ${{ secrets.ALGOLIA_PUBLIC_KEY }}
SENTRY_DSN: "https://[email protected]/583"

NODE_ENV: "production"
# This is the "production" key for sparrow analytics.
# Include this here because this step will rebuild Wrangler and needs to have this available
Expand Down
5 changes: 4 additions & 1 deletion packages/wrangler/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -123,10 +123,12 @@
"@cloudflare/pages-shared": "workspace:^",
"@cloudflare/types": "^6.18.4",
"@cloudflare/workers-tsconfig": "workspace:*",
"https-proxy-agent": "7.0.2",
"@cloudflare/workers-types": "^4.20230914.0",
"@iarna/toml": "^3.0.0",
"@microsoft/api-extractor": "^7.28.3",
"@sentry/node": "^7.86.0",
"@sentry/types": "^7.86.0",
"@sentry/utils": "^7.86.0",
"@types/body-parser": "^1.19.2",
"@types/busboy": "^1.5.0",
"@types/command-exists": "^1.2.0",
Expand Down Expand Up @@ -164,6 +166,7 @@
"get-port": "^6.1.2",
"glob-to-regexp": "0.4.1",
"http-terminator": "^3.2.0",
"https-proxy-agent": "7.0.2",
"ignore": "^5.2.0",
"ink": "^3.2.0",
"ink-select-input": "^4.2.1",
Expand Down
3 changes: 3 additions & 0 deletions packages/wrangler/scripts/bundle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,9 @@ async function buildMain(flags: BuildFlags = {}) {
...(process.env.ALGOLIA_PUBLIC_KEY
? { ALGOLIA_PUBLIC_KEY: `"${process.env.ALGOLIA_PUBLIC_KEY}"` }
: {}),
...(process.env.SENTRY_DSN
? { SENTRY_DSN: `"${process.env.SENTRY_DSN}"` }
: {}),
},
plugins: [embedWorkersPlugin],
};
Expand Down
2 changes: 1 addition & 1 deletion packages/wrangler/src/__tests__/deployments.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -301,7 +301,7 @@ describe("deployments", () => {
"🚧\`wrangler rollback\` is a beta command. Please report any issues to https://github.com/cloudflare/workers-sdk/issues/new/choose
? This deployment 3mEgaU1T will immediately replace the current deployment and become the active deployment across all your deployed routes and domains. However, your local development environment will not be affected by this rollback. Note: Rolling back to a previous deployment will not rollback any of the bound resources (Durable Object, R2, KV, etc.).
🤖 Using default value in non-interactive context: yes
🤖 Using fallback value in non-interactive context: yes
? Please provide a message for this rollback (120 characters max)
🤖 Using default value in non-interactive context:
Expand Down
109 changes: 109 additions & 0 deletions packages/wrangler/src/__tests__/sentry.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import { rest } from "msw";

import { mockAccountId, mockApiToken } from "./helpers/mock-account-id";
import { mockConsoleMethods } from "./helpers/mock-console";
import { clearDialogs, mockConfirm } from "./helpers/mock-dialogs";
import { useMockIsTTY } from "./helpers/mock-istty";
import { msw } from "./helpers/msw";
import { runInTempDir } from "./helpers/run-in-tmp";
import { runWrangler } from "./helpers/run-wrangler";

declare const global: { SENTRY_DSN: string | undefined };

describe("sentry", () => {
const ORIGINAL_SENTRY_DSN = global.SENTRY_DSN;
const std = mockConsoleMethods();
runInTempDir();
mockAccountId();
mockApiToken();
const { setIsTTY } = useMockIsTTY();

let sentryRequests: { count: number } | undefined;

beforeEach(() => {
global.SENTRY_DSN =
"https://[email protected]/24601";

sentryRequests = mockSentryEndpoint();
});
afterEach(() => {
global.SENTRY_DSN = ORIGINAL_SENTRY_DSN;
clearDialogs();
msw.resetHandlers();
});
describe("non interactive", () => {
it("should not hit sentry in normal usage", async () => {
await runWrangler("version");
expect(sentryRequests?.count).toEqual(0);
});

it("should not hit sentry after error", async () => {
await expect(runWrangler("delete")).rejects.toMatchInlineSnapshot(
`[AssertionError: A worker name must be defined, either via --name, or in wrangler.toml]`
);
expect(std.out).toMatchInlineSnapshot(`
"
If you think this is a bug then please create an issue at https://github.com/cloudflare/workers-sdk/issues/new/choose
? Would you like to report this error to Cloudflare?
🤖 Using fallback value in non-interactive context: no"
`);
expect(sentryRequests?.count).toEqual(0);
});
});
describe("interactive", () => {
beforeEach(() => {
setIsTTY(true);
});
afterEach(() => {
setIsTTY(false);
});
it("should not hit sentry in normal usage", async () => {
await runWrangler("version");
expect(sentryRequests?.count).toEqual(0);
});
it("should not hit sentry after error when permission denied", async () => {
mockConfirm({
text: "Would you like to report this error to Cloudflare?",
result: false,
});
await expect(runWrangler("delete")).rejects.toMatchInlineSnapshot(
`[AssertionError: A worker name must be defined, either via --name, or in wrangler.toml]`
);
expect(std.out).toMatchInlineSnapshot(`
"
If you think this is a bug then please create an issue at https://github.com/cloudflare/workers-sdk/issues/new/choose"
`);
expect(sentryRequests?.count).toEqual(0);
});
it("should hit sentry after error when permission provided", async () => {
mockConfirm({
text: "Would you like to report this error to Cloudflare?",
result: true,
});
await expect(runWrangler("delete")).rejects.toMatchInlineSnapshot(
`[AssertionError: A worker name must be defined, either via --name, or in wrangler.toml]`
);
expect(std.out).toMatchInlineSnapshot(`
"
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?.count).toBeGreaterThan(0);
});
});
});

function mockSentryEndpoint() {
const requests = { count: 0 };
msw.use(
rest.post(
`https://platform.dash.cloudflare.com/sentry/envelope`,
async (req, res, cxt) => {
requests.count++;
return res(cxt.status(200), cxt.json({}));
}
)
);

return requests;
}
9 changes: 5 additions & 4 deletions packages/wrangler/src/dialogs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,20 +21,21 @@ export class NoDefaultValueProvided extends Error {

interface ConfirmOptions {
defaultValue?: boolean;
fallbackValue?: boolean;
}

export async function confirm(
text: string,
{ defaultValue = true }: ConfirmOptions = {}
{ defaultValue = true, fallbackValue = true }: ConfirmOptions = {}
): Promise<boolean> {
if (isNonInteractiveOrCI()) {
logger.log(`? ${text}`);
logger.log(
`🤖 ${chalk.dim(
"Using default value in non-interactive context:"
)} ${chalk.white.bold(defaultValue ? "yes" : "no")}`
"Using fallback value in non-interactive context:"
)} ${chalk.white.bold(fallbackValue ? "yes" : "no")}`
);
return defaultValue;
return fallbackValue;
}
const { value } = await prompts({
type: "confirm",
Expand Down
12 changes: 12 additions & 0 deletions packages/wrangler/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,13 +39,20 @@ import { initHandler, initOptions } from "./init";
import { kvNamespace, kvKey, kvBulk } from "./kv";
import { logBuildFailure, logger } from "./logger";
import * as metrics from "./metrics";

import { mTlsCertificateCommands } from "./mtls-certificate/cli";
import { pages } from "./pages";
import { formatMessage, ParseError } from "./parse";
import { pubSubCommands } from "./pubsub/pubsub-commands";
import { queues } from "./queues/cli/commands";
import { r2 } from "./r2";
import { secret, secretBulkHandler, secretBulkOptions } from "./secret";
import {
captureGlobalException,
addBreadcrumb,
closeSentry,
setupSentry,
} from "./sentry";
import { tailOptions, tailHandler } from "./tail";
import { generateTypes } from "./type-generation";
import { printWranglerBanner } from "./update-check";
Expand Down Expand Up @@ -701,6 +708,9 @@ export function createCLIParser(argv: string[]) {
}

export async function main(argv: string[]): Promise<void> {
setupSentry();
addBreadcrumb(`wrangler ${argv.join(" ")}`);

const wrangler = createCLIParser(argv);
try {
await wrangler.parse();
Expand Down Expand Up @@ -755,9 +765,11 @@ export async function main(argv: string[]): Promise<void> {
`${fgGreenColor}%s${resetColor}`,
"If you think this is a bug then please create an issue at https://github.com/cloudflare/workers-sdk/issues/new/choose"
);
await captureGlobalException(e);
}
throw e;
} finally {
await closeSentry();
// In the bootstrapper script `bin/wrangler.js`, we open an IPC channel, so
// IPC messages from this process are propagated through the bootstrapper.
// Make sure this channel is closed once it's no longer needed, so we can
Expand Down
132 changes: 132 additions & 0 deletions packages/wrangler/src/sentry/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
import * as Sentry from "@sentry/node";
import { rejectedSyncPromise } from "@sentry/utils";
import { fetch } from "undici";
import { version as wranglerVersion } from "../../package.json";
import { confirm } from "../dialogs";
import { logger } from "../logger";
import type { BaseTransportOptions, TransportRequest } from "@sentry/types";
import type { RequestInit } from "undici";

let sentryReportingAllowed = false;

// The SENTRY_DSN is provided at esbuild time as a `define` for production and beta releases.
// Otherwise it is left undefined, which disables reporting.
declare const SENTRY_DSN: string;

/* Returns a Sentry transport for the Sentry proxy Worker. */
export const makeSentry10Transport = (options: BaseTransportOptions) => {
let eventQueue: [string, RequestInit][] = [];

const transportSentry10 = async (request: TransportRequest) => {
/* Adds helpful properties to the request body before we send it to our
proxy Worker. These properties can be parsed out from the NDJSON in
`request.body`, but it's easier and safer to just attach them here. */
const sentryWorkerPayload = {
envelope: request.body,
url: options.url,
};

try {
if (sentryReportingAllowed) {
const eventsToSend = [...eventQueue];
eventQueue = [];
for (const event of eventsToSend) {
await fetch(event[0], event[1]);
}

const response = await fetch(
`https://platform.dash.cloudflare.com/sentry/envelope`,
{
method: "POST",
headers: {
Accept: "*/*",
"Content-Type": "application/json",
},
body: JSON.stringify(sentryWorkerPayload),
}
);

return {
statusCode: response.status,
headers: {
"x-sentry-rate-limits": response.headers.get(
"X-Sentry-Rate-Limits"
),
"retry-after": response.headers.get("Retry-After"),
},
};
} else {
// We don't currently have permission to send this event, but maybe we will in the future.
// Add to an in-memory just in case
eventQueue.push([
`https://platform.dash.cloudflare.com/sentry/envelope`,
{
method: "POST",
headers: {
Accept: "*/*",
"Content-Type": "application/json",
},
body: JSON.stringify(sentryWorkerPayload),
},
]);
return {
statusCode: 200,
};
}
} catch (err) {
console.log(err);

return rejectedSyncPromise(err);
}
};

return Sentry.createTransport(options, transportSentry10);
};

export function setupSentry() {
if (typeof SENTRY_DSN !== "undefined") {
Sentry.init({
release: `wrangler@${wranglerVersion}`,
dsn: SENTRY_DSN,
transport: makeSentry10Transport,
});
}
}

export function addBreadcrumb(
message: string,
level: Sentry.SeverityLevel = "log"
) {
if (typeof SENTRY_DSN !== "undefined") {
Sentry.addBreadcrumb({
message,
level,
});
}
}

// Capture top-level Wrangler errors. Also take this opportunity to ask the user for
// 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?",
{ fallbackValue: false }
);

if (!sentryReportingAllowed) {
logger.debug(`Sentry: Reporting disabled - would have sent ${e}.`);
return;
}

logger.debug(`Sentry: Capturing exception ${e}`);
Sentry.captureException(e);
}
}

// Ensure we send Sentry events before Wrangler exits
export async function closeSentry() {
if (typeof SENTRY_DSN !== "undefined") {
await Sentry.close();
}
}
Loading

0 comments on commit 3314dbd

Please sign in to comment.