Skip to content

Commit

Permalink
Merge pull request #3891 from balena-io/removes-corvus
Browse files Browse the repository at this point in the history
Removes corvus in favor of sentry and analytics client
  • Loading branch information
balena-ci committed Jan 16, 2023
2 parents d5ba1ea + 86d43a5 commit 7616c41
Show file tree
Hide file tree
Showing 10 changed files with 747 additions and 706 deletions.
30 changes: 2 additions & 28 deletions .github/actions/publish/action.yml
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions docs/MAINTAINERS.md
Expand Up @@ -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)
Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion docs/MANUAL-TESTING.md
Expand Up @@ -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**
2 changes: 2 additions & 0 deletions lib/gui/app/app.ts
Expand Up @@ -296,6 +296,8 @@ driveScanner.start();

let popupExists = false;

analytics.initAnalytics();

window.addEventListener('beforeunload', async (event) => {
if (!flashState.isFlashing() || popupExists) {
analytics.logEvent('Close application', {
Expand Down
7 changes: 6 additions & 1 deletion lib/gui/app/components/safe-webview/safe-webview.tsx
Expand Up @@ -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,
});
Expand Down
251 changes: 179 additions & 72 deletions lib/gui/app/modules/analytics.ts
Expand Up @@ -15,102 +15,202 @@
*/

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);
}
});
});

event.breadcrumbs?.forEach((breadcrumb) => {
if (breadcrumb.data?.url) {
breadcrumb.data.url = clearUserPath(breadcrumb.data.url);
}
});
}

let mixpanelSample = DEFAULT_PROBABILITY;
if (event.request?.url) {
event.request.url = clearUserPath(event.request.url);
}

/**
* @summary Init analytics configurations
*/
async function initConfig() {
await installCorvus();
let validatedConfig = null;
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 {
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);
return anonymizePath(input);
} catch (e) {
return '[ANONYMIZE PATH FAILED]';
}
resinCorvus.setConfigs({
mixpanel: validatedConfig,
});
}
};

initConfig();
const sensitiveEtcherProperties = [
'error.description',
'error.message',
'error.stack',
'image',
'image.path',
'path',
];

/**
* @summary Check that the client is eligible for analytics
*/
function isClientEligible(probability: number) {
return Math.random() < probability;
}
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 Check that config has at least HTTP_PROTOCOL and api_host
* @summary Init analytics configurations
*/
function validateMixpanelConfig(config: {
api_host?: string;
HTTP_PROTOCOL?: string;
}) {
const mixpanelConfig = {
api_host: 'https://api.mixpanel.com',
export const initAnalytics = _.once(() => {
const dsn =
settings.getSync('analyticsSentryToken') ||
_.get(packageJSON, ['analytics', 'sentry', 'token']);
SentryRenderer.init({ dsn, beforeSend: anonymizeSentryData });

const projectName =
settings.getSync('analyticsAmplitudeToken') ||
_.get(packageJSON, ['analytics', 'amplitude', 'token']);

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;
}

/**
* @summary Log an event
*
* @description
* This function sends the debug message to product analytics services.
*/
export function logEvent(message: string, data: _.Dictionary<any> = {}) {
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();
resinCorvus.logEvent(message, {

const event = formatEvent({
...data,
sample: mixpanelSample,
applicationSessionUuid,
flashingWorkflowUuid,
});
analyticsClient.track(message, event);
}

/**
* @summary Log an event
*
* @description
* This function sends the debug message to product analytics services.
*/
export async function logEvent(message: string, data: AnalyticsPayload = {}) {
const shouldReportAnalytics = await settings.get('errorReporting');
if (shouldReportAnalytics) {
initAnalytics();
reportAnalytics(message, data);
}
}

/**
Expand All @@ -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);
}
}

0 comments on commit 7616c41

Please sign in to comment.