Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(ecau): add ability to export debug logs and HTTP responses for bug reports #591

Open
wants to merge 10 commits into
base: main
Choose a base branch
from
4 changes: 3 additions & 1 deletion build/rollup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ import postcss from 'rollup-plugin-postcss';
import progress from 'rollup-plugin-progress';
import { minify } from 'terser';

import { deduplicateArray } from '@lib/util/array';

import { parseChangelogEntries } from './changelog';
import { consts } from './plugin-consts';
import { logger } from './plugin-logger';
Expand Down Expand Up @@ -249,7 +251,7 @@ function getVendorMinifiedPreamble(chunk: Readonly<RenderedChunk>): string {
.slice(0, module.startsWith('@') ? 2 : 1)
.join('/'));

const uniqueBundledModules = [...new Set(bundledModules)];
const uniqueBundledModules = deduplicateArray(bundledModules);
if ('\u0000rollupPluginBabelHelpers.js' in chunk.modules) {
uniqueBundledModules.unshift('babel helpers');
}
Expand Down
3 changes: 2 additions & 1 deletion src/lib/MB/URLs.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { deduplicateArray } from '@lib/util/array';
import { request } from '@lib/util/request';

import type { ReleaseAdvRel, URLAdvRel } from './advanced-relationships';
Expand All @@ -21,7 +22,7 @@ export async function getURLsForRelease(releaseId: string, options?: { excludeEn
}
let urls = urlARs.map((ar) => ar.url.resource);
if (excludeDuplicates) {
urls = [...new Set(urls)];
urls = deduplicateArray(urls);
}

return urls.flatMap((url) => {
Expand Down
46 changes: 46 additions & 0 deletions src/lib/logging/collectorSink.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import type { LoggingSink } from './sink';
import { LogLevel } from './levels';

interface LogRecord {
level: string;
message: string;
timestamp: number;
exception?: unknown;
}

export class CollectorSink implements LoggingSink {
private readonly records: LogRecord[];

public constructor() {
this.records = [];
}

private saveMessage(level: string, message: string, exception?: unknown): void {
this.records.push({
level,
message,
exception,
timestamp: Date.now(),
});
}

public dumpMessages(): string {
return this.records
.flatMap(({ level, message, timestamp, exception }) => {
const dateStr = new Date(timestamp).toISOString();
const lines = [`[${dateStr} - ${level}] ${message}`];
if (exception !== undefined) lines.push(`${exception}`);
return lines;
})
.join('\n');
}

public readonly onDebug = this.saveMessage.bind(this, 'DEBUG');
public readonly onLog = this.saveMessage.bind(this, 'LOG');
public readonly onInfo = this.saveMessage.bind(this, 'INFO');
public readonly onSuccess = this.saveMessage.bind(this, 'SUCCESS');
public readonly onWarn = this.saveMessage.bind(this, 'WARNING');
public readonly onError = this.saveMessage.bind(this, 'ERROR');

public readonly minimumLevel = LogLevel.DEBUG;
}
9 changes: 5 additions & 4 deletions src/lib/logging/logger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,14 @@ interface LoggerOptions {
sinks: LoggingSink[];
}

const HANDLER_NAMES: Record<LogLevel, keyof LoggingSink> = {
const HANDLER_NAMES = {
[LogLevel.DEBUG]: 'onDebug',
[LogLevel.LOG]: 'onLog',
[LogLevel.INFO]: 'onInfo',
[LogLevel.SUCCESS]: 'onSuccess',
[LogLevel.WARNING]: 'onWarn',
[LogLevel.ERROR]: 'onError',
};
} as const;

const DEFAULT_OPTIONS = {
logLevel: LogLevel.INFO,
Expand All @@ -31,10 +31,11 @@ export class Logger {
}

private fireHandlers(level: LogLevel, message: string, exception?: unknown): void {
if (level < this._configuration.logLevel) return;

this._configuration.sinks
.forEach((sink) => {
const minLevel = sink.minimumLevel ?? this.configuration.logLevel;
if (level < minLevel) return;

const handler = sink[HANDLER_NAMES[level]];
if (!handler) return;

Expand Down
8 changes: 8 additions & 0 deletions src/lib/logging/sink.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,16 @@
import type { LogLevel } from './levels';

export interface LoggingSink {
onDebug?(message: string): void;
onLog?(message: string): void;
onInfo?(message: string): void;
onSuccess?(message: string): void;
onWarn?(message: string, exception?: unknown): void;
onError?(message: string, exception?: unknown): void;

/**
* Minimum level of log messages to pass to this sink. If left undefined,
* the logger will use the minimum level set on the logger itself.
*/
minimumLevel?: LogLevel;
}
4 changes: 4 additions & 0 deletions src/lib/util/array.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,3 +67,7 @@ export function insertBetween<T1, T2>(arr: readonly T1[], newElement: T2 | (() =
...arr.slice(1).flatMap((elmt) => [isFactory(newElement) ? newElement() : newElement, elmt]),
];
}

export function deduplicateArray(arr: readonly string[]): string[] {
return [...new Set(arr)];
}
70 changes: 58 additions & 12 deletions src/lib/util/request/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
/* eslint-disable no-restricted-globals */

import type { RequestObserver } from './observers';
import type { RequestMethod, RequestOptions } from './requestOptions';
import type { Response, ResponseFor, TextResponse } from './response';
import { performFetchRequest } from './backendFetch';
Expand All @@ -25,6 +26,8 @@ interface RequestFunc {

head<RequestOptionsT extends RequestOptions>(url: string | URL, options: RequestOptionsT): Promise<ResponseFor<RequestOptionsT>>;
head(url: string | URL): Promise<TextResponse>;

addObserver(observer: RequestObserver): void;
}

const hasGMXHR = (
Expand All @@ -33,22 +36,65 @@ const hasGMXHR = (
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- Might be using GMv3 API.
|| (typeof GM !== 'undefined' && GM.xmlHttpRequest !== undefined));

export const request: RequestFunc = async function (method: RequestMethod, url: string | URL, options?: RequestOptions) {
// istanbul ignore next: Difficult to test.
const backend = options?.backend ?? (hasGMXHR ? RequestBackend.GMXHR : RequestBackend.FETCH);
const response = await performRequest(backend, method, url, options);
export const request = ((): RequestFunc => {
const observers: RequestObserver[] = [];

function notifyObservers<EventT extends keyof RequestObserver>(event: EventT, data: Parameters<NonNullable<RequestObserver[EventT]>>[0]): void {
for (const observer of observers) {
// @ts-expect-error: False positive?
observer[event]?.(data);
}
}

const throwForStatus = options?.throwForStatus ?? true;
if (throwForStatus && response.status >= 400) {
throw new HTTPResponseError(url, response, options?.httpErrorMessages?.[response.status]);
function insertDefaultProgressListener(backend: RequestBackend, method: RequestMethod, url: URL | string, options?: RequestOptions): RequestOptions {
return {
...options,
// istanbul ignore next: Difficult to cover, test gmxhr doesn't emit progress events.
onProgress: (progressEvent): void => {
notifyObservers('onProgress', { backend, method, url, options, progressEvent });
// Also pass through this progress event to original listener if it exists.
options?.onProgress?.(progressEvent);
},
};
}

return response;
} as RequestFunc;
const impl = async function (method: RequestMethod, url: string | URL, options?: RequestOptions) {
// istanbul ignore next: Difficult to test.
const backend = options?.backend ?? (hasGMXHR ? RequestBackend.GMXHR : RequestBackend.FETCH);

try {
notifyObservers('onStarted', { backend, method, url, options });

// Inject own progress listener so we can echo that to the observers.
const optionsWithProgressWrapper = insertDefaultProgressListener(backend, method, url, options);
const response = await performRequest(backend, method, url, optionsWithProgressWrapper);

const throwForStatus = options?.throwForStatus ?? true;
if (throwForStatus && response.status >= 400) {
throw new HTTPResponseError(url, response, options?.httpErrorMessages?.[response.status]);
}

notifyObservers('onSuccess', { backend, method, url, options, response });
return response;
} catch (err) {
// istanbul ignore else: Should not happen in practice.
if (err instanceof Error) {
notifyObservers('onFailed', { backend, method, url, options, error: err });
}
throw err;
}
} as RequestFunc;

impl.get = impl.bind(undefined, 'GET');
impl.post = impl.bind(undefined, 'POST');
impl.head = impl.bind(undefined, 'HEAD');

impl.addObserver = (observer): void => {
observers.push(observer);
};

request.get = request.bind(undefined, 'GET');
request.post = request.bind(undefined, 'POST');
request.head = request.bind(undefined, 'HEAD');
return impl;
})();

function performRequest(backend: RequestBackend, method: RequestMethod, url: string | URL, options?: RequestOptions): Promise<Response> {
switch (backend) {
Expand Down
3 changes: 3 additions & 0 deletions src/lib/util/request/observers/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export { loggingObserver } from './loggingObserver';
export { RecordingObserver } from './recordingObserver';
export type { RequestObserver } from './types';
20 changes: 20 additions & 0 deletions src/lib/util/request/observers/loggingObserver.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { LOGGER } from '@lib/logging/logger';

import type { RequestObserver } from './types';

export const loggingObserver: RequestObserver = {
onStarted({ backend, method, url }) {
LOGGER.debug(`${method} ${url} - STARTED (backend: ${backend})`);
},
onSuccess({ method, url, response }) {
LOGGER.debug(`${method} ${url} - SUCCESS (code ${response.status})`);
},
onFailed({ method, url, error }) {
LOGGER.debug(`${method} ${url} - FAILED (${error})`);
},
// istanbul ignore next: Unit tests don't have progress events.
onProgress({ method, url, progressEvent }) {
const { loaded, total } = progressEvent;
LOGGER.debug(`${method} ${url} - PROGRESS (${loaded}/${total})`);
},
};
110 changes: 110 additions & 0 deletions src/lib/util/request/observers/recordingObserver.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import { deduplicateArray } from '@lib/util/array';

import type { ArrayBufferResponse, BlobResponse, Response, TextResponse } from '../response';
import type { BaseRequestEvent, RequestObserver } from './types';
import { HTTPResponseError } from '../errors';

type RecordedResponse = Omit<TextResponse, 'json' | 'rawResponse'>;
interface Recording {
requestInfo: BaseRequestEvent;
response: RecordedResponse;
}

/**
* Convert a response to a textual one. For blob and arraybuffer, strips the
* actual content and sets text to a placeholder string. We don't record the
* original responses since they can be large and would cause memory leaks.
*/
function convertResponse(response: Response): RecordedResponse {
if (Object.prototype.hasOwnProperty.call(response, 'text')) return response as TextResponse;

const text = Object.prototype.hasOwnProperty.call(response, 'blob')
? `<Blob, ${(response as BlobResponse).blob.size} bytes>`
: `<ArrayBuffer, ${(response as ArrayBufferResponse).arrayBuffer.byteLength} bytes>`;

return {
headers: response.headers,
status: response.status,
statusText: response.statusText,
url: response.url,
text,
};
}

/**
* Convert request info for recording. Strips down the passed in object to
* remove references to responses to prevent memory leaks. Returns a copy.
*/
function convertRequestInfo(requestInfo: BaseRequestEvent): BaseRequestEvent {
return {
backend: requestInfo.backend,
url: requestInfo.url,
method: requestInfo.method,
options: requestInfo.options,
};
}

function getURLHost(url: string | URL): string {
return new URL(url).host;
}

function exportRecordedResponse(recordedResponse: Recording): string {
const { requestInfo, response } = recordedResponse;
const { backend, method, url, options: reqOptions } = requestInfo;

const reqOptionsString = JSON.stringify(reqOptions, (key, value) => {
// Don't include the progress callback.
if (key === 'onProgress') return undefined;
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
return value;
}, 2);
const reqPreamble = `${method} ${url} (backend: ${backend})\nOptions: ${reqOptionsString}`;

const respPreamble = `${response.url} ${response.status}: ${response.statusText}`;
const respHeaders = [...response.headers.entries()]
.map(([name, value]) => `${name}: ${value}`)
.join('\n');

return [reqPreamble, '\n', respPreamble, respHeaders, '\n', response.text].join('\n');
}

export class RecordingObserver implements RequestObserver {
private readonly recordedResponses: Recording[];

public constructor() {
this.recordedResponses = [];
}

public onSuccess(event: BaseRequestEvent & { response: Response }): void {
this.recordedResponses.push({
requestInfo: convertRequestInfo(event),
response: convertResponse(event.response),
});
}

public onFailed(event: BaseRequestEvent & { error: Error }): void {
if (!(event.error instanceof HTTPResponseError)) return;
this.recordedResponses.push({
requestInfo: convertRequestInfo(event),
response: convertResponse(event.error.response),
});
}

public exportResponses(): string {
return this.recordedResponses
.map((recordedResponse) => exportRecordedResponse(recordedResponse))
.join('\n\n==============================\n\n');
}

public hasRecordings(): boolean {
return this.recordedResponses.length > 0;
}

public get recordedDomains(): string[] {
return deduplicateArray(this.recordedResponses.flatMap((rec) => {
const domains = [getURLHost(rec.requestInfo.url)];
if (rec.response.url) domains.push(getURLHost(rec.response.url));
return domains;
}));
}
}
16 changes: 16 additions & 0 deletions src/lib/util/request/observers/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import type { RequestBackend, RequestMethod, RequestOptions } from '../requestOptions';
import type { ProgressEvent, Response } from '../response';

export interface BaseRequestEvent {
backend: RequestBackend;
method: RequestMethod;
url: URL | string;
options?: RequestOptions;
}

export interface RequestObserver {
onStarted?: (event: Readonly<BaseRequestEvent>) => void;
onFailed?: (event: Readonly<BaseRequestEvent & { error: Error }>) => void;
onSuccess?: (event: Readonly<BaseRequestEvent & { response: Response }>) => void;
onProgress?: (event: Readonly<BaseRequestEvent & { progressEvent: ProgressEvent }>) => void;
}
Loading