Skip to content

Commit 3314dbd

Browse files
authored
Send Crash Reports to Sentry (#4571)
* Initial sentry implementation * Prompt every time * Add tests * Create pink-bags-push.md * Clear event queue * Make event queue non-const
1 parent 609430b commit 3314dbd

File tree

13 files changed

+346
-22
lines changed

13 files changed

+346
-22
lines changed

.changeset/pink-bags-push.md

+7
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
"wrangler": minor
3+
---
4+
5+
feat: When Wrangler crashes, send an error report to Sentry to aid in debugging.
6+
7+
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.

.github/workflows/create-pullrequest-prerelease.yml

+1
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ jobs:
6868
NODE_ENV: "production"
6969
ALGOLIA_APP_ID: ${{ secrets.ALGOLIA_APP_ID }}
7070
ALGOLIA_PUBLIC_KEY: ${{ secrets.ALGOLIA_PUBLIC_KEY }}
71+
SENTRY_DSN: "https://[email protected]/583"
7172
CI_OS: ${{ runner.os }}
7273

7374
- name: Pack miniflare

.github/workflows/prereleases.yml

+2
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ jobs:
6767
# this is the "test/staging" key for sparrow analytics
6868
SPARROW_SOURCE_KEY: "5adf183f94b3436ba78d67f506965998"
6969
ALGOLIA_APP_ID: ${{ secrets.ALGOLIA_APP_ID }}
70+
SENTRY_DSN: "https://[email protected]/583"
7071
ALGOLIA_PUBLIC_KEY: ${{ secrets.ALGOLIA_PUBLIC_KEY }}
7172
working-directory: packages/wrangler
7273

@@ -111,6 +112,7 @@ jobs:
111112
NODE_ENV: "production"
112113
ALGOLIA_APP_ID: ${{ secrets.ALGOLIA_APP_ID }}
113114
ALGOLIA_PUBLIC_KEY: ${{ secrets.ALGOLIA_PUBLIC_KEY }}
115+
SENTRY_DSN: "https://[email protected]/583"
114116
CI_OS: ${{ runner.os }}
115117

116118
- name: Build & Publish Prerelease Registry

.github/workflows/release.yml

+2
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,8 @@ jobs:
6262
NPM_PUBLISH_TOKEN: ${{ secrets.NPM_PUBLISH_TOKEN }}
6363
ALGOLIA_APP_ID: ${{ secrets.ALGOLIA_APP_ID }}
6464
ALGOLIA_PUBLIC_KEY: ${{ secrets.ALGOLIA_PUBLIC_KEY }}
65+
SENTRY_DSN: "https://[email protected]/583"
66+
6567
NODE_ENV: "production"
6668
# This is the "production" key for sparrow analytics.
6769
# Include this here because this step will rebuild Wrangler and needs to have this available

packages/wrangler/package.json

+4-1
Original file line numberDiff line numberDiff line change
@@ -123,10 +123,12 @@
123123
"@cloudflare/pages-shared": "workspace:^",
124124
"@cloudflare/types": "^6.18.4",
125125
"@cloudflare/workers-tsconfig": "workspace:*",
126-
"https-proxy-agent": "7.0.2",
127126
"@cloudflare/workers-types": "^4.20230914.0",
128127
"@iarna/toml": "^3.0.0",
129128
"@microsoft/api-extractor": "^7.28.3",
129+
"@sentry/node": "^7.86.0",
130+
"@sentry/types": "^7.86.0",
131+
"@sentry/utils": "^7.86.0",
130132
"@types/body-parser": "^1.19.2",
131133
"@types/busboy": "^1.5.0",
132134
"@types/command-exists": "^1.2.0",
@@ -164,6 +166,7 @@
164166
"get-port": "^6.1.2",
165167
"glob-to-regexp": "0.4.1",
166168
"http-terminator": "^3.2.0",
169+
"https-proxy-agent": "7.0.2",
167170
"ignore": "^5.2.0",
168171
"ink": "^3.2.0",
169172
"ink-select-input": "^4.2.1",

packages/wrangler/scripts/bundle.ts

+3
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,9 @@ async function buildMain(flags: BuildFlags = {}) {
5454
...(process.env.ALGOLIA_PUBLIC_KEY
5555
? { ALGOLIA_PUBLIC_KEY: `"${process.env.ALGOLIA_PUBLIC_KEY}"` }
5656
: {}),
57+
...(process.env.SENTRY_DSN
58+
? { SENTRY_DSN: `"${process.env.SENTRY_DSN}"` }
59+
: {}),
5760
},
5861
plugins: [embedWorkersPlugin],
5962
};

packages/wrangler/src/__tests__/deployments.test.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -301,7 +301,7 @@ describe("deployments", () => {
301301
"🚧\`wrangler rollback\` is a beta command. Please report any issues to https://github.com/cloudflare/workers-sdk/issues/new/choose
302302
303303
? 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.).
304-
🤖 Using default value in non-interactive context: yes
304+
🤖 Using fallback value in non-interactive context: yes
305305
? Please provide a message for this rollback (120 characters max)
306306
🤖 Using default value in non-interactive context:
307307
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
import { rest } from "msw";
2+
3+
import { mockAccountId, mockApiToken } from "./helpers/mock-account-id";
4+
import { mockConsoleMethods } from "./helpers/mock-console";
5+
import { clearDialogs, mockConfirm } from "./helpers/mock-dialogs";
6+
import { useMockIsTTY } from "./helpers/mock-istty";
7+
import { msw } from "./helpers/msw";
8+
import { runInTempDir } from "./helpers/run-in-tmp";
9+
import { runWrangler } from "./helpers/run-wrangler";
10+
11+
declare const global: { SENTRY_DSN: string | undefined };
12+
13+
describe("sentry", () => {
14+
const ORIGINAL_SENTRY_DSN = global.SENTRY_DSN;
15+
const std = mockConsoleMethods();
16+
runInTempDir();
17+
mockAccountId();
18+
mockApiToken();
19+
const { setIsTTY } = useMockIsTTY();
20+
21+
let sentryRequests: { count: number } | undefined;
22+
23+
beforeEach(() => {
24+
global.SENTRY_DSN =
25+
"https://[email protected]/24601";
26+
27+
sentryRequests = mockSentryEndpoint();
28+
});
29+
afterEach(() => {
30+
global.SENTRY_DSN = ORIGINAL_SENTRY_DSN;
31+
clearDialogs();
32+
msw.resetHandlers();
33+
});
34+
describe("non interactive", () => {
35+
it("should not hit sentry in normal usage", async () => {
36+
await runWrangler("version");
37+
expect(sentryRequests?.count).toEqual(0);
38+
});
39+
40+
it("should not hit sentry after error", async () => {
41+
await expect(runWrangler("delete")).rejects.toMatchInlineSnapshot(
42+
`[AssertionError: A worker name must be defined, either via --name, or in wrangler.toml]`
43+
);
44+
expect(std.out).toMatchInlineSnapshot(`
45+
"
46+
If you think this is a bug then please create an issue at https://github.com/cloudflare/workers-sdk/issues/new/choose
47+
? Would you like to report this error to Cloudflare?
48+
🤖 Using fallback value in non-interactive context: no"
49+
`);
50+
expect(sentryRequests?.count).toEqual(0);
51+
});
52+
});
53+
describe("interactive", () => {
54+
beforeEach(() => {
55+
setIsTTY(true);
56+
});
57+
afterEach(() => {
58+
setIsTTY(false);
59+
});
60+
it("should not hit sentry in normal usage", async () => {
61+
await runWrangler("version");
62+
expect(sentryRequests?.count).toEqual(0);
63+
});
64+
it("should not hit sentry after error when permission denied", async () => {
65+
mockConfirm({
66+
text: "Would you like to report this error to Cloudflare?",
67+
result: false,
68+
});
69+
await expect(runWrangler("delete")).rejects.toMatchInlineSnapshot(
70+
`[AssertionError: A worker name must be defined, either via --name, or in wrangler.toml]`
71+
);
72+
expect(std.out).toMatchInlineSnapshot(`
73+
"
74+
If you think this is a bug then please create an issue at https://github.com/cloudflare/workers-sdk/issues/new/choose"
75+
`);
76+
expect(sentryRequests?.count).toEqual(0);
77+
});
78+
it("should hit sentry after error when permission provided", async () => {
79+
mockConfirm({
80+
text: "Would you like to report this error to Cloudflare?",
81+
result: true,
82+
});
83+
await expect(runWrangler("delete")).rejects.toMatchInlineSnapshot(
84+
`[AssertionError: A worker name must be defined, either via --name, or in wrangler.toml]`
85+
);
86+
expect(std.out).toMatchInlineSnapshot(`
87+
"
88+
If you think this is a bug then please create an issue at https://github.com/cloudflare/workers-sdk/issues/new/choose"
89+
`);
90+
// Sentry sends multiple HTTP requests to capture breadcrumbs
91+
expect(sentryRequests?.count).toBeGreaterThan(0);
92+
});
93+
});
94+
});
95+
96+
function mockSentryEndpoint() {
97+
const requests = { count: 0 };
98+
msw.use(
99+
rest.post(
100+
`https://platform.dash.cloudflare.com/sentry/envelope`,
101+
async (req, res, cxt) => {
102+
requests.count++;
103+
return res(cxt.status(200), cxt.json({}));
104+
}
105+
)
106+
);
107+
108+
return requests;
109+
}

packages/wrangler/src/dialogs.ts

+5-4
Original file line numberDiff line numberDiff line change
@@ -21,20 +21,21 @@ export class NoDefaultValueProvided extends Error {
2121

2222
interface ConfirmOptions {
2323
defaultValue?: boolean;
24+
fallbackValue?: boolean;
2425
}
2526

2627
export async function confirm(
2728
text: string,
28-
{ defaultValue = true }: ConfirmOptions = {}
29+
{ defaultValue = true, fallbackValue = true }: ConfirmOptions = {}
2930
): Promise<boolean> {
3031
if (isNonInteractiveOrCI()) {
3132
logger.log(`? ${text}`);
3233
logger.log(
3334
`🤖 ${chalk.dim(
34-
"Using default value in non-interactive context:"
35-
)} ${chalk.white.bold(defaultValue ? "yes" : "no")}`
35+
"Using fallback value in non-interactive context:"
36+
)} ${chalk.white.bold(fallbackValue ? "yes" : "no")}`
3637
);
37-
return defaultValue;
38+
return fallbackValue;
3839
}
3940
const { value } = await prompts({
4041
type: "confirm",

packages/wrangler/src/index.ts

+12
Original file line numberDiff line numberDiff line change
@@ -39,13 +39,20 @@ import { initHandler, initOptions } from "./init";
3939
import { kvNamespace, kvKey, kvBulk } from "./kv";
4040
import { logBuildFailure, logger } from "./logger";
4141
import * as metrics from "./metrics";
42+
4243
import { mTlsCertificateCommands } from "./mtls-certificate/cli";
4344
import { pages } from "./pages";
4445
import { formatMessage, ParseError } from "./parse";
4546
import { pubSubCommands } from "./pubsub/pubsub-commands";
4647
import { queues } from "./queues/cli/commands";
4748
import { r2 } from "./r2";
4849
import { secret, secretBulkHandler, secretBulkOptions } from "./secret";
50+
import {
51+
captureGlobalException,
52+
addBreadcrumb,
53+
closeSentry,
54+
setupSentry,
55+
} from "./sentry";
4956
import { tailOptions, tailHandler } from "./tail";
5057
import { generateTypes } from "./type-generation";
5158
import { printWranglerBanner } from "./update-check";
@@ -701,6 +708,9 @@ export function createCLIParser(argv: string[]) {
701708
}
702709

703710
export async function main(argv: string[]): Promise<void> {
711+
setupSentry();
712+
addBreadcrumb(`wrangler ${argv.join(" ")}`);
713+
704714
const wrangler = createCLIParser(argv);
705715
try {
706716
await wrangler.parse();
@@ -755,9 +765,11 @@ export async function main(argv: string[]): Promise<void> {
755765
`${fgGreenColor}%s${resetColor}`,
756766
"If you think this is a bug then please create an issue at https://github.com/cloudflare/workers-sdk/issues/new/choose"
757767
);
768+
await captureGlobalException(e);
758769
}
759770
throw e;
760771
} finally {
772+
await closeSentry();
761773
// In the bootstrapper script `bin/wrangler.js`, we open an IPC channel, so
762774
// IPC messages from this process are propagated through the bootstrapper.
763775
// Make sure this channel is closed once it's no longer needed, so we can

packages/wrangler/src/sentry/index.ts

+132
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
import * as Sentry from "@sentry/node";
2+
import { rejectedSyncPromise } from "@sentry/utils";
3+
import { fetch } from "undici";
4+
import { version as wranglerVersion } from "../../package.json";
5+
import { confirm } from "../dialogs";
6+
import { logger } from "../logger";
7+
import type { BaseTransportOptions, TransportRequest } from "@sentry/types";
8+
import type { RequestInit } from "undici";
9+
10+
let sentryReportingAllowed = false;
11+
12+
// The SENTRY_DSN is provided at esbuild time as a `define` for production and beta releases.
13+
// Otherwise it is left undefined, which disables reporting.
14+
declare const SENTRY_DSN: string;
15+
16+
/* Returns a Sentry transport for the Sentry proxy Worker. */
17+
export const makeSentry10Transport = (options: BaseTransportOptions) => {
18+
let eventQueue: [string, RequestInit][] = [];
19+
20+
const transportSentry10 = async (request: TransportRequest) => {
21+
/* Adds helpful properties to the request body before we send it to our
22+
proxy Worker. These properties can be parsed out from the NDJSON in
23+
`request.body`, but it's easier and safer to just attach them here. */
24+
const sentryWorkerPayload = {
25+
envelope: request.body,
26+
url: options.url,
27+
};
28+
29+
try {
30+
if (sentryReportingAllowed) {
31+
const eventsToSend = [...eventQueue];
32+
eventQueue = [];
33+
for (const event of eventsToSend) {
34+
await fetch(event[0], event[1]);
35+
}
36+
37+
const response = await fetch(
38+
`https://platform.dash.cloudflare.com/sentry/envelope`,
39+
{
40+
method: "POST",
41+
headers: {
42+
Accept: "*/*",
43+
"Content-Type": "application/json",
44+
},
45+
body: JSON.stringify(sentryWorkerPayload),
46+
}
47+
);
48+
49+
return {
50+
statusCode: response.status,
51+
headers: {
52+
"x-sentry-rate-limits": response.headers.get(
53+
"X-Sentry-Rate-Limits"
54+
),
55+
"retry-after": response.headers.get("Retry-After"),
56+
},
57+
};
58+
} else {
59+
// We don't currently have permission to send this event, but maybe we will in the future.
60+
// Add to an in-memory just in case
61+
eventQueue.push([
62+
`https://platform.dash.cloudflare.com/sentry/envelope`,
63+
{
64+
method: "POST",
65+
headers: {
66+
Accept: "*/*",
67+
"Content-Type": "application/json",
68+
},
69+
body: JSON.stringify(sentryWorkerPayload),
70+
},
71+
]);
72+
return {
73+
statusCode: 200,
74+
};
75+
}
76+
} catch (err) {
77+
console.log(err);
78+
79+
return rejectedSyncPromise(err);
80+
}
81+
};
82+
83+
return Sentry.createTransport(options, transportSentry10);
84+
};
85+
86+
export function setupSentry() {
87+
if (typeof SENTRY_DSN !== "undefined") {
88+
Sentry.init({
89+
release: `wrangler@${wranglerVersion}`,
90+
dsn: SENTRY_DSN,
91+
transport: makeSentry10Transport,
92+
});
93+
}
94+
}
95+
96+
export function addBreadcrumb(
97+
message: string,
98+
level: Sentry.SeverityLevel = "log"
99+
) {
100+
if (typeof SENTRY_DSN !== "undefined") {
101+
Sentry.addBreadcrumb({
102+
message,
103+
level,
104+
});
105+
}
106+
}
107+
108+
// Capture top-level Wrangler errors. Also take this opportunity to ask the user for
109+
// consent if not already granted.
110+
export async function captureGlobalException(e: unknown) {
111+
if (typeof SENTRY_DSN !== "undefined") {
112+
sentryReportingAllowed = await confirm(
113+
"Would you like to report this error to Cloudflare?",
114+
{ fallbackValue: false }
115+
);
116+
117+
if (!sentryReportingAllowed) {
118+
logger.debug(`Sentry: Reporting disabled - would have sent ${e}.`);
119+
return;
120+
}
121+
122+
logger.debug(`Sentry: Capturing exception ${e}`);
123+
Sentry.captureException(e);
124+
}
125+
}
126+
127+
// Ensure we send Sentry events before Wrangler exits
128+
export async function closeSentry() {
129+
if (typeof SENTRY_DSN !== "undefined") {
130+
await Sentry.close();
131+
}
132+
}

0 commit comments

Comments
 (0)