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: Offline Support #392

Merged
merged 12 commits into from
Dec 15, 2021
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@
"@types/koa": "^2.0.52",
"@types/koa-bodyparser": "^4.3.0",
"@types/mocha": "^9.0.0",
"@types/tmp": "^0.2.2",
"busboy": "^0.3.1",
"chai": "^4.3.4",
"chai-as-promised": "^7.1.1",
Expand All @@ -95,6 +96,7 @@
"npm-run-all": "^4.1.5",
"prettier": "^2.4.1",
"rimraf": "^3.0.2",
"tmp": "^0.2.1",
"ts-node": "^10.4.0",
"typescript": "^4.4.4",
"xvfb-maybe": "^0.2.1",
Expand Down
31 changes: 29 additions & 2 deletions src/main/fs.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import { mkdir, mkdirSync, readdir, readFile, rename, stat, Stats, statSync, unlink } from 'fs';
import { dirname, resolve } from 'path';
import { app } from 'electron';
import { mkdir, mkdirSync, readdir, readFile, rename, stat, Stats, statSync, unlink, writeFile } from 'fs';
import { dirname, join, resolve } from 'path';

export const sentryCachePath = join(app.getPath('userData'), 'sentry');
timfish marked this conversation as resolved.
Show resolved Hide resolved

/**
* Asynchronously reads given files content.
Expand All @@ -24,6 +27,30 @@ export async function readFileAsync(
});
}

/**
* Asynchronously reads given files content.
timfish marked this conversation as resolved.
Show resolved Hide resolved
*
* @param path A relative or absolute path to the file
* @returns A Promise that resolves when the file has been read.
*/
export async function writeFileAsync(
path: string,
data: Buffer | string,
options?: { encoding?: string; flag?: string },
): Promise<void> {
// We cannot use util.promisify here because that was only introduced in Node
// 8 and we need to support older Node versions.
timfish marked this conversation as resolved.
Show resolved Hide resolved
return new Promise((res, reject) => {
writeFile(path, data, options as any, (err: any) => {
if (err) {
reject(err);
} else {
res();
}
});
});
}

/**
* Asynchronously creates the given directory.
*
Expand Down
1 change: 1 addition & 0 deletions src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ export {
export { NodeOptions, NodeBackend, NodeClient, lastEventId } from '@sentry/node';

export { ElectronNetTransport } from './transports/electron-net';
export { ElectronOfflineNetTransport } from './transports/electron-offline-net';
export const Integrations = { ...ElectronMainIntegrations, ...NodeIntegrations };

export { init, ElectronMainOptions, defaultIntegrations } from './sdk';
Expand Down
86 changes: 5 additions & 81 deletions src/main/integrations/sentry-minidump/base-uploader.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,15 @@
import { API } from '@sentry/core';
import { NodeOptions } from '@sentry/node';
import { Event, Status, Transport } from '@sentry/types';
import { Event, Transport } from '@sentry/types';
import { isThenable, logger, SentryError, timestampWithMs } from '@sentry/utils';
import { basename, join } from 'path';
import { basename } from 'path';

import { mkdirp, readFileAsync, renameAsync, statAsync, unlinkAsync } from '../../fs';
import { Store } from '../../store';
import { readFileAsync, statAsync, unlinkAsync } from '../../fs';
import { ElectronNetTransport, SentryElectronRequest } from '../../transports/electron-net';

/** Maximum number of days to keep a minidump before deleting it. */
const MAX_AGE = 30;

/** Maximum number of requests that we store/queue if something goes wrong. */
const MAX_REQUESTS_COUNT = 10;

/**
* Payload for a minidump request comprising a persistent file system path and
* event metadata.
Expand All @@ -32,31 +28,20 @@ export abstract class BaseUploader {
/** List of minidumps that have been found already. */
private readonly _knownPaths: string[];

/**
* Store to persist queued Minidumps beyond application crashes or lost
* internet connection.
*/
private readonly _queue: Store<MinidumpRequest[]>;

/** API object */
private readonly _api: API;

/**
* Creates a new uploader instance.
*/
public constructor(
private readonly _options: NodeOptions,
private readonly _cacheDirectory: string,
private readonly _transport: Transport,
) {
public constructor(private readonly _options: NodeOptions, private readonly _transport: Transport) {
this._knownPaths = [];

if (!_options.dsn) {
throw new SentryError('Attempted to enable Electron native crash reporter but no DSN was supplied');
}

this._api = new API(_options.dsn);
this._queue = new Store(this._cacheDirectory, 'queue', []);
}

/**
Expand Down Expand Up @@ -90,14 +75,10 @@ export abstract class BaseUploader {

const transport = this._transport as ElectronNetTransport;
try {
let response;

if (request.event && !transport.isRateLimited('event')) {
logger.log('Sending minidump', request.path);

const requestForTransport = await this._toMinidumpRequest(transport, request.event, request.path);
response = await transport.sendRequest(requestForTransport);
logger.log('Minidump sent');
await transport.sendRequest(requestForTransport);
}

// We either succeeded, something went wrong or the send was aborted. Either way, we can remove the minidump file.
Expand All @@ -109,22 +90,10 @@ export abstract class BaseUploader {
}

// Forget this minidump in all caches
this._queue.update((queued) => queued.filter((stored) => stored !== request));
this._knownPaths.splice(this._knownPaths.indexOf(request.path), 1);

// If we were successful, we can try to flush the remaining queue
if (response && response.status === Status.Success) {
await this.flushQueue();
}
} catch (err) {
// TODO: Test this
logger.warn('Failed to upload minidump', err);

// User's internet connection was down so we queue it as well
const error = err ? (err as { code: string }) : { code: '' };
if (error.code === 'ENOTFOUND') {
await this._queueMinidump(request);
}
}
}

Expand Down Expand Up @@ -164,11 +133,6 @@ export abstract class BaseUploader {
});
}

/** Flushes locally cached minidumps from the queue. */
public async flushQueue(): Promise<void> {
await Promise.all(this._queue.get().map(async (request) => this.uploadMinidump(request)));
}

/**
* Helper to filter an array with asynchronous callbacks.
*
Expand All @@ -186,46 +150,6 @@ export abstract class BaseUploader {
return array.filter((_, index) => verdicts[index]);
}

/**
* Enqueues a minidump with event information for later upload.
* @param request The request containing a minidump and event info.
*/
private async _queueMinidump(request: MinidumpRequest): Promise<void> {
const filename = basename(request.path);

// Only enqueue if this minidump hasn't been enqueued before. Compare the
// filename instead of the full path, because we will move the file to a
// temporary location later on.
if (this._queue.get().some((req) => basename(req.path) === filename)) {
return;
}

// Move the minidump file to a separate cache directory and enqueue it. Even
// if the Electron CrashReporter's cache directory gets wiped or changes,
// this will allow us to retry uploading the file later.
const queuePath = join(this._cacheDirectory, filename);
await mkdirp(this._cacheDirectory);
await renameAsync(request.path, queuePath);

// Remove stale minidumps in case we go over limit. Note that we have to
// re-fetch the queue as it might have changed in the meanwhile. It is
// important to store the queue value again immediately to avoid phantom
// reads.
const requests = [...this._queue.get(), { ...request, path: queuePath }];
const stale = requests.splice(-MAX_REQUESTS_COUNT);
this._queue.set(requests);

await Promise.all(
stale.map(async (req) => {
try {
await unlinkAsync(req.path);
} catch (e) {
logger.warn('Could not delete', req.path);
}
}),
);
}

/**
* Create minidump request to dispatch to the transport
*/
Expand Down
4 changes: 2 additions & 2 deletions src/main/integrations/sentry-minidump/breakpad-uploader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@ import { BaseUploader } from './base-uploader';
export class BreakpadUploader extends BaseUploader {
private readonly _crashesDirectory: string = getCrashesDirectory();

public constructor(options: NodeOptions, cacheDirectory: string, transport: Transport) {
super(options, cacheDirectory, transport);
public constructor(options: NodeOptions, transport: Transport) {
super(options, transport);
}

/** @inheritdoc */
Expand Down
4 changes: 2 additions & 2 deletions src/main/integrations/sentry-minidump/crashpad-uploader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,8 @@ export class CrashpadUploader extends BaseUploader {
/** The sub-directory where crashpad dumps can be found */
private readonly _crashpadSubDirectory: string;

public constructor(options: NodeOptions, cacheDirectory: string, transport: Transport) {
super(options, cacheDirectory, transport);
public constructor(options: NodeOptions, transport: Transport) {
super(options, transport);
this._crashpadSubDirectory = process.platform === 'win32' ? 'reports' : 'completed';
}

Expand Down
14 changes: 4 additions & 10 deletions src/main/integrations/sentry-minidump/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ import { NodeClient } from '@sentry/node';
import { Event, Integration, Severity } from '@sentry/types';
import { forget, isPlainObject, isThenable, logger, SentryError } from '@sentry/utils';
import { app, crashReporter } from 'electron';
import { join } from 'path';

import { onRendererProcessGone, usesCrashpad } from '../../electron-normalize';
import { mergeEvents, normalizeUrl } from '../../../common';
Expand All @@ -15,6 +14,7 @@ import { CrashpadUploader } from './crashpad-uploader';
import { Store } from '../../store';
import { getEventDefaults } from '../../context';
import { checkPreviousSession, sessionCrashed } from '../../sessions';
import { sentryCachePath } from '../../fs';

/** Sends minidumps via the Sentry uploader.. */
export class SentryMinidump implements Integration {
Expand All @@ -33,9 +33,6 @@ export class SentryMinidump implements Integration {
/** Uploader for minidump files. */
private _uploader?: BaseUploader;

/** The path to the Sentry cache directory. */
private readonly _cachePath: string = join(app.getPath('userData'), 'sentry');

/** @inheritDoc */
public setupOnce(): void {
// Mac AppStore builds cannot run the crash reporter due to the sandboxing
Expand All @@ -47,7 +44,7 @@ export class SentryMinidump implements Integration {

this._startCrashReporter();

this._scopeStore = new Store<Scope>(this._cachePath, 'scope_v2', new Scope());
this._scopeStore = new Store<Scope>(sentryCachePath, 'scope_v2', new Scope());
// We need to store the scope in a variable here so it can be attached to minidumps
this._scopeLastRun = this._scopeStore.get();

Expand All @@ -64,15 +61,12 @@ export class SentryMinidump implements Integration {
const transport = (client as any)._getBackend().getTransport() as ElectronNetTransport;

this._uploader = usesCrashpad()
? new CrashpadUploader(options, this._cachePath, transport)
: new BreakpadUploader(options, this._cachePath, transport);
? new CrashpadUploader(options, transport)
: new BreakpadUploader(options, transport);

// Every time a subprocess or renderer crashes, send a minidump right away.
onRendererProcessGone((contents, details) => this._sendRendererCrash(options, contents, details));

// Flush already cached minidumps from the queue.
forget(this._uploader.flushQueue());

// Start to submit recent minidump crashes. This will load breadcrumbs and
// context information that was cached on disk prior to the crash.
forget(
Expand Down
4 changes: 2 additions & 2 deletions src/main/sdk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import {
SentryMinidump,
} from './integrations';
import { configureIPC } from './ipc';
import { ElectronNetTransport } from './transports/electron-net';
import { ElectronOfflineNetTransport } from './transports/electron-offline-net';

export const defaultIntegrations: Integration[] = [
new SentryMinidump(),
Expand Down Expand Up @@ -86,7 +86,7 @@ export function init(partialOptions: Partial<ElectronMainOptions>): void {
setDefaultIntegrations(defaults, options);

if (options.dsn && options.transport === undefined) {
options.transport = ElectronNetTransport;
options.transport = ElectronOfflineNetTransport;
}

configureIPC(options);
Expand Down
9 changes: 2 additions & 7 deletions src/main/sessions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,20 +2,15 @@ import { getCurrentHub } from '@sentry/core';
import { flush, NodeClient } from '@sentry/node';
import { SessionContext, SessionStatus } from '@sentry/types';
import { logger } from '@sentry/utils';
import { app } from 'electron';
import { join } from 'path';

import { sentryCachePath } from './fs';
import { Store } from './store';
import { ElectronNetTransport } from './transports/electron-net';

const PERSIST_INTERVAL_MS = 60_000;

/** Stores the app session in case of termination due to main process crash or app killed */
const sessionStore = new Store<SessionContext | undefined>(
join(app.getPath('userData'), 'sentry'),
'session',
undefined,
);
const sessionStore = new Store<SessionContext | undefined>(sentryCachePath, 'session', undefined);

/** Previous session that did not exit cleanly */
let previousSession = sessionStore.get();
Expand Down
15 changes: 14 additions & 1 deletion src/main/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,17 @@ import { dirname, join } from 'path';

import { mkdirpSync } from './fs';

const dateFormat = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.*\d{0,10}Z$/;

/** JSON revive function to enable de-serialization of Date objects */
function dateReviver(_: string, value: any): any {
AbhiPrasad marked this conversation as resolved.
Show resolved Hide resolved
if (typeof value === 'string' && dateFormat.test(value)) {
return new Date(value);
}

return value;
}

/**
* Note, this class is only compatible with Node.
* Lazily serializes data to a JSON file to persist. When created, it loads data
Expand Down Expand Up @@ -70,7 +81,9 @@ export class Store<T> {
public get(): T {
if (this._data === undefined) {
try {
this._data = existsSync(this._path) ? (JSON.parse(readFileSync(this._path, 'utf8')) as T) : this._initial;
this._data = existsSync(this._path)
? (JSON.parse(readFileSync(this._path, 'utf8'), dateReviver) as T)
: this._initial;
} catch (e) {
this._data = this._initial;
}
Expand Down
Loading