Skip to content
This repository has been archived by the owner on Dec 15, 2022. It is now read-only.

Integrate metrics #1562

Merged
merged 24 commits into from Jul 9, 2018
Merged
Show file tree
Hide file tree
Changes from 17 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
3 changes: 3 additions & 0 deletions lib/containers/remote-container.js
Expand Up @@ -2,6 +2,7 @@ import React from 'react';
import PropTypes from 'prop-types';
import {QueryRenderer, graphql} from 'react-relay';

import {incrementCounter} from '../reporter-proxy';
import {autobind} from '../helpers';
import {RemotePropType, BranchSetPropType, OperationStateObserverPropType} from '../prop-types';
import RelayNetworkLayerManager from '../relay-network-layer-manager';
Expand Down Expand Up @@ -132,10 +133,12 @@ export default class RemoteContainer extends React.Component {
}

handleLogin(token) {
incrementCounter('github-login');
this.props.loginModel.setToken(this.props.host, token);
}

handleLogout() {
incrementCounter('github-logout');
this.props.loginModel.removeToken(this.props.host);
}
}
14 changes: 9 additions & 5 deletions lib/controllers/remote-controller.js
Expand Up @@ -3,6 +3,7 @@ import PropTypes from 'prop-types';
import {shell} from 'electron';

import {autobind} from '../helpers';
import {incrementCounter} from '../reporter-proxy';
import {RemotePropType, BranchSetPropType, OperationStateObserverPropType} from '../prop-types';
import IssueishSearchesController from './issueish-searches-controller';

Expand Down Expand Up @@ -69,10 +70,13 @@ export default class RemoteController extends React.Component {
createPrUrl += '/compare/' + encodeURIComponent(currentBranch.getName());
createPrUrl += '?expand=1';

return new Promise((resolve, reject) => {
shell.openExternal(createPrUrl, {}, err => {
if (err) { reject(err); } else { resolve(); }
});
});
let returnPromise;
Copy link
Contributor Author

Choose a reason for hiding this comment

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

@smashwilson, does this look right to you?

I was having trouble getting the inner promise to return for the test. Other than adding the counter, this should behave identically.

Copy link
Contributor

Choose a reason for hiding this comment

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

It looks like openExternal uses a callback instead of returning a Promise, at least on macOS:

callback Function (optional) macOS - If specified will perform the open asynchronously.

(From what I can tell, on other platforms, the callback will be called synchronously instead.)

Returns Boolean - Whether an application was available to open the URL. If callback is specified, always returns true.

Try putting the incrementCounter() call inside the if statement instead:

return new Promise((resolve, reject) => {
  shell.openExternal(createPrUrl, {}, err => {
    if (err) {
      reject(err);
    } else {
      incrementCounter('create-pull-request');
      resolve();
    }
  });
});

Copy link
Contributor Author

Choose a reason for hiding this comment

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

that was the first thing I tried, and the promise never returned in the test.

I also tried using a .done() callback in the test and the promise still did not return.

Lemmesee if I can figure out a different approach.

try {
returnPromise = await shell.openExternal(createPrUrl);
incrementCounter('create-pull-request');
} catch (err) {
returnPromise = Promise.reject(err);
}
return returnPromise;
}
}
3 changes: 3 additions & 0 deletions lib/controllers/root-controller.js
Expand Up @@ -26,6 +26,7 @@ import Conflict from '../models/conflicts/conflict';
import Switchboard from '../switchboard';
import {destroyFilePatchPaneItems, destroyEmptyFilePatchPaneItems, autobind} from '../helpers';
import {GitError} from '../git-shell-out-strategy';
import {incrementCounter} from '../reporter-proxy';

export default class RootController extends React.Component {
static propTypes = {
Expand Down Expand Up @@ -788,10 +789,12 @@ class TabTracker {
}

reveal() {
incrementCounter(`${this.name}-tab-open`);
return this.getWorkspace().open(this.uri, {searchAllPanes: true, activateItem: true, activatePane: true});
}

hide() {
incrementCounter(`${this.name}-tab-close`);
return this.getWorkspace().hide(this.uri);
}

Expand Down
11 changes: 10 additions & 1 deletion lib/git-shell-out-strategy.js
Expand Up @@ -13,6 +13,7 @@ import {parse as parseStatus} from 'what-the-status';
import GitPromptServer from './git-prompt-server';
import GitTempDir from './git-temp-dir';
import AsyncQueue from './async-queue';
import {incrementCounter} from './reporter-proxy';
import {
getDugitePath, getSharedModulePath, getAtomHelperPath,
extractCoAuthorsAndRawCommitMessage, fileExists, isFileExecutable, isFileSymlink, isBinary,
Expand Down Expand Up @@ -42,6 +43,9 @@ export class LargeRepoError extends Error {
}
}

// ignored for the purposes of usage metrics tracking because they're noisy
const IGNORED_GIT_COMMANDS = ['cat-file', 'config', 'diff', 'for-each-ref', 'log', 'rev-parse', 'status'];
Copy link
Contributor

Choose a reason for hiding this comment

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

Would it be more reliable to list the commands we do want to capture?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Good question. My thinking on this was that

  1. I don't really know which commands are important for understanding user behavior. So logging all of them except really obviously noisy ones avoids having to be more specific about what we'd like to learn from this. Which could be good or bad depending on how you look at it.

  2. Using an "exclude" list rather than an "include" list means that we don't have to worry about forgetting to update the list when we add support for new git commands. (Although obviously if we add new noisy commands, we'd still need to update.)


const DISABLE_COLOR_FLAGS = [
'branch', 'diff', 'showBranch', 'status', 'ui',
].reduce((acc, type) => {
Expand Down Expand Up @@ -89,6 +93,7 @@ export default class GitShellOutStrategy {
async exec(args, options = GitShellOutStrategy.defaultExecArgs) {
/* eslint-disable no-console,no-control-regex */
const {stdin, useGitPromptServer, useGpgWrapper, useGpgAtomPrompt, writeOperation} = options;
const commandName = args[0];
Copy link
Contributor

Choose a reason for hiding this comment

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

👍

const subscriptions = new CompositeDisposable();
const diagnosticsEnabled = process.env.ATOM_GITHUB_GIT_DIAGNOSTICS || atom.config.get('github.gitDiagnostics');

Expand Down Expand Up @@ -311,6 +316,10 @@ export default class GitShellOutStrategy {
err.command = formattedArgs;
reject(err);
}

if (!IGNORED_GIT_COMMANDS.includes(commandName)) {
incrementCounter(commandName);
}
resolve(stdout);
});
}, {parallel: !writeOperation});
Expand Down Expand Up @@ -473,7 +482,7 @@ export default class GitShellOutStrategy {

if (amend) { args.push('--amend'); }
if (allowEmpty) { args.push('--allow-empty'); }
return this.gpgExec(args, {writeOperation: true});
return await this.gpgExec(args, {writeOperation: true});
Copy link
Contributor

Choose a reason for hiding this comment

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

☝️ You shouldn't need this await.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

👍 oops. fixed.

}

addCoAuthorsToMessage(message, coAuthors = []) {
Expand Down
5 changes: 5 additions & 0 deletions lib/github-package.js
Expand Up @@ -21,6 +21,7 @@ import ContextMenuInterceptor from './context-menu-interceptor';
import AsyncQueue from './async-queue';
import WorkerManager from './worker-manager';
import getRepoPipelineManager from './get-repo-pipeline-manager';
import {reporterProxy} from './reporter-proxy';

const defaultState = {
newProject: true,
Expand Down Expand Up @@ -310,6 +311,10 @@ export default class GithubPackage {
this.rerender();
}

consumeReporter(reporter) {
reporterProxy.setReporter(reporter);
}

createGitTimingsView() {
return StubItem.create('git-timings-view', {
title: 'GitHub Package Timings View',
Expand Down
63 changes: 63 additions & 0 deletions lib/reporter-proxy.js
@@ -0,0 +1,63 @@
const pjson = require('../package.json');

// this class allows us to call reporter methods
// before the reporter is actually loaded, since we don't want to
// assume that the metrics package will load before the GitHub package.

// maybe this should be an object instead of a class.
// IDK.
Copy link
Contributor

Choose a reason for hiding this comment

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

Haha, this is fine. One advantage of having a class - you can export it and test its internals independent of the global state.

class ReporterProxy {
constructor() {
this.reporter = null;
this.events = [];
this.timings = [];
this.counters = [];
this.gitHubPackageVersion = pjson.version;
}

// function that is called after the reporter is actually loaded, to
// set the reporter and send any data that have accumulated while it was loading.
Copy link
Contributor

Choose a reason for hiding this comment

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

One question: what happens if the metrics package is disabled and this is never called? It'd be good not to endlessly accumulate events and slowly eat RAM without bound... but I'm not sure how we could know that this method won't be called.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

oh, gooooooood question. Hadn't thought about that edge case.

I guess I could set a timer, and if the metrics package fails to be activated within a reasonable timeframe, cast the events into the void? I can't think of a better way to handle this but I'm open to suggestions.

Do you know how common it is for users to straight up disable core packages like that? Now I'm curious.

setReporter(reporter) {
this.reporter = reporter;

this.events.forEach(customEvent => {
this.reporter.addCustomEvent(customEvent.eventType, customEvent.event);
});

this.timings.forEach(timing => {
this.reporter.addTiming(timing.eventType, timing.durationInMilliseconds, timing.metadata);
});

this.counters.forEach(counterName => {
this.reporter.incrementCounter(counterName);
});
}
}

export const reporterProxy = new ReporterProxy();

export const incrementCounter = function(counterName) {
Copy link
Contributor

Choose a reason for hiding this comment

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

Any particular reason for the export const name = function() { ... } style? Elsewhere we're just doing export function name() { ... }, but this being JavaScript it's quite possible I'm missing some subtleties about the value of this or something between declaration styles 🤔

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I wasn't sure if export function name() { .. } retained the execution context of the module where the function was defined, which is necessary because I'm using closures. but I suppose I could bind if I need to. Consistency is good, I'll try to switch to the other style. Thanks!

if (reporterProxy.reporter) {
reporterProxy.reporter.incrementCounter(counterName);
} else {
reporterProxy.counters.push(counterName);
}
};

export const addTiming = function(eventType, durationInMilliseconds, metadata = {}) {
metadata.gitHubPackageVersion = reporterProxy.gitHubPackageVersion;
if (reporterProxy.reporter) {
reporterProxy.reporter.addTiming(eventType, durationInMilliseconds, metadata);
} else {
reporterProxy.timings.push({eventType, durationInMilliseconds, metadata});
}
};

export const addEvent = function(eventType, event) {
event.gitHubPackageVersion = reporterProxy.gitHubPackageVersion;
if (reporterProxy.reporter) {
reporterProxy.reporter.addCustomEvent(eventType, event);
} else {
reporterProxy.events.push({eventType, event});
}
};
5 changes: 5 additions & 0 deletions package.json
Expand Up @@ -96,6 +96,11 @@
"versions": {
"^1.0.0": "consumeStatusBar"
}
},
"metrics-reporter": {
"versions": {
"^1.1.0": "consumeReporter"
}
}
},
"configSchema": {
Expand Down
23 changes: 23 additions & 0 deletions test/containers/remote-container.test.js
@@ -1,6 +1,7 @@
import React from 'react';
import {mount} from 'enzyme';

import * as reporterProxy from '../../lib/reporter-proxy';
import {createRepositoryResult} from '../fixtures/factories/repository-result';
import Remote from '../../lib/models/remote';
import Branch, {nullBranch} from '../../lib/models/branch';
Expand Down Expand Up @@ -131,6 +132,28 @@ describe('RemoteContainer', function() {
assert.strictEqual(qev.prop('error'), e);
});

it('increments a counter on login', function() {
const token = '1234';
sinon.stub(model, 'getScopes').resolves(GithubLoginModel.REQUIRED_SCOPES);
Copy link
Contributor

Choose a reason for hiding this comment

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

Hmm I should probably add this as a helper or something - you have to stub it every time any test needs to touch the GithubLoginModel, which is annoying.

const incrementCounterStub = sinon.stub(reporterProxy, 'incrementCounter');

const wrapper = mount(buildApp());
wrapper.instance().handleLogin(token);
assert.isTrue(incrementCounterStub.calledOnceWith('github-login'));
Copy link
Contributor

Choose a reason for hiding this comment

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

Reminder to self: look up how to set up Sinon with Chai expectations again so we can (hopefully) get more informative failure messages on these.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

jah, seems like a good issue for the cleanup sprint.

});

it('increments a counter on logout', function() {
const token = '1234';
sinon.stub(model, 'getScopes').resolves(GithubLoginModel.REQUIRED_SCOPES);

const wrapper = mount(buildApp());
wrapper.instance().handleLogin(token);

const incrementCounterStub = sinon.stub(reporterProxy, 'incrementCounter');
wrapper.instance().handleLogout();
assert.isTrue(incrementCounterStub.calledOnceWith('github-logout'));
});

it('renders the controller once results have arrived', async function() {
const {resolve} = expectRepositoryQuery();
expectEmptyIssueishQuery();
Expand Down
25 changes: 25 additions & 0 deletions test/controllers/remote-controller.test.js
@@ -1,11 +1,13 @@
import React from 'react';
import {shallow} from 'enzyme';
import {shell} from 'electron';

import BranchSet from '../../lib/models/branch-set';
import Branch, {nullBranch} from '../../lib/models/branch';
import Remote from '../../lib/models/remote';
import {nullOperationStateObserver} from '../../lib/models/operation-state-observer';
import RemoteController from '../../lib/controllers/remote-controller';
import * as reporterProxy from '../../lib/reporter-proxy';

describe('RemoteController', function() {
let atomEnv, remote, branchSet, currentBranch;
Expand Down Expand Up @@ -49,6 +51,29 @@ describe('RemoteController', function() {
);
}

it('increments a counter when onCreatePr is called', async function() {
const wrapper = shallow(createApp());
sinon.stub(shell, 'openExternal').resolves();
Copy link
Contributor

Choose a reason for hiding this comment

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

Ah, this is the bit where you were having trouble getting the Promise to resolve?

Because the real shell.openExternal doesn't return a Promise, you might have better luck with:

sinon.stub(shell, 'openExternal').callsArg(2);

And to report an error:

sinon.stub(shell, 'openExternal').callsArgWith(2, new Error('boom'));

Copy link
Contributor Author

Choose a reason for hiding this comment

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

ah thanks, I'll try that!

sinon.stub(reporterProxy, 'incrementCounter');

await wrapper.instance().onCreatePr();
assert.equal(reporterProxy.incrementCounter.callCount, 1);
assert.deepEqual(reporterProxy.incrementCounter.lastCall.args, ['create-pull-request']);
});

it('handles error when onCreatePr fails', async function() {
const wrapper = shallow(createApp());
sinon.stub(shell, 'openExternal').rejects(new Error('oh noes'));
sinon.stub(reporterProxy, 'incrementCounter');

try {
await wrapper.instance().onCreatePr();
} catch (err) {
assert.equal(err.message, 'oh noes');
}
assert.equal(reporterProxy.incrementCounter.callCount, 0);
});

it('renders issueish searches', function() {
const wrapper = shallow(createApp());

Expand Down
17 changes: 17 additions & 0 deletions test/controllers/root-controller.test.js
Expand Up @@ -11,6 +11,7 @@ import Repository from '../../lib/models/repository';
import GitTabItem from '../../lib/items/git-tab-item';
import GithubTabController from '../../lib/controllers/github-tab-controller';
import ResolutionProgress from '../../lib/models/conflicts/resolution-progress';
import * as reporterProxy from '../../lib/reporter-proxy';

import RootController from '../../lib/controllers/root-controller';

Expand Down Expand Up @@ -120,6 +121,14 @@ describe('RootController', function() {
{searchAllPanes: true, activateItem: true, activatePane: true},
]);
});
it('increments counter with correct name', function() {
sinon.stub(workspace, 'open');
const incrementCounterStub = sinon.stub(reporterProxy, 'incrementCounter');

tabTracker.reveal();
assert.equal(incrementCounterStub.callCount, 1);
assert.deepEqual(incrementCounterStub.lastCall.args, [`${tabName}-tab-open`]);
});
});

describe('hide', function() {
Expand All @@ -132,6 +141,14 @@ describe('RootController', function() {
`atom-github://dock-item/${tabName}`,
]);
});
it('increments counter with correct name', function() {
sinon.stub(workspace, 'hide');
const incrementCounterStub = sinon.stub(reporterProxy, 'incrementCounter');

tabTracker.hide();
assert.equal(incrementCounterStub.callCount, 1);
assert.deepEqual(incrementCounterStub.lastCall.args, [`${tabName}-tab-close`]);
});
});

describe('toggle()', function() {
Expand Down
18 changes: 18 additions & 0 deletions test/git-strategies.test.js
Expand Up @@ -13,6 +13,7 @@ import WorkerManager from '../lib/worker-manager';

import {cloneRepository, initRepository, assertDeepPropertyVals} from './helpers';
import {normalizeGitHelperPath, getTempDir} from '../lib/helpers';
import * as reporterProxy from '../lib/reporter-proxy';

/**
* KU Thoughts: The GitShellOutStrategy methods are tested in Repository tests for the most part
Expand Down Expand Up @@ -1298,7 +1299,24 @@ import {normalizeGitHelperPath, getTempDir} from '../lib/helpers';
});
});
});
describe('exec', function() {
let workingDirPath, git, incrementCounterStub;
beforeEach(async function() {
workingDirPath = await cloneRepository('three-files');
git = createTestStrategy(workingDirPath);
incrementCounterStub = sinon.stub(reporterProxy, 'incrementCounter');
});
it('does not call incrementCounter when git command is on the ignore list', async function() {
await git.exec(['status']);
assert.equal(incrementCounterStub.callCount, 0);
});
it('does call incrementCounter when git command is NOT on the ignore list', async function() {
await git.exec(['commit', '--allow-empty', '-m', 'make an empty commit']);

assert.equal(incrementCounterStub.callCount, 1);
assert.deepEqual(incrementCounterStub.lastCall.args, ['commit']);
});
});
describe('executeGitCommand', function() {
it('shells out in process until WorkerManager instance is ready', async function() {
if (process.env.ATOM_GITHUB_INLINE_GIT_EXEC) {
Expand Down