Skip to content

Commit 3762d40

Browse files
authored
Improve rageshake upload experience by providing useful error information (#29378)
* Refactor submit rageshake so that it uses the new error codes. * Improve error information given in Bug Report Dialog * use type * Refactor with generic error & policy link. * lint * lint * Add BugReportDialog test * fix time travel * use waitFor while waiting for fetch to finish * lint * Drop error prefix as per matrix-org/rageshake@3973bb3 * small fixes * Don't change string here. * Fixup i18n strings.
1 parent 42192cb commit 3762d40

File tree

4 files changed

+240
-29
lines changed

4 files changed

+240
-29
lines changed

src/components/views/dialogs/BugReportDialog.tsx

Lines changed: 45 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,13 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com
99
Please see LICENSE files in the repository root for full details.
1010
*/
1111

12-
import React from "react";
12+
import React, { type ReactNode } from "react";
13+
import { Link } from "@vector-im/compound-web";
1314

1415
import SdkConfig from "../../../SdkConfig";
1516
import Modal from "../../../Modal";
1617
import { _t } from "../../../languageHandler";
17-
import sendBugReport, { downloadBugReport } from "../../../rageshake/submit-rageshake";
18+
import sendBugReport, { downloadBugReport, RageshakeError } from "../../../rageshake/submit-rageshake";
1819
import AccessibleButton from "../elements/AccessibleButton";
1920
import QuestionDialog from "./QuestionDialog";
2021
import BaseDialog from "./BaseDialog";
@@ -26,7 +27,7 @@ import defaultDispatcher from "../../../dispatcher/dispatcher";
2627
import { Action } from "../../../dispatcher/actions";
2728
import { getBrowserSupport } from "../../../SupportedBrowser";
2829

29-
interface IProps {
30+
export interface BugReportDialogProps {
3031
onFinished: (success: boolean) => void;
3132
initialText?: string;
3233
label?: string;
@@ -36,19 +37,19 @@ interface IProps {
3637
interface IState {
3738
sendLogs: boolean;
3839
busy: boolean;
39-
err: string | null;
40+
err: ReactNode | null;
4041
issueUrl: string;
4142
text: string;
4243
progress: string | null;
4344
downloadBusy: boolean;
4445
downloadProgress: string | null;
4546
}
4647

47-
export default class BugReportDialog extends React.Component<IProps, IState> {
48+
export default class BugReportDialog extends React.Component<BugReportDialogProps, IState> {
4849
private unmounted: boolean;
4950
private issueRef: React.RefObject<Field>;
5051

51-
public constructor(props: IProps) {
52+
public constructor(props: BugReportDialogProps) {
5253
super(props);
5354

5455
this.state = {
@@ -89,6 +90,42 @@ export default class BugReportDialog extends React.Component<IProps, IState> {
8990
this.props.onFinished(false);
9091
};
9192

93+
private getErrorText(error: Error | RageshakeError): ReactNode {
94+
if (error instanceof RageshakeError) {
95+
let errorText;
96+
switch (error.errorcode) {
97+
case "DISALLOWED_APP":
98+
errorText = _t("bug_reporting|failed_send_logs_causes|disallowed_app");
99+
break;
100+
case "REJECTED_BAD_VERSION":
101+
errorText = _t("bug_reporting|failed_send_logs_causes|rejected_version");
102+
break;
103+
case "REJECTED_UNEXPECTED_RECOVERY_KEY":
104+
errorText = _t("bug_reporting|failed_send_logs_causes|rejected_recovery_key");
105+
break;
106+
default:
107+
if (error.errorcode?.startsWith("REJECTED")) {
108+
errorText = _t("bug_reporting|failed_send_logs_causes|rejected_generic");
109+
} else {
110+
errorText = _t("bug_reporting|failed_send_logs_causes|server_unknown_error");
111+
}
112+
break;
113+
}
114+
return (
115+
<>
116+
<p>{errorText}</p>
117+
{error.policyURL && (
118+
<Link size="medium" target="_blank" href={error.policyURL}>
119+
{_t("action|learn_more")}
120+
</Link>
121+
)}
122+
</>
123+
);
124+
} else {
125+
return <p>{_t("bug_reporting|failed_send_logs_causes|unknown_error")}</p>;
126+
}
127+
}
128+
92129
private onSubmit = (): void => {
93130
if ((!this.state.text || !this.state.text.trim()) && (!this.state.issueUrl || !this.state.issueUrl.trim())) {
94131
this.setState({
@@ -126,7 +163,7 @@ export default class BugReportDialog extends React.Component<IProps, IState> {
126163
this.setState({
127164
busy: false,
128165
progress: null,
129-
err: _t("bug_reporting|failed_send_logs") + `${err.message}`,
166+
err: this.getErrorText(err),
130167
});
131168
}
132169
},
@@ -155,7 +192,7 @@ export default class BugReportDialog extends React.Component<IProps, IState> {
155192
this.setState({
156193
downloadBusy: false,
157194
downloadProgress:
158-
_t("bug_reporting|failed_send_logs") + `${err instanceof Error ? err.message : ""}`,
195+
_t("bug_reporting|failed_download_logs") + `${err instanceof Error ? err.message : ""}`,
159196
});
160197
}
161198
}

src/i18n/strings/en_EN.json

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -407,7 +407,15 @@
407407
"download_logs": "Download logs",
408408
"downloading_logs": "Downloading logs",
409409
"error_empty": "Please tell us what went wrong or, better, create a GitHub issue that describes the problem.",
410-
"failed_send_logs": "Failed to send logs: ",
410+
"failed_download_logs": "Failed to download debug logs: ",
411+
"failed_send_logs_causes": {
412+
"disallowed_app": "Your bug report was rejected. The rageshake server does not support this application.",
413+
"rejected_generic": "Your bug report was rejected. The rageshake server rejected the contents of the report due to a policy.",
414+
"rejected_recovery_key": "Your bug report was rejected for safety reasons, as it contained a recovery key.",
415+
"rejected_version": "Your bug report was rejected as the version you are running is too old.",
416+
"server_unknown_error": "The rageshake server encountered an unknown error and could not handle the report.",
417+
"unknown_error": "Failed to send logs."
418+
},
411419
"github_issue": "GitHub issue",
412420
"introduction": "If you've submitted a bug via GitHub, debug logs can help us track down the problem. ",
413421
"log_request": "To help us prevent this in future, please <a>send us logs</a>.",

src/rageshake/submit-rageshake.ts

Lines changed: 54 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
Copyright 2024 New Vector Ltd.
2+
Copyright 2024, 2025 New Vector Ltd.
33
Copyright 2019 The Matrix.org Foundation C.I.C.
44
Copyright 2018 New Vector Ltd
55
Copyright 2017 OpenMarket Ltd
@@ -30,6 +30,24 @@ interface IOpts {
3030
customFields?: Record<string, string>;
3131
}
3232

33+
export class RageshakeError extends Error {
34+
/**
35+
* This error is thrown when the rageshake server cannot process the request.
36+
* @param errorcode Machine-readable error code. See https://github.com/matrix-org/rageshake/blob/main/docs/api.md
37+
* @param error A human-readable error.
38+
* @param statusCode The HTTP status code.
39+
* @param policyURL Optional policy URL that can be presented to the user.
40+
*/
41+
public constructor(
42+
public readonly errorcode: string,
43+
public readonly error: string,
44+
public readonly statusCode: number,
45+
public readonly policyURL?: string,
46+
) {
47+
super(`The rageshake server responded with an error ${errorcode} (${statusCode}): ${error}`);
48+
}
49+
}
50+
3351
/**
3452
* Exported only for testing.
3553
* @internal public for test
@@ -323,6 +341,9 @@ async function collectLogs(
323341
* @param {function(string)} opts.progressCallback Callback to call with progress updates
324342
*
325343
* @return {Promise<string>} URL returned by the rageshake server
344+
*
345+
* @throws A RageshakeError when the rageshake server responds with an error. This will be `RS_UNKNOWN` if the
346+
* the server does not respond with an expected body format.
326347
*/
327348
export default async function sendBugReport(bugReportEndpoint?: string, opts: IOpts = {}): Promise<string> {
328349
if (!bugReportEndpoint) {
@@ -426,24 +447,37 @@ export async function submitFeedback(
426447
}
427448
}
428449

429-
function submitReport(endpoint: string, body: FormData, progressCallback: (str: string) => void): Promise<string> {
430-
return new Promise<string>((resolve, reject) => {
431-
const req = new XMLHttpRequest();
432-
req.open("POST", endpoint);
433-
req.responseType = "json";
434-
req.timeout = 5 * 60 * 1000;
435-
req.onreadystatechange = function (): void {
436-
if (req.readyState === XMLHttpRequest.LOADING) {
437-
progressCallback(_t("bug_reporting|waiting_for_server"));
438-
} else if (req.readyState === XMLHttpRequest.DONE) {
439-
// on done
440-
if (req.status < 200 || req.status >= 400) {
441-
reject(new Error(`HTTP ${req.status}`));
442-
return;
443-
}
444-
resolve(req.response.report_url || "");
445-
}
446-
};
447-
req.send(body);
450+
/**
451+
* Submit a rageshake report to the rageshake server.
452+
*
453+
* @param endpoint The endpoint to call.
454+
* @param body The report body.
455+
* @param progressCallback A callback that will be called when the upload process has begun.
456+
* @returns The URL to the public report.
457+
* @throws A RageshakeError when the rageshake server responds with an error. This will be `RS_UNKNOWN` if the
458+
* the server does not respond with an expected body format.
459+
*/
460+
async function submitReport(
461+
endpoint: string,
462+
body: FormData,
463+
progressCallback: (str: string) => void,
464+
): Promise<string> {
465+
const req = fetch(endpoint, {
466+
method: "POST",
467+
body,
468+
signal: AbortSignal.timeout?.(5 * 60 * 1000),
448469
});
470+
progressCallback(_t("bug_reporting|waiting_for_server"));
471+
const response = await req;
472+
if (response.headers.get("Content-Type") !== "application/json") {
473+
throw new RageshakeError("UNKNOWN", "Rageshake server responded with unexpected type", response.status);
474+
}
475+
const data = await response.json();
476+
if (response.status < 200 || response.status >= 400) {
477+
if ("errcode" in data) {
478+
throw new RageshakeError(data.errcode, data.error, response.status, data.policy_url);
479+
}
480+
throw new RageshakeError("UNKNOWN", "Rageshake server responded with unexpected type", response.status);
481+
}
482+
return data.report_url;
449483
}
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
/*
2+
Copyright 2025 New Vector Ltd.
3+
4+
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
5+
Please see LICENSE files in the repository root for full details.
6+
*/
7+
8+
import { render, waitFor, type RenderResult } from "jest-matrix-react";
9+
import userEvent from "@testing-library/user-event";
10+
import React from "react";
11+
import fetchMock from "fetch-mock-jest";
12+
import { type Mocked } from "jest-mock";
13+
14+
import BugReportDialog, {
15+
type BugReportDialogProps,
16+
} from "../../../../../src/components/views/dialogs/BugReportDialog";
17+
import SdkConfig from "../../../../../src/SdkConfig";
18+
import { type ConsoleLogger } from "../../../../../src/rageshake/rageshake";
19+
20+
const BUG_REPORT_URL = "https://example.org/submit";
21+
22+
describe("BugReportDialog", () => {
23+
const onFinished: jest.Mock<any, any> = jest.fn();
24+
25+
function renderComponent(props: Partial<BugReportDialogProps> = {}): RenderResult {
26+
return render(<BugReportDialog onFinished={onFinished} />);
27+
}
28+
29+
beforeEach(() => {
30+
jest.resetAllMocks();
31+
SdkConfig.put({
32+
bug_report_endpoint_url: BUG_REPORT_URL,
33+
});
34+
35+
const mockConsoleLogger = {
36+
flush: jest.fn(),
37+
consume: jest.fn(),
38+
warn: jest.fn(),
39+
} as unknown as Mocked<ConsoleLogger>;
40+
41+
// @ts-ignore - mock the console logger
42+
global.mx_rage_logger = mockConsoleLogger;
43+
44+
// @ts-ignore
45+
mockConsoleLogger.flush.mockReturnValue([
46+
{
47+
id: "instance-0",
48+
line: "line 1",
49+
},
50+
{
51+
id: "instance-1",
52+
line: "line 2",
53+
},
54+
]);
55+
});
56+
57+
afterEach(() => {
58+
SdkConfig.reset();
59+
fetchMock.restore();
60+
});
61+
62+
it("can close the bug reporter", async () => {
63+
const { getByTestId } = renderComponent();
64+
await userEvent.click(getByTestId("dialog-cancel-button"));
65+
expect(onFinished).toHaveBeenCalledWith(false);
66+
});
67+
68+
it("can submit a bug report", async () => {
69+
const { getByLabelText, getByText } = renderComponent();
70+
fetchMock.postOnce(BUG_REPORT_URL, { report_url: "https://exmaple.org/report/url" });
71+
await userEvent.type(getByLabelText("GitHub issue"), "https://example.org/some/issue");
72+
await userEvent.type(getByLabelText("Notes"), "Additional text");
73+
await userEvent.click(getByText("Send logs"));
74+
await waitFor(() => expect(getByText("Thank you!")).toBeInTheDocument());
75+
expect(onFinished).toHaveBeenCalledWith(false);
76+
expect(fetchMock).toHaveFetched(BUG_REPORT_URL);
77+
});
78+
79+
it.each([
80+
{
81+
errcode: undefined,
82+
text: "The rageshake server encountered an unknown error and could not handle the report.",
83+
},
84+
{
85+
errcode: "CUSTOM_ERROR_TYPE",
86+
text: "The rageshake server encountered an unknown error and could not handle the report.",
87+
},
88+
{
89+
errcode: "DISALLOWED_APP",
90+
text: "Your bug report was rejected. The rageshake server does not support this application.",
91+
},
92+
{
93+
errcode: "REJECTED_BAD_VERSION",
94+
text: "Your bug report was rejected as the version you are running is too old.",
95+
},
96+
{
97+
errcode: "REJECTED_UNEXPECTED_RECOVERY_KEY",
98+
text: "Your bug report was rejected for safety reasons, as it contained a recovery key.",
99+
},
100+
{
101+
errcode: "REJECTED_CUSTOM_REASON",
102+
text: "Your bug report was rejected. The rageshake server rejected the contents of the report due to a policy.",
103+
},
104+
])("handles bug report upload errors ($errcode)", async ({ errcode, text }) => {
105+
const { getByLabelText, getByText } = renderComponent();
106+
fetchMock.postOnce(BUG_REPORT_URL, { status: 400, body: errcode ? { errcode: errcode, error: "blah" } : "" });
107+
await userEvent.type(getByLabelText("GitHub issue"), "https://example.org/some/issue");
108+
await userEvent.type(getByLabelText("Notes"), "Additional text");
109+
await userEvent.click(getByText("Send logs"));
110+
expect(onFinished).not.toHaveBeenCalled();
111+
expect(fetchMock).toHaveFetched(BUG_REPORT_URL);
112+
await waitFor(() => getByText(text));
113+
});
114+
115+
it("should show a policy link when provided", async () => {
116+
const { getByLabelText, getByText } = renderComponent();
117+
fetchMock.postOnce(BUG_REPORT_URL, {
118+
status: 404,
119+
body: { errcode: "REJECTED_CUSTOM_REASON", error: "blah", policy_url: "https://example.org/policyurl" },
120+
});
121+
await userEvent.type(getByLabelText("GitHub issue"), "https://example.org/some/issue");
122+
await userEvent.type(getByLabelText("Notes"), "Additional text");
123+
await userEvent.click(getByText("Send logs"));
124+
expect(onFinished).not.toHaveBeenCalled();
125+
expect(fetchMock).toHaveFetched(BUG_REPORT_URL);
126+
await waitFor(() => {
127+
const learnMoreLink = getByText("Learn more");
128+
expect(learnMoreLink).toBeInTheDocument();
129+
expect(learnMoreLink.getAttribute("href")).toEqual("https://example.org/policyurl");
130+
});
131+
});
132+
});

0 commit comments

Comments
 (0)