mirror of
https://github.com/balena-io/etcher.git
synced 2025-07-29 06:06:33 +00:00
Merge pull request #3891 from balena-io/removes-corvus
Removes corvus in favor of sentry and analytics client
This commit is contained in:
commit
7616c41564
30
.github/actions/publish/action.yml
vendored
30
.github/actions/publish/action.yml
vendored
@ -72,32 +72,6 @@ runs:
|
||||
chmod +x "$(dirname "$(which node)")/resinci-deploy" && which resinci-deploy
|
||||
fi
|
||||
|
||||
# FIXME: store sentry workflow is not documented
|
||||
# https://github.com/product-os/resinci-deploy/blob/master/lib/sentry.ts
|
||||
# https://github.com/getsentry/sentry-cli
|
||||
# https://docs.sentry.io/api/projects/create-a-new-client-key/
|
||||
- name: Generate Sentry DSN
|
||||
id: sentry
|
||||
shell: bash --noprofile --norc -eo pipefail -x {0}
|
||||
run: |
|
||||
set -ea
|
||||
|
||||
[[ '${{ inputs.VERBOSE }}' =~ on|On|Yes|yes|true|True ]] && set -x
|
||||
|
||||
branch="$(echo '${{ github.event.pull_request.head.ref }}' | sed 's/[^[:alnum:]]/-/g')"
|
||||
|
||||
stdout="$(resinci-deploy store sentry \
|
||||
--branch="${branch}" \
|
||||
--name="$(jq -r '.name' package.json)" \
|
||||
--team="$(yq e '.sentry.team' repo.yml)" \
|
||||
--org="$(yq e '.sentry.org' repo.yml)" \
|
||||
--type="$(yq e '.sentry.type' repo.yml)")"
|
||||
|
||||
echo "dsn=$(echo "${stdout}" | tail -n 1)" >> $GITHUB_OUTPUT
|
||||
|
||||
env:
|
||||
SENTRY_TOKEN: ${{ fromJSON(inputs.secrets).SENTRY_AUTH_TOKEN }}
|
||||
|
||||
# https://www.electron.build/code-signing.html
|
||||
# https://github.com/Apple-Actions/import-codesign-certs
|
||||
- name: Import Apple code signing certificate
|
||||
@ -171,8 +145,8 @@ runs:
|
||||
|
||||
for target in ${TARGETS}; do
|
||||
electron-builder ${ELECTRON_BUILDER_OS} ${target} ${ARCHITECTURE_FLAGS} \
|
||||
--c.extraMetadata.analytics.sentry.token='${{ steps.sentry.outputs.dsn }}' \
|
||||
--c.extraMetadata.analytics.mixpanel.token='balena-etcher' \
|
||||
--c.extraMetadata.analytics.sentry.token='https://739bbcfc0ba4481481138d3fc831136d@o95242.ingest.sentry.io/4504451487301632' \
|
||||
--c.extraMetadata.analytics.amplitude.token='balena-etcher' \
|
||||
--c.extraMetadata.packageType="${target}"
|
||||
|
||||
find dist -type f -maxdepth 1
|
||||
|
@ -31,7 +31,7 @@ Releasing
|
||||
- [Post release note to forums](https://forums.balena.io/c/etcher)
|
||||
- [Submit Windows binaries to Symantec for whitelisting](#submitting-binaries-to-symantec)
|
||||
- [Update the website](https://github.com/balena-io/etcher-homepage)
|
||||
- Wait 2-3 hours for analytics (Sentry, Mixpanel) to trickle in and check for elevated error rates, or regressions
|
||||
- Wait 2-3 hours for analytics (Sentry, Amplitude) to trickle in and check for elevated error rates, or regressions
|
||||
- If regressions arise; pull the release, and release a patched version, else:
|
||||
- [Upload deb & rpm packages to Bintray](#uploading-packages-to-bintray)
|
||||
- [Upload build artifacts to Amazon S3](#uploading-binaries-to-amazon-s3)
|
||||
@ -48,7 +48,7 @@ Make sure to set the analytics tokens when generating production release binarie
|
||||
|
||||
```bash
|
||||
export ANALYTICS_SENTRY_TOKEN="xxxxxx"
|
||||
export ANALYTICS_MIXPANEL_TOKEN="xxxxxx"
|
||||
export ANALYTICS_AMPLITUDE_TOKEN="xxxxxx"
|
||||
```
|
||||
|
||||
#### Linux
|
||||
|
@ -112,4 +112,4 @@ Analytics
|
||||
- [ ] Disable analytics, open DevTools Network pane or a packet sniffer, and
|
||||
check that no request is sent
|
||||
- [ ] **Disable analytics, refresh application from DevTools (using Cmd-R or
|
||||
F5), and check that initial events are not sent to Mixpanel**
|
||||
F5), and check that initial events are not sent to Amplitude**
|
||||
|
@ -296,6 +296,8 @@ driveScanner.start();
|
||||
|
||||
let popupExists = false;
|
||||
|
||||
analytics.initAnalytics();
|
||||
|
||||
window.addEventListener('beforeunload', async (event) => {
|
||||
if (!flashState.isFlashing() || popupExists) {
|
||||
analytics.logEvent('Close application', {
|
||||
|
@ -183,7 +183,12 @@ export class SafeWebview extends React.PureComponent<
|
||||
// only care about this event if it's a request for the main frame
|
||||
if (event.resourceType === 'mainFrame') {
|
||||
const HTTP_OK = 200;
|
||||
analytics.logEvent('SafeWebview loaded', { event });
|
||||
const { webContents, ...webviewEvent } = event;
|
||||
analytics.logEvent('SafeWebview loaded', {
|
||||
...webviewEvent,
|
||||
screen_height: webContents?.hostWebContents.browserWindowOptions.height,
|
||||
screen_width: webContents?.hostWebContents.browserWindowOptions.width,
|
||||
});
|
||||
this.setState({
|
||||
shouldShow: event.statusCode === HTTP_OK,
|
||||
});
|
||||
|
@ -15,84 +15,188 @@
|
||||
*/
|
||||
|
||||
import * as _ from 'lodash';
|
||||
import * as resinCorvus from 'resin-corvus/browser';
|
||||
|
||||
import * as packageJSON from '../../../../package.json';
|
||||
import { getConfig } from '../../../shared/utils';
|
||||
import { Client, createClient, createNoopClient } from 'analytics-client';
|
||||
import * as SentryRenderer from '@sentry/electron/renderer';
|
||||
import * as settings from '../models/settings';
|
||||
import { store } from '../models/store';
|
||||
import * as packageJSON from '../../../../package.json';
|
||||
|
||||
const DEFAULT_PROBABILITY = 0.1;
|
||||
type AnalyticsPayload = _.Dictionary<any>;
|
||||
|
||||
async function installCorvus(): Promise<void> {
|
||||
const sentryToken =
|
||||
(await settings.get('analyticsSentryToken')) ||
|
||||
_.get(packageJSON, ['analytics', 'sentry', 'token']);
|
||||
const mixpanelToken =
|
||||
(await settings.get('analyticsMixpanelToken')) ||
|
||||
_.get(packageJSON, ['analytics', 'mixpanel', 'token']);
|
||||
resinCorvus.install({
|
||||
services: {
|
||||
sentry: sentryToken,
|
||||
mixpanel: mixpanelToken,
|
||||
},
|
||||
options: {
|
||||
release: packageJSON.version,
|
||||
shouldReport: () => {
|
||||
return settings.getSync('errorReporting');
|
||||
},
|
||||
mixpanelDeferred: true,
|
||||
},
|
||||
const clearUserPath = (filename: string): string => {
|
||||
const generatedFile = filename.split('generated').reverse()[0];
|
||||
return generatedFile !== filename ? `generated${generatedFile}` : filename;
|
||||
};
|
||||
|
||||
export const anonymizeSentryData = (
|
||||
event: SentryRenderer.Event,
|
||||
): SentryRenderer.Event => {
|
||||
event.exception?.values?.forEach((exception) => {
|
||||
exception.stacktrace?.frames?.forEach((frame) => {
|
||||
if (frame.filename) {
|
||||
frame.filename = clearUserPath(frame.filename);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
let mixpanelSample = DEFAULT_PROBABILITY;
|
||||
event.breadcrumbs?.forEach((breadcrumb) => {
|
||||
if (breadcrumb.data?.url) {
|
||||
breadcrumb.data.url = clearUserPath(breadcrumb.data.url);
|
||||
}
|
||||
});
|
||||
|
||||
if (event.request?.url) {
|
||||
event.request.url = clearUserPath(event.request.url);
|
||||
}
|
||||
|
||||
return event;
|
||||
};
|
||||
|
||||
const extractPathRegex = /(.*)(^|\s)(file\:\/\/)?(\w\:)?([\\\/].+)/;
|
||||
const etcherSegmentMarkers = ['app.asar', 'Resources'];
|
||||
|
||||
export const anonymizePath = (input: string) => {
|
||||
// First, extract a part of the value that matches a path pattern.
|
||||
const match = extractPathRegex.exec(input);
|
||||
if (match === null) {
|
||||
return input;
|
||||
}
|
||||
const mainPart = match[5];
|
||||
const space = match[2];
|
||||
const beginning = match[1];
|
||||
const uriPrefix = match[3] || '';
|
||||
|
||||
// We have to deal with both Windows and POSIX here.
|
||||
// The path starts with its separator (we work with absolute paths).
|
||||
const sep = mainPart[0];
|
||||
const segments = mainPart.split(sep);
|
||||
|
||||
// Moving from the end, find the first marker and cut the path from there.
|
||||
const startCutIndex = _.findLastIndex(segments, (segment) =>
|
||||
etcherSegmentMarkers.includes(segment),
|
||||
);
|
||||
return (
|
||||
beginning +
|
||||
space +
|
||||
uriPrefix +
|
||||
'[PERSONAL PATH]' +
|
||||
sep +
|
||||
segments.splice(startCutIndex).join(sep)
|
||||
);
|
||||
};
|
||||
|
||||
const safeAnonymizePath = (input: string) => {
|
||||
try {
|
||||
return anonymizePath(input);
|
||||
} catch (e) {
|
||||
return '[ANONYMIZE PATH FAILED]';
|
||||
}
|
||||
};
|
||||
|
||||
const sensitiveEtcherProperties = [
|
||||
'error.description',
|
||||
'error.message',
|
||||
'error.stack',
|
||||
'image',
|
||||
'image.path',
|
||||
'path',
|
||||
];
|
||||
|
||||
export const anonymizeAnalyticsPayload = (
|
||||
data: AnalyticsPayload,
|
||||
): AnalyticsPayload => {
|
||||
for (const prop of sensitiveEtcherProperties) {
|
||||
const value = data[prop];
|
||||
if (value != null) {
|
||||
data[prop] = safeAnonymizePath(value.toString());
|
||||
}
|
||||
}
|
||||
return data;
|
||||
};
|
||||
|
||||
let analyticsClient: Client;
|
||||
/**
|
||||
* @summary Init analytics configurations
|
||||
*/
|
||||
async function initConfig() {
|
||||
await installCorvus();
|
||||
let validatedConfig = null;
|
||||
try {
|
||||
const configUrl = await settings.get('configUrl');
|
||||
const config = await getConfig(configUrl);
|
||||
const mixpanel = _.get(config, ['analytics', 'mixpanel'], {});
|
||||
mixpanelSample = mixpanel.probability || DEFAULT_PROBABILITY;
|
||||
if (isClientEligible(mixpanelSample)) {
|
||||
validatedConfig = validateMixpanelConfig(mixpanel);
|
||||
}
|
||||
} catch (err) {
|
||||
resinCorvus.logException(err);
|
||||
}
|
||||
resinCorvus.setConfigs({
|
||||
mixpanel: validatedConfig,
|
||||
});
|
||||
}
|
||||
export const initAnalytics = _.once(() => {
|
||||
const dsn =
|
||||
settings.getSync('analyticsSentryToken') ||
|
||||
_.get(packageJSON, ['analytics', 'sentry', 'token']);
|
||||
SentryRenderer.init({ dsn, beforeSend: anonymizeSentryData });
|
||||
|
||||
initConfig();
|
||||
const projectName =
|
||||
settings.getSync('analyticsAmplitudeToken') ||
|
||||
_.get(packageJSON, ['analytics', 'amplitude', 'token']);
|
||||
|
||||
/**
|
||||
* @summary Check that the client is eligible for analytics
|
||||
*/
|
||||
function isClientEligible(probability: number) {
|
||||
return Math.random() < probability;
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Check that config has at least HTTP_PROTOCOL and api_host
|
||||
*/
|
||||
function validateMixpanelConfig(config: {
|
||||
api_host?: string;
|
||||
HTTP_PROTOCOL?: string;
|
||||
}) {
|
||||
const mixpanelConfig = {
|
||||
api_host: 'https://api.mixpanel.com',
|
||||
const clientConfig = {
|
||||
projectName,
|
||||
endpoint: 'data.balena-cloud.com',
|
||||
componentName: 'etcher',
|
||||
componentVersion: packageJSON.version,
|
||||
};
|
||||
if (config.HTTP_PROTOCOL !== undefined && config.api_host !== undefined) {
|
||||
mixpanelConfig.api_host = `${config.HTTP_PROTOCOL}://${config.api_host}`;
|
||||
analyticsClient = projectName
|
||||
? createClient(clientConfig)
|
||||
: createNoopClient();
|
||||
});
|
||||
|
||||
const getCircularReplacer = () => {
|
||||
const seen = new WeakSet();
|
||||
return (key: any, value: any) => {
|
||||
if (typeof value === 'object' && value !== null) {
|
||||
if (seen.has(value)) {
|
||||
return;
|
||||
}
|
||||
seen.add(value);
|
||||
}
|
||||
return value;
|
||||
};
|
||||
};
|
||||
|
||||
function flattenObject(obj: any) {
|
||||
const toReturn: AnalyticsPayload = {};
|
||||
|
||||
for (const i in obj) {
|
||||
if (!obj.hasOwnProperty(i)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (Array.isArray(obj[i])) {
|
||||
toReturn[i] = obj[i];
|
||||
continue;
|
||||
}
|
||||
|
||||
if (typeof obj[i] === 'object' && obj[i] !== null) {
|
||||
const flatObject = flattenObject(obj[i]);
|
||||
for (const x in flatObject) {
|
||||
if (!flatObject.hasOwnProperty(x)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
toReturn[i.toLowerCase() + '.' + x.toLowerCase()] = flatObject[x];
|
||||
}
|
||||
} else {
|
||||
toReturn[i] = obj[i];
|
||||
}
|
||||
}
|
||||
return mixpanelConfig;
|
||||
return toReturn;
|
||||
}
|
||||
|
||||
function formatEvent(data: any): AnalyticsPayload {
|
||||
const event = JSON.parse(JSON.stringify(data, getCircularReplacer()));
|
||||
return anonymizeAnalyticsPayload(flattenObject(event));
|
||||
}
|
||||
|
||||
function reportAnalytics(message: string, data: AnalyticsPayload = {}) {
|
||||
const { applicationSessionUuid, flashingWorkflowUuid } = store
|
||||
.getState()
|
||||
.toJS();
|
||||
|
||||
const event = formatEvent({
|
||||
...data,
|
||||
applicationSessionUuid,
|
||||
flashingWorkflowUuid,
|
||||
});
|
||||
analyticsClient.track(message, event);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -101,16 +205,12 @@ function validateMixpanelConfig(config: {
|
||||
* @description
|
||||
* This function sends the debug message to product analytics services.
|
||||
*/
|
||||
export function logEvent(message: string, data: _.Dictionary<any> = {}) {
|
||||
const { applicationSessionUuid, flashingWorkflowUuid } = store
|
||||
.getState()
|
||||
.toJS();
|
||||
resinCorvus.logEvent(message, {
|
||||
...data,
|
||||
sample: mixpanelSample,
|
||||
applicationSessionUuid,
|
||||
flashingWorkflowUuid,
|
||||
});
|
||||
export async function logEvent(message: string, data: AnalyticsPayload = {}) {
|
||||
const shouldReportAnalytics = await settings.get('errorReporting');
|
||||
if (shouldReportAnalytics) {
|
||||
initAnalytics();
|
||||
reportAnalytics(message, data);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@ -119,4 +219,11 @@ export function logEvent(message: string, data: _.Dictionary<any> = {}) {
|
||||
* @description
|
||||
* This function logs an exception to error reporting services.
|
||||
*/
|
||||
export const logException = resinCorvus.logException;
|
||||
export function logException(error: any) {
|
||||
const shouldReportErrors = settings.getSync('errorReporting');
|
||||
if (shouldReportErrors) {
|
||||
initAnalytics();
|
||||
console.error(error);
|
||||
SentryRenderer.captureException(error);
|
||||
}
|
||||
}
|
||||
|
@ -20,6 +20,7 @@ import { promises as fs } from 'fs';
|
||||
import { platform } from 'os';
|
||||
import * as path from 'path';
|
||||
import * as semver from 'semver';
|
||||
import * as _ from 'lodash';
|
||||
|
||||
import './app/i18n';
|
||||
|
||||
@ -27,9 +28,11 @@ import { packageType, version } from '../../package.json';
|
||||
import * as EXIT_CODES from '../shared/exit-codes';
|
||||
import { delay, getConfig } from '../shared/utils';
|
||||
import * as settings from './app/models/settings';
|
||||
import { logException } from './app/modules/analytics';
|
||||
import { buildWindowMenu } from './menu';
|
||||
import * as i18n from 'i18next';
|
||||
import * as SentryMain from '@sentry/electron/main';
|
||||
import * as packageJSON from '../../package.json';
|
||||
import { anonymizeSentryData } from './app/modules/analytics';
|
||||
|
||||
const customProtocol = 'etcher';
|
||||
const scheme = `${customProtocol}://`;
|
||||
@ -53,13 +56,21 @@ async function checkForUpdates(interval: number) {
|
||||
packageUpdated = true;
|
||||
}
|
||||
} catch (err) {
|
||||
logException(err);
|
||||
logMainProcessException(err);
|
||||
}
|
||||
}
|
||||
await delay(interval);
|
||||
}
|
||||
}
|
||||
|
||||
function logMainProcessException(error: any) {
|
||||
const shouldReportErrors = settings.getSync('errorReporting');
|
||||
if (shouldReportErrors) {
|
||||
console.error(error);
|
||||
SentryMain.captureException(error);
|
||||
}
|
||||
}
|
||||
|
||||
async function isFile(filePath: string): Promise<boolean> {
|
||||
try {
|
||||
const stat = await fs.stat(filePath);
|
||||
@ -94,6 +105,14 @@ async function getCommandLineURL(argv: string[]): Promise<string | undefined> {
|
||||
}
|
||||
}
|
||||
|
||||
const initSentryMain = _.once(() => {
|
||||
const dsn =
|
||||
settings.getSync('analyticsSentryToken') ||
|
||||
_.get(packageJSON, ['analytics', 'sentry', 'token']);
|
||||
|
||||
SentryMain.init({ dsn, beforeSend: anonymizeSentryData });
|
||||
});
|
||||
|
||||
const sourceSelectorReady = new Promise((resolve) => {
|
||||
electron.ipcMain.on('source-selector-ready', resolve);
|
||||
});
|
||||
@ -190,8 +209,9 @@ async function createMainWindow() {
|
||||
const page = mainWindow.webContents;
|
||||
|
||||
page.once('did-frame-finish-load', async () => {
|
||||
console.log('packageUpdatable', packageUpdatable);
|
||||
autoUpdater.on('error', (err) => {
|
||||
logException(err);
|
||||
logMainProcessException(err);
|
||||
});
|
||||
if (packageUpdatable) {
|
||||
try {
|
||||
@ -208,7 +228,7 @@ async function createMainWindow() {
|
||||
onlineConfig?.autoUpdates?.checkForUpdatesTimer ?? 300000;
|
||||
checkForUpdates(checkForUpdatesTimer);
|
||||
} catch (err) {
|
||||
logException(err);
|
||||
logMainProcessException(err);
|
||||
}
|
||||
}
|
||||
});
|
||||
@ -233,6 +253,7 @@ async function main(): Promise<void> {
|
||||
if (!electron.app.requestSingleInstanceLock()) {
|
||||
electron.app.quit();
|
||||
} else {
|
||||
initSentryMain();
|
||||
await electron.app.whenReady();
|
||||
const window = await createMainWindow();
|
||||
electron.app.on('second-instance', async (_event, argv) => {
|
||||
@ -256,7 +277,6 @@ async function main(): Promise<void> {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
|
||||
console.time('ready-to-show');
|
||||
|
1122
package-lock.json
generated
1122
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -53,6 +53,7 @@
|
||||
"@balena/lint": "5.4.2",
|
||||
"@balena/sudo-prompt": "9.2.1-workaround-windows-amperstand-in-username-0849e215b947987a643fe5763902aea201255534",
|
||||
"@fortawesome/fontawesome-free": "5.15.4",
|
||||
"@sentry/electron": "^4.1.2",
|
||||
"@svgr/webpack": "5.5.0",
|
||||
"@types/chai": "4.3.4",
|
||||
"@types/copy-webpack-plugin": "6.4.3",
|
||||
@ -68,6 +69,7 @@
|
||||
"@types/terser-webpack-plugin": "5.0.4",
|
||||
"@types/tmp": "0.2.3",
|
||||
"@types/webpack-node-externals": "2.5.3",
|
||||
"analytics-client": "^2.0.1",
|
||||
"aws4-axios": "2.4.9",
|
||||
"chai": "4.3.7",
|
||||
"copy-webpack-plugin": "7.0.0",
|
||||
@ -102,7 +104,6 @@
|
||||
"react-i18next": "11.18.6",
|
||||
"redux": "4.2.0",
|
||||
"rendition": "19.3.2",
|
||||
"resin-corvus": "2.0.5",
|
||||
"semver": "7.3.8",
|
||||
"simple-progress-webpack-plugin": "1.1.2",
|
||||
"sinon": "9.2.4",
|
||||
|
@ -29,7 +29,7 @@ import * as PnpWebpackPlugin from 'pnp-webpack-plugin';
|
||||
import * as tsconfigRaw from './tsconfig.webpack.json';
|
||||
|
||||
/**
|
||||
* Don't webpack package.json as mixpanel & sentry tokens
|
||||
* Don't webpack package.json as sentry tokens
|
||||
* will be inserted in it after webpacking
|
||||
*/
|
||||
function externalPackageJson(packageJsonPath: string) {
|
||||
|
Loading…
x
Reference in New Issue
Block a user