Allow local log downloads when a rageshake URL is not configured. (#31716)

* Add support for storing debug logs locally and allowing local downloads.

* static

* Comprehensive testing for bug report flow.

* Driveby cleanup of typography

* fix i18n

* Improvements to UX

* More testing

* update snaps

* linting

* lint

* Fix feedback

* Fix boldnewss

* fix bold

* fix heading

* Increase test coverage

* remove focus

* Don't show the FAQ depending on whether you can submit feedback.

* move reset

* fix err

* Remove unused

* update snap

* Remove text

* Bumping up that coverage

* tidy

* lint

* update snap

* Use a const

* fix imports

* Remove import in e2e test

* whoops
This commit is contained in:
Will Hunt
2026-01-20 12:29:18 +00:00
committed by GitHub
parent b7a2e8c64e
commit a15efcc6d0
27 changed files with 692 additions and 162 deletions

View File

@@ -80,7 +80,7 @@ declare global {
function setTimeout(handler: TimerHandler, timeout: number, ...arguments: any[]): number;
interface Window {
mxSendRageshake: (text: string, withLogs?: boolean) => void;
mxSendRageshake: (text: string, withLogs?: boolean) => Promise<void>;
matrixLogger: typeof logger;
matrixChat?: MatrixChat;
mxSendSentryReport: (userText: string, issueUrl: string, error: Error) => Promise<void>;

View File

@@ -17,6 +17,12 @@ import { type ValidatedServerConfig } from "./utils/ValidatedServerConfig";
/* eslint-disable camelcase */
/* eslint @typescript-eslint/naming-convention: ["error", { "selector": "property", "format": ["snake_case"] } ] */
/**
* Bug reports are enabled but must only be locally
* downloadable.
*/
export const BugReportEndpointURLLocal = "local";
// see element-web config.md for non-developer docs
export interface IConfigOptions {
// dev note: while true that this is arbitrary JSON, it's valuable to enforce that all
@@ -98,7 +104,10 @@ export interface IConfigOptions {
show_labs_settings: boolean;
features?: Record<string, boolean>; // <FeatureName, EnabledBool>
bug_report_endpoint_url?: string; // omission disables bug reporting
/**
* Bug report endpoint URL. "local" means the logs should not be uploaded.
*/
bug_report_endpoint_url?: typeof BugReportEndpointURLLocal | string; // omission disables bug reporting
uisi_autorageshake_app?: string; // defaults to "element-auto-uisi"
sentry?: {
dsn: string;

View File

@@ -1,4 +1,5 @@
/*
Copyright 2026 Element Creations Ltd.
Copyright 2024 New Vector Ltd.
Copyright 2019 Michael Telatynski <7t3chguy@gmail.com>
Copyright 2019 The Matrix.org Foundation C.I.C.
@@ -10,7 +11,7 @@ Please see LICENSE files in the repository root for full details.
*/
import React, { type JSX, type ReactNode } from "react";
import { Link } from "@vector-im/compound-web";
import { Link, Text } from "@vector-im/compound-web";
import SdkConfig from "../../../SdkConfig";
import Modal from "../../../Modal";
@@ -26,6 +27,7 @@ import { sendSentryReport } from "../../../sentry";
import defaultDispatcher from "../../../dispatcher/dispatcher";
import { Action } from "../../../dispatcher/actions";
import { getBrowserSupport } from "../../../SupportedBrowser";
import { BugReportEndpointURLLocal } from "../../../IConfigOptions";
export interface BugReportDialogProps {
onFinished: (success: boolean) => void;
@@ -48,6 +50,7 @@ interface IState {
export default class BugReportDialog extends React.Component<BugReportDialogProps, IState> {
private unmounted: boolean;
private issueRef: React.RefObject<Field | null>;
private readonly isLocalOnly: boolean;
public constructor(props: BugReportDialogProps) {
super(props);
@@ -65,6 +68,8 @@ export default class BugReportDialog extends React.Component<BugReportDialogProp
this.unmounted = false;
this.issueRef = React.createRef();
// This config is static at runtime, but may change during tests.
this.isLocalOnly = SdkConfig.get().bug_report_endpoint_url === BugReportEndpointURLLocal;
}
public componentDidMount(): void {
@@ -142,6 +147,14 @@ export default class BugReportDialog extends React.Component<BugReportDialogProp
this.setState({ busy: true, progress: null, err: null });
this.sendProgressCallback(_t("bug_reporting|preparing_logs"));
if (this.isLocalOnly) {
// Shouldn't reach here, but throw in case we do.
this.setState({
err: _t("bug_reporting|failed_send_logs_causes|unknown_error"),
});
return;
}
sendBugReport(SdkConfig.get().bug_report_endpoint_url, {
userText,
sendLogs: true,
@@ -241,77 +254,77 @@ export default class BugReportDialog extends React.Component<BugReportDialogProp
(window.Modernizr && Object.values(window.Modernizr).some((support) => support === false)) ||
!getBrowserSupport()
) {
warning = (
<p>
<strong>{_t("bug_reporting|unsupported_browser")}</strong>
</p>
);
warning = <Text weight="semibold">{_t("bug_reporting|unsupported_browser")}</Text>;
}
return (
<BaseDialog
className="mx_BugReportDialog"
onFinished={this.onCancel}
title={_t("bug_reporting|submit_debug_logs")}
title={this.isLocalOnly ? _t("bug_reporting|download_logs") : _t("bug_reporting|submit_debug_logs")}
contentId="mx_Dialog_content"
>
<div className="mx_Dialog_content" id="mx_Dialog_content">
{warning}
<p>{_t("bug_reporting|description")}</p>
<p>
<strong>
{_t(
"bug_reporting|before_submitting",
{},
{
a: (sub) => (
<a
target="_blank"
href={SdkConfig.get().feedback.new_issue_url}
rel="noreferrer noopener"
>
{sub}
</a>
),
},
)}
</strong>
</p>
<Text>{_t("bug_reporting|description")}</Text>
{this.isLocalOnly ? (
<>{this.state.downloadProgress && <span>{this.state.downloadProgress} ...</span>}</>
) : (
<>
<Text weight="semibold">
{_t(
"bug_reporting|before_submitting",
{},
{
a: (sub) => (
<Link target="_blank" href={SdkConfig.get().feedback.new_issue_url}>
{sub}
</Link>
),
},
)}
</Text>
<div className="mx_BugReportDialog_download">
<AccessibleButton onClick={this.onDownload} kind="link" disabled={this.state.downloadBusy}>
{_t("bug_reporting|download_logs")}
</AccessibleButton>
{this.state.downloadProgress && <span>{this.state.downloadProgress} ...</span>}
</div>
<div className="mx_BugReportDialog_download">
<AccessibleButton
onClick={this.onDownload}
kind="link"
disabled={this.state.downloadBusy}
>
{_t("bug_reporting|download_logs")}
</AccessibleButton>
{this.state.downloadProgress && <span>{this.state.downloadProgress} ...</span>}
</div>
<Field
type="text"
className="mx_BugReportDialog_field_input"
label={_t("bug_reporting|github_issue")}
onChange={this.onIssueUrlChange}
value={this.state.issueUrl}
placeholder="https://github.com/vector-im/element-web/issues/..."
ref={this.issueRef}
/>
<Field
className="mx_BugReportDialog_field_input"
element="textarea"
label={_t("bug_reporting|textarea_label")}
rows={5}
onChange={this.onTextChange}
value={this.state.text}
placeholder={_t("bug_reporting|additional_context")}
/>
{progress}
{error}
<Field
type="text"
className="mx_BugReportDialog_field_input"
label={_t("bug_reporting|github_issue")}
onChange={this.onIssueUrlChange}
value={this.state.issueUrl}
placeholder="https://github.com/vector-im/element-web/issues/..."
ref={this.issueRef}
/>
<Field
className="mx_BugReportDialog_field_input"
element="textarea"
label={_t("bug_reporting|textarea_label")}
rows={5}
onChange={this.onTextChange}
value={this.state.text}
placeholder={_t("bug_reporting|additional_context")}
/>
{progress}
{error}
</>
)}
</div>
<DialogButtons
primaryButton={_t("bug_reporting|send_logs")}
onPrimaryButtonClick={this.onSubmit}
primaryButton={this.isLocalOnly ? _t("bug_reporting|download_logs") : _t("bug_reporting|send_logs")}
onPrimaryButtonClick={this.isLocalOnly ? this.onDownload : this.onSubmit}
focus={true}
onCancel={this.onCancel}
disabled={this.state.busy}
disabled={this.isLocalOnly ? this.state.downloadBusy : this.state.busy}
/>
</BaseDialog>
);

View File

@@ -45,6 +45,7 @@ const FeedbackDialog: React.FC<IProps> = (props: IProps) => {
const onFinished = (sendFeedback: boolean): void => {
if (hasFeedback && sendFeedback) {
const label = props.feature ? `${props.feature}-feedback` : "feedback";
// TODO: Handle rejection.
submitFeedback(label, comment, canContact);
Modal.createDialog(InfoDialog, {

View File

@@ -38,7 +38,7 @@ const GenericFeatureFeedbackDialog: React.FC<IProps> = ({
const sendFeedback = async (ok: boolean): Promise<void> => {
if (!ok) return onFinished(false);
// TODO: Handle rejection.
submitFeedback(rageshakeLabel, comment, canContact, rageshakeData);
onFinished(true);

View File

@@ -0,0 +1,43 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import React, { useCallback } from "react";
import { Button } from "@vector-im/compound-web";
import SdkConfig from "../../../SdkConfig";
import { _t } from "../../../languageHandler";
import Modal from "../../../Modal";
import BugReportDialog, { type BugReportDialogProps } from "../dialogs/BugReportDialog";
import { BugReportEndpointURLLocal } from "../../../IConfigOptions";
/**
* Renders a button to open the BugReportDialog *if* the configuration
* supports it.
*/
export function BugReportDialogButton({
label,
error,
}: Pick<BugReportDialogProps, "label" | "error">): React.ReactElement | null {
const bugReportUrl = SdkConfig.get().bug_report_endpoint_url;
const onClick = useCallback(() => {
Modal.createDialog(BugReportDialog, {
label,
error,
});
}, [label, error]);
if (!bugReportUrl) {
return null;
}
return (
<Button kind="secondary" size="sm" onClick={onClick}>
{bugReportUrl === BugReportEndpointURLLocal
? _t("bug_reporting|download_logs")
: _t("bug_reporting|submit_debug_logs")}
</Button>
);
}

View File

@@ -12,10 +12,9 @@ import { logger } from "matrix-js-sdk/src/logger";
import { _t } from "../../../languageHandler";
import { MatrixClientPeg } from "../../../MatrixClientPeg";
import PlatformPeg from "../../../PlatformPeg";
import Modal from "../../../Modal";
import SdkConfig from "../../../SdkConfig";
import BugReportDialog from "../dialogs/BugReportDialog";
import AccessibleButton from "./AccessibleButton";
import { BugReportDialogButton } from "./BugReportDialogButton";
interface Props {
children: ReactNode;
@@ -60,13 +59,6 @@ export default class ErrorBoundary extends React.PureComponent<Props, IState> {
});
};
private onBugReport = (): void => {
Modal.createDialog(BugReportDialog, {
label: "react-soft-crash",
error: this.state.error,
});
};
public render(): ReactNode {
if (this.state.error) {
const newIssueUrl = SdkConfig.get().feedback.new_issue_url;
@@ -95,9 +87,7 @@ export default class ErrorBoundary extends React.PureComponent<Props, IState> {
&nbsp;
{_t("bug_reporting|description")}
</p>
<AccessibleButton onClick={this.onBugReport} kind="primary">
{_t("bug_reporting|submit_debug_logs")}
</AccessibleButton>
<BugReportDialogButton error={this.state.error} label="react-soft-crash" />
</React.Fragment>
);
}

View File

@@ -12,12 +12,11 @@ import { type MatrixEvent } from "matrix-js-sdk/src/matrix";
import { _t } from "../../../languageHandler";
import Modal from "../../../Modal";
import SdkConfig from "../../../SdkConfig";
import BugReportDialog from "../dialogs/BugReportDialog";
import AccessibleButton from "../elements/AccessibleButton";
import SettingsStore from "../../../settings/SettingsStore";
import ViewSource from "../../structures/ViewSource";
import { type Layout } from "../../../settings/enums/Layout";
import { BugReportDialogButton } from "../elements/BugReportDialogButton";
interface IProps {
mxEvent: MatrixEvent;
@@ -42,13 +41,6 @@ export default class TileErrorBoundary extends React.Component<IProps, IState> {
return { error };
}
private onBugReport = (): void => {
Modal.createDialog(BugReportDialog, {
label: "react-soft-crash-tile",
error: this.state.error,
});
};
private onViewSource = (): void => {
Modal.createDialog(
ViewSource,
@@ -69,18 +61,6 @@ export default class TileErrorBoundary extends React.Component<IProps, IState> {
mx_EventTile_tileError: true,
};
let submitLogsButton;
if (SdkConfig.get().bug_report_endpoint_url) {
submitLogsButton = (
<>
&nbsp;
<AccessibleButton kind="link" onClick={this.onBugReport}>
{_t("bug_reporting|submit_debug_logs")}
</AccessibleButton>
</>
);
}
let viewSourceButton;
if (mxEvent && SettingsStore.getValue("developerMode")) {
viewSourceButton = (
@@ -99,7 +79,7 @@ export default class TileErrorBoundary extends React.Component<IProps, IState> {
<span>
{_t("timeline|error_rendering_message")}
{mxEvent && ` (${mxEvent.getType()})`}
{submitLogsButton}
<BugReportDialogButton error={this.state.error} label="react-tile-soft-crash" />
{viewSourceButton}
</span>
</div>

View File

@@ -13,16 +13,15 @@ import { type EmptyObject } from "matrix-js-sdk/src/matrix";
import AccessibleButton from "../../../elements/AccessibleButton";
import { _t } from "../../../../../languageHandler";
import SdkConfig from "../../../../../SdkConfig";
import Modal from "../../../../../Modal";
import PlatformPeg from "../../../../../PlatformPeg";
import UpdateCheckButton from "../../UpdateCheckButton";
import BugReportDialog from "../../../dialogs/BugReportDialog";
import CopyableText from "../../../elements/CopyableText";
import SettingsTab from "../SettingsTab";
import { SettingsSection } from "../../shared/SettingsSection";
import { SettingsSubsection, SettingsSubsectionText } from "../../shared/SettingsSubsection";
import ExternalLink from "../../../elements/ExternalLink";
import MatrixClientContext from "../../../../../contexts/MatrixClientContext";
import { BugReportDialogButton } from "../../../elements/BugReportDialogButton";
interface IState {
appVersion: string | null;
@@ -80,10 +79,6 @@ export default class HelpUserSettingsTab extends React.Component<EmptyObject, IS
});
};
private onBugReport = (): void => {
Modal.createDialog(BugReportDialog, {});
};
private renderLegal(): ReactNode {
const tocLinks = SdkConfig.get().terms_and_conditions_links;
if (!tocLinks) return null;
@@ -231,9 +226,7 @@ export default class HelpUserSettingsTab extends React.Component<EmptyObject, IS
</>
}
>
<AccessibleButton onClick={this.onBugReport} kind="primary_outline">
{_t("bug_reporting|submit_debug_logs")}
</AccessibleButton>
<BugReportDialogButton />
<SettingsSubsectionText>
{_t(
"bug_reporting|matrix_security_issue",

View File

@@ -44,6 +44,7 @@ import { type JitsiCallMemberContent, JitsiCallMemberEventType } from "../call-t
import SdkConfig from "../SdkConfig.ts";
import DMRoomMap from "../utils/DMRoomMap.ts";
import { type WidgetMessaging, WidgetMessagingEvent } from "../stores/widgets/WidgetMessaging.ts";
import { BugReportEndpointURLLocal } from "../IConfigOptions.ts";
const TIMEOUT_MS = 16000;
const logger = rootLogger.getChild("models/Call");
@@ -769,7 +770,7 @@ export class ElementCall extends Call {
}
const rageshakeSubmitUrl = SdkConfig.get("bug_report_endpoint_url");
if (rageshakeSubmitUrl) {
if (rageshakeSubmitUrl && rageshakeSubmitUrl !== BugReportEndpointURLLocal) {
params.append("rageshakeSubmitUrl", rageshakeSubmitUrl);
}

View File

@@ -20,6 +20,8 @@ import * as rageshake from "./rageshake";
import SettingsStore from "../settings/SettingsStore";
import SdkConfig from "../SdkConfig";
import { getServerVersionFromFederationApi } from "../components/views/dialogs/devtools/ServerInfo";
import type * as Tar from "tar-js";
import { BugReportEndpointURLLocal } from "../IConfigOptions";
interface IOpts {
labels?: string[];
@@ -342,7 +344,7 @@ async function collectLogs(
* the server does not respond with an expected body format.
*/
export default async function sendBugReport(bugReportEndpoint?: string, opts: IOpts = {}): Promise<string> {
if (!bugReportEndpoint) {
if (!bugReportEndpoint || bugReportEndpoint === BugReportEndpointURLLocal) {
throw new Error("No bug report endpoint has been set.");
}
@@ -354,20 +356,12 @@ export default async function sendBugReport(bugReportEndpoint?: string, opts: IO
}
/**
* Downloads the files from a bug report. This is the same as sendBugReport,
* but instead causes the browser to download the files locally.
* Loads a bug report into a tarball.
*
* @param {object} opts optional dictionary of options
*
* @param {string} opts.userText Any additional user input.
*
* @param {boolean} opts.sendLogs True to send logs
*
* @param {function(string)} opts.progressCallback Callback to call with progress updates
*
* @return {Promise} Resolved when the bug report is downloaded (or started).
* @param opts optional dictionary of options
* @return Resolves with a Tarball object.
*/
export async function downloadBugReport(opts: IOpts = {}): Promise<void> {
export async function loadBugReport(opts: IOpts = {}): Promise<Tar> {
const Tar = (await import("tar-js")).default;
const progressCallback = opts.progressCallback || ((): void => {});
const body = await collectBugReport(opts, false);
@@ -391,7 +385,18 @@ export async function downloadBugReport(opts: IOpts = {}): Promise<void> {
}
}
tape.append("issue.txt", metadata);
return tape;
}
/**
* Downloads the files from a bug report. This is the same as sendBugReport,
* but instead causes the browser to download the files locally.
*
* @param opts optional dictionary of options
* @return Resolved when the bug report is downloaded (or started).
*/
export async function downloadBugReport(opts: IOpts = {}): Promise<void> {
const tape = await loadBugReport(opts);
// We have to create a new anchor to download if we want a filename. Otherwise we could
// just use window.open.
const dl = document.createElement("a");
@@ -417,6 +422,10 @@ export async function submitFeedback(
canContact = false,
extraData: Record<string, any> = {},
): Promise<void> {
const bugReportEndpointUrl = SdkConfig.get().bug_report_endpoint_url;
if (!bugReportEndpointUrl || bugReportEndpointUrl === BugReportEndpointURLLocal) {
throw new Error("Bug report URL is not set or local");
}
let version: string | undefined;
try {
version = await PlatformPeg.get()?.getAppVersion();
@@ -436,11 +445,7 @@ export async function submitFeedback(
body.append(k, JSON.stringify(extraData[k]));
}
const bugReportEndpointUrl = SdkConfig.get().bug_report_endpoint_url;
if (bugReportEndpointUrl) {
await submitReport(bugReportEndpointUrl, body, () => {});
}
await submitReport(bugReportEndpointUrl, body, () => {});
}
/**

View File

@@ -405,15 +405,14 @@ export const SETTINGS: Settings = {
</p>
</>
),
faq: () =>
SdkConfig.get().bug_report_endpoint_url && (
<>
<h4>{_t("labs|video_rooms_faq1_question")}</h4>
<p>{_t("labs|video_rooms_faq1_answer")}</p>
<h4>{_t("labs|video_rooms_faq2_question")}</h4>
<p>{_t("labs|video_rooms_faq2_answer")}</p>
</>
),
faq: () => (
<>
<h4>{_t("labs|video_rooms_faq1_question")}</h4>
<p>{_t("labs|video_rooms_faq1_answer")}</p>
<h4>{_t("labs|video_rooms_faq2_question")}</h4>
<p>{_t("labs|video_rooms_faq2_answer")}</p>
</>
),
feedbackLabel: "video-room-feedback",
feedbackSubheading: _td("labs|video_rooms_feedbackSubheading"),
// eslint-disable-next-line @typescript-eslint/no-require-imports

View File

@@ -6,10 +6,12 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com
Please see LICENSE files in the repository root for full details.
*/
import { BugReportEndpointURLLocal } from "../IConfigOptions";
import SdkConfig from "../SdkConfig";
import SettingsStore from "../settings/SettingsStore";
import { UIFeature } from "../settings/UIFeature";
export function shouldShowFeedback(): boolean {
return !!SdkConfig.get().bug_report_endpoint_url && SettingsStore.getValue(UIFeature.Feedback);
const url = SdkConfig.get().bug_report_endpoint_url;
return !!url && url !== BugReportEndpointURLLocal && SettingsStore.getValue(UIFeature.Feedback);
}

View File

@@ -22,7 +22,8 @@ import { logger } from "matrix-js-sdk/src/logger";
import * as rageshake from "../rageshake/rageshake";
import SdkConfig from "../SdkConfig";
import sendBugReport from "../rageshake/submit-rageshake";
import sendBugReport, { loadBugReport } from "../rageshake/submit-rageshake";
import { BugReportEndpointURLLocal } from "../IConfigOptions";
export function initRageshake(): Promise<void> {
// we manually check persistence for rageshakes ourselves
@@ -54,28 +55,40 @@ export function initRageshakeStore(): Promise<void> {
return rageshake.tryInitStorage();
}
window.mxSendRageshake = function (text: string, withLogs?: boolean): void {
window.mxSendRageshake = async function (text: string, withLogs = true): Promise<void> {
const url = SdkConfig.get().bug_report_endpoint_url;
if (!url) {
logger.error("Cannot send a rageshake - no bug_report_endpoint_url configured");
return;
}
if (withLogs === undefined) withLogs = true;
if (!text || !text.trim()) {
logger.error("Cannot send a rageshake without a message - please tell us what went wrong");
return;
}
sendBugReport(url, {
userText: text,
sendLogs: withLogs,
progressCallback: logger.log.bind(console),
}).then(
() => {
if (url === BugReportEndpointURLLocal) {
try {
const tape = await loadBugReport({
userText: text,
sendLogs: withLogs,
progressCallback: logger.log.bind(console),
});
const blob = new Blob([new Uint8Array(tape.out)], { type: "application/gzip" });
const url = URL.createObjectURL(blob);
logger.log(`Your logs are available at ${url}`);
} catch (err) {
logger.error("Failed to load bug report", err);
}
} else {
try {
await sendBugReport(url, {
userText: text,
sendLogs: withLogs,
progressCallback: logger.log.bind(console),
});
logger.log("Bug report sent!");
},
(err) => {
logger.error(err);
},
);
} catch (err) {
logger.error("Failed to send bug report", err);
}
}
};