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

Removes corvus in favor of sentry and analytics client #3891

Merged
merged 6 commits into from Jan 16, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
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' \
aethernet marked this conversation as resolved.
Show resolved Hide resolved
--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)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Apparently that repo is now read-only ?

- 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)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LOL, and IIRC you now use Cloudsmith instead of 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);
}
aethernet marked this conversation as resolved.
Show resolved Hide resolved
}