Merge pull request #1356 from balena-io/singleton-eventtracker

Make the event-tracker module a singleton
This commit is contained in:
bulldozer-balena[bot] 2020-06-08 12:36:10 +00:00 committed by GitHub
commit f8e48573c7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 326 additions and 305 deletions

View File

@ -10,7 +10,7 @@ import * as url from 'url';
import * as deviceRegister from './lib/register-device'; import * as deviceRegister from './lib/register-device';
import * as config from './config'; import * as config from './config';
import { EventTracker } from './event-tracker'; import * as eventTracker from './event-tracker';
import { loadBackupFromMigration } from './lib/migration'; import { loadBackupFromMigration } from './lib/migration';
import constants = require('./lib/constants'); import constants = require('./lib/constants');
@ -41,7 +41,6 @@ const INTERNAL_STATE_KEYS = [
]; ];
export interface APIBinderConstructOpts { export interface APIBinderConstructOpts {
eventTracker: EventTracker;
logger: Logger; logger: Logger;
} }
@ -68,7 +67,6 @@ export class APIBinder {
public router: express.Router; public router: express.Router;
private deviceState: DeviceState; private deviceState: DeviceState;
private eventTracker: EventTracker;
private logger: Logger; private logger: Logger;
public balenaApi: PinejsClientRequest | null = null; public balenaApi: PinejsClientRequest | null = null;
@ -84,8 +82,7 @@ export class APIBinder {
private stateReportErrors = 0; private stateReportErrors = 0;
private readyForUpdates = false; private readyForUpdates = false;
public constructor({ eventTracker, logger }: APIBinderConstructOpts) { public constructor({ logger }: APIBinderConstructOpts) {
this.eventTracker = eventTracker;
this.logger = logger; this.logger = logger;
this.router = this.createAPIBinderRouter(this); this.router = this.createAPIBinderRouter(this);
@ -549,7 +546,7 @@ export class APIBinder {
await this.report(); await this.report();
this.reportCurrentState(); this.reportCurrentState();
} catch (e) { } catch (e) {
this.eventTracker.track('Device state report failure', { error: e }); eventTracker.track('Device state report failure', { error: e });
// We use the poll interval as the upper limit of // We use the poll interval as the upper limit of
// the exponential backoff // the exponential backoff
const maxDelay = await config.get('appUpdatePollInterval'); const maxDelay = await config.get('appUpdatePollInterval');
@ -828,7 +825,7 @@ export class APIBinder {
apiKey: null, apiKey: null,
}; };
await config.set(configToUpdate); await config.set(configToUpdate);
this.eventTracker.track('Device bootstrap success'); eventTracker.track('Device bootstrap success');
} }
// Now check if we need to pin the device // Now check if we need to pin the device
@ -847,11 +844,11 @@ export class APIBinder {
} }
private async provisionOrRetry(retryDelay: number): Promise<void> { private async provisionOrRetry(retryDelay: number): Promise<void> {
this.eventTracker.track('Device bootstrap'); eventTracker.track('Device bootstrap');
try { try {
await this.provision(); await this.provision();
} catch (e) { } catch (e) {
this.eventTracker.track(`Device bootstrap failed, retrying`, { eventTracker.track(`Device bootstrap failed, retrying`, {
error: e, error: e,
delay: retryDelay, delay: retryDelay,
}); });
@ -889,7 +886,7 @@ export class APIBinder {
router.use(bodyParser.json({ limit: '10mb' })); router.use(bodyParser.json({ limit: '10mb' }));
router.post('/v1/update', (req, res, next) => { router.post('/v1/update', (req, res, next) => {
apiBinder.eventTracker.track('Update notification'); eventTracker.track('Update notification');
if (apiBinder.readyForUpdates) { if (apiBinder.readyForUpdates) {
config config
.get('instantUpdates') .get('instantUpdates')

View File

@ -4,7 +4,6 @@ import { Router } from 'express';
import Knex = require('knex'); import Knex = require('knex');
import { ServiceAction } from './device-api/common'; import { ServiceAction } from './device-api/common';
import { EventTracker } from './event-tracker';
import { Logger } from './logger'; import { Logger } from './logger';
import { DeviceStatus, InstancedAppState } from './types/state'; import { DeviceStatus, InstancedAppState } from './types/state';
@ -49,7 +48,6 @@ class ApplicationManager extends EventEmitter {
public _lockingIfNecessary: any; public _lockingIfNecessary: any;
public logger: Logger; public logger: Logger;
public deviceState: DeviceState; public deviceState: DeviceState;
public eventTracker: EventTracker;
public apiBinder: APIBinder; public apiBinder: APIBinder;
public services: ServiceManager; public services: ServiceManager;
@ -67,7 +65,6 @@ class ApplicationManager extends EventEmitter {
public constructor({ public constructor({
logger: Logger, logger: Logger,
eventTracker: EventTracker,
deviceState: DeviceState, deviceState: DeviceState,
apiBinder: APIBinder, apiBinder: APIBinder,
}); });

View File

@ -79,7 +79,7 @@ const createApplicationManagerRouter = function (applications) {
}; };
export class ApplicationManager extends EventEmitter { export class ApplicationManager extends EventEmitter {
constructor({ logger, eventTracker, deviceState, apiBinder }) { constructor({ logger, deviceState, apiBinder }) {
super(); super();
this.serviceAction = serviceAction; this.serviceAction = serviceAction;
@ -170,7 +170,6 @@ export class ApplicationManager extends EventEmitter {
this.localModeSwitchCompletion = this.localModeSwitchCompletion.bind(this); this.localModeSwitchCompletion = this.localModeSwitchCompletion.bind(this);
this.reportOptionalContainers = this.reportOptionalContainers.bind(this); this.reportOptionalContainers = this.reportOptionalContainers.bind(this);
this.logger = logger; this.logger = logger;
this.eventTracker = eventTracker;
this.deviceState = deviceState; this.deviceState = deviceState;
this.apiBinder = apiBinder; this.apiBinder = apiBinder;
this.images = new Images({ this.images = new Images({

View File

@ -1,12 +1,12 @@
import * as Promise from 'bluebird'; import * as Promise from 'bluebird';
import * as _ from 'lodash'; import * as _ from 'lodash';
import * as eventTracker from '../event-tracker';
import * as constants from '../lib/constants'; import * as constants from '../lib/constants';
import { checkInt, checkTruthy } from '../lib/validation'; import { checkInt, checkTruthy } from '../lib/validation';
import { doRestart, doPurge, serviceAction } from './common'; import { doRestart, doPurge, serviceAction } from './common';
export const createV1Api = function (router, applications) { export const createV1Api = function (router, applications) {
const { eventTracker } = applications;
router.post('/v1/restart', function (req, res, next) { router.post('/v1/restart', function (req, res, next) {
const appId = checkInt(req.body.appId); const appId = checkInt(req.body.appId);
const force = checkTruthy(req.body.force) ?? false; const force = checkTruthy(req.body.force) ?? false;

View File

@ -10,7 +10,6 @@ import prettyMs = require('pretty-ms');
import * as config from './config'; import * as config from './config';
import * as db from './db'; import * as db from './db';
import EventTracker from './event-tracker';
import Logger from './logger'; import Logger from './logger';
import { import {
@ -177,7 +176,6 @@ function createDeviceStateRouter(deviceState: DeviceState) {
} }
interface DeviceStateConstructOpts { interface DeviceStateConstructOpts {
eventTracker: EventTracker;
logger: Logger; logger: Logger;
apiBinder: APIBinder; apiBinder: APIBinder;
} }
@ -218,7 +216,6 @@ type DeviceStateStep<T extends PossibleStepTargets> =
| ConfigStep; | ConfigStep;
export class DeviceState extends (EventEmitter as new () => DeviceStateEventEmitter) { export class DeviceState extends (EventEmitter as new () => DeviceStateEventEmitter) {
public eventTracker: EventTracker;
public logger: Logger; public logger: Logger;
public applications: ApplicationManager; public applications: ApplicationManager;
@ -242,16 +239,14 @@ export class DeviceState extends (EventEmitter as new () => DeviceStateEventEmit
public connected: boolean; public connected: boolean;
public router: express.Router; public router: express.Router;
constructor({ eventTracker, logger, apiBinder }: DeviceStateConstructOpts) { constructor({ logger, apiBinder }: DeviceStateConstructOpts) {
super(); super();
this.eventTracker = eventTracker;
this.logger = logger; this.logger = logger;
this.deviceConfig = new DeviceConfig({ this.deviceConfig = new DeviceConfig({
logger: this.logger, logger: this.logger,
}); });
this.applications = new ApplicationManager({ this.applications = new ApplicationManager({
logger: this.logger, logger: this.logger,
eventTracker: this.eventTracker,
deviceState: this, deviceState: this,
apiBinder, apiBinder,
}); });

View File

@ -4,6 +4,7 @@ import { fs } from 'mz';
import { Image } from '../compose/images'; import { Image } from '../compose/images';
import DeviceState from '../device-state'; import DeviceState from '../device-state';
import * as config from '../config'; import * as config from '../config';
import * as eventTracker from '../event-tracker';
import constants = require('../lib/constants'); import constants = require('../lib/constants');
import { AppsJsonParseError, EISDIR, ENOENT } from '../lib/errors'; import { AppsJsonParseError, EISDIR, ENOENT } from '../lib/errors';
@ -111,7 +112,7 @@ export async function loadTargetFromFile(
if (ENOENT(e) || EISDIR(e)) { if (ENOENT(e) || EISDIR(e)) {
log.debug('No apps.json file present, skipping preload'); log.debug('No apps.json file present, skipping preload');
} else { } else {
deviceState.eventTracker.track('Loading preloaded apps failed', { eventTracker.track('Loading preloaded apps failed', {
error: e, error: e,
}); });
} }

View File

@ -1,22 +1,14 @@
import * as Bluebird from 'bluebird';
import mask = require('json-mask'); import mask = require('json-mask');
import * as _ from 'lodash'; import * as _ from 'lodash';
import * as memoizee from 'memoizee'; import * as memoizee from 'memoizee';
import * as mixpanel from 'mixpanel'; import * as mixpanel from 'mixpanel';
import { ConfigType } from './config'; import * as config from './config';
import log from './lib/supervisor-console'; import log from './lib/supervisor-console';
import supervisorVersion = require('./lib/supervisor-version'); import supervisorVersion = require('./lib/supervisor-version');
export type EventTrackProperties = Dictionary<any>; export type EventTrackProperties = Dictionary<any>;
interface InitArgs {
uuid: ConfigType<'uuid'>;
unmanaged: ConfigType<'unmanaged'>;
mixpanelHost: ConfigType<'mixpanelHost'>;
mixpanelToken: ConfigType<'mixpanelToken'>;
}
// The minimum amount of time to wait between sending // The minimum amount of time to wait between sending
// events of the same type // events of the same type
const eventDebounceTime = 60000; const eventDebounceTime = 60000;
@ -32,38 +24,47 @@ const mixpanelMask = [
'stateDiff/local(os_version,superisor_version,ip_address,apps/*/services)', 'stateDiff/local(os_version,superisor_version,ip_address,apps/*/services)',
].join(','); ].join(',');
export class EventTracker { let defaultProperties: EventTrackProperties;
private defaultProperties: EventTrackProperties | null; // We must export this for the tests, but we make no references
private client: mixpanel.Mixpanel | null; // to it within the rest of the supervisor codebase
export let client: mixpanel.Mixpanel | null = null;
public constructor() { export const initialized = (async () => {
this.client = null; await config.initialized;
this.defaultProperties = null;
}
public init({ const {
unmanaged, unmanaged,
mixpanelHost, mixpanelHost,
mixpanelToken, mixpanelToken,
uuid, uuid,
}: InitArgs): Bluebird<void> { } = await config.getMany([
return Bluebird.try(() => { 'unmanaged',
this.defaultProperties = { 'mixpanelHost',
'mixpanelToken',
'uuid',
]);
defaultProperties = {
distinct_id: uuid, distinct_id: uuid,
uuid, uuid,
supervisorVersion, supervisorVersion,
}; };
if (unmanaged || mixpanelHost == null || mixpanelToken == null) { if (unmanaged || mixpanelHost == null || mixpanelToken == null) {
return; return;
} }
this.client = mixpanel.init(mixpanelToken, { client = mixpanel.init(mixpanelToken, {
host: mixpanelHost.host, host: mixpanelHost.host,
path: mixpanelHost.path, path: mixpanelHost.path,
}); });
}); })();
}
export async function track(
event: string,
properties: EventTrackProperties | Error = {},
) {
await initialized;
public track(event: string, properties: EventTrackProperties | Error = {}) {
if (properties instanceof Error) { if (properties instanceof Error) {
properties = { error: properties }; properties = { error: properties };
} }
@ -79,21 +80,21 @@ export class EventTracker {
// Don't send potentially sensitive information, by using a whitelist // Don't send potentially sensitive information, by using a whitelist
properties = mask(properties, mixpanelMask); properties = mask(properties, mixpanelMask);
this.logEvent('Event:', event, JSON.stringify(properties)); log.event('Event:', event, JSON.stringify(properties));
if (this.client == null) { if (client == null) {
return; return;
} }
properties = this.assignDefaultProperties(properties); properties = assignDefaultProperties(properties);
this.throttleddLogger(event)(properties); throttleddLogger(event)(properties);
} }
private throttleddLogger = memoizee( const throttleddLogger = memoizee(
(event: string) => { (event: string) => {
// Call this function at maximum once every minute // Call this function at maximum once every minute
return _.throttle( return _.throttle(
(properties: EventTrackProperties | Error) => { (properties: EventTrackProperties | Error) => {
this.client?.track(event, properties); client?.track(event, properties);
}, },
eventDebounceTime, eventDebounceTime,
{ leading: true }, { leading: true },
@ -102,15 +103,8 @@ export class EventTracker {
{ primitive: true }, { primitive: true },
); );
private logEvent(...args: string[]) { function assignDefaultProperties(
log.event(...args);
}
private assignDefaultProperties(
properties: EventTrackProperties, properties: EventTrackProperties,
): EventTrackProperties { ): EventTrackProperties {
return _.merge({}, properties, this.defaultProperties); return _.merge({}, properties, defaultProperties);
} }
}
export default EventTracker;

View File

@ -3,7 +3,7 @@ import * as _ from 'lodash';
import * as config from './config'; import * as config from './config';
import * as db from './db'; import * as db from './db';
import { EventTracker } from './event-tracker'; import * as eventTracker from './event-tracker';
import { LogType } from './lib/log-types'; import { LogType } from './lib/log-types';
import { writeLock } from './lib/update-lock'; import { writeLock } from './lib/update-lock';
import { import {
@ -30,22 +30,16 @@ interface LoggerSetupOptions {
type LogEventObject = Dictionary<any> | null; type LogEventObject = Dictionary<any> | null;
interface LoggerConstructOptions {
eventTracker: EventTracker;
}
export class Logger { export class Logger {
private backend: LogBackend | null = null; private backend: LogBackend | null = null;
private balenaBackend: BalenaLogBackend | null = null; private balenaBackend: BalenaLogBackend | null = null;
private localBackend: LocalLogBackend | null = null; private localBackend: LocalLogBackend | null = null;
private eventTracker: EventTracker;
private containerLogs: { [containerId: string]: ContainerLogs } = {}; private containerLogs: { [containerId: string]: ContainerLogs } = {};
private logMonitor: LogMonitor; private logMonitor: LogMonitor;
public constructor({ eventTracker }: LoggerConstructOptions) { public constructor() {
this.backend = null; this.backend = null;
this.eventTracker = eventTracker;
this.logMonitor = new LogMonitor(); this.logMonitor = new LogMonitor();
} }
@ -144,7 +138,7 @@ export class Logger {
} }
this.log(msgObj); this.log(msgObj);
if (track) { if (track) {
this.eventTracker.track( eventTracker.track(
eventName != null ? eventName : message, eventName != null ? eventName : message,
eventObj != null ? eventObj : {}, eventObj != null ? eventObj : {},
); );

View File

@ -5,7 +5,7 @@ import * as _ from 'lodash';
import * as morgan from 'morgan'; import * as morgan from 'morgan';
import * as config from './config'; import * as config from './config';
import { EventTracker } from './event-tracker'; import * as eventTracker from './event-tracker';
import blink = require('./lib/blink'); import blink = require('./lib/blink');
import * as iptables from './lib/iptables'; import * as iptables from './lib/iptables';
import { checkTruthy } from './lib/validation'; import { checkTruthy } from './lib/validation';
@ -76,7 +76,6 @@ const expressLogger = morgan(
); );
interface SupervisorAPIConstructOpts { interface SupervisorAPIConstructOpts {
eventTracker: EventTracker;
routers: express.Router[]; routers: express.Router[];
healthchecks: Array<() => Promise<boolean>>; healthchecks: Array<() => Promise<boolean>>;
} }
@ -86,7 +85,6 @@ interface SupervisorAPIStopOpts {
} }
export class SupervisorAPI { export class SupervisorAPI {
private eventTracker: EventTracker;
private routers: express.Router[]; private routers: express.Router[];
private healthchecks: Array<() => Promise<boolean>>; private healthchecks: Array<() => Promise<boolean>>;
@ -101,12 +99,7 @@ export class SupervisorAPI {
} }
: this.applyListeningRules.bind(this); : this.applyListeningRules.bind(this);
public constructor({ public constructor({ routers, healthchecks }: SupervisorAPIConstructOpts) {
eventTracker,
routers,
healthchecks,
}: SupervisorAPIConstructOpts) {
this.eventTracker = eventTracker;
this.routers = routers; this.routers = routers;
this.healthchecks = healthchecks; this.healthchecks = healthchecks;
@ -132,7 +125,7 @@ export class SupervisorAPI {
this.api.use(authenticate()); this.api.use(authenticate());
this.api.post('/v1/blink', (_req, res) => { this.api.post('/v1/blink', (_req, res) => {
this.eventTracker.track('Device blink'); eventTracker.track('Device blink');
blink.pattern.start(); blink.pattern.start();
setTimeout(blink.pattern.stop, 15000); setTimeout(blink.pattern.stop, 15000);
return res.sendStatus(200); return res.sendStatus(200);

View File

@ -2,7 +2,7 @@ import APIBinder from './api-binder';
import * as db from './db'; import * as db from './db';
import * as config from './config'; import * as config from './config';
import DeviceState from './device-state'; import DeviceState from './device-state';
import EventTracker from './event-tracker'; import * as eventTracker from './event-tracker';
import { intialiseContractRequirements } from './lib/contracts'; import { intialiseContractRequirements } from './lib/contracts';
import { normaliseLegacyDatabase } from './lib/migration'; import { normaliseLegacyDatabase } from './lib/migration';
import * as osRelease from './lib/os-release'; import * as osRelease from './lib/os-release';
@ -29,21 +29,17 @@ const startupConfigFields: config.ConfigKey[] = [
]; ];
export class Supervisor { export class Supervisor {
private eventTracker: EventTracker;
private logger: Logger; private logger: Logger;
private deviceState: DeviceState; private deviceState: DeviceState;
private apiBinder: APIBinder; private apiBinder: APIBinder;
private api: SupervisorAPI; private api: SupervisorAPI;
public constructor() { public constructor() {
this.eventTracker = new EventTracker(); this.logger = new Logger();
this.logger = new Logger({ eventTracker: this.eventTracker });
this.apiBinder = new APIBinder({ this.apiBinder = new APIBinder({
eventTracker: this.eventTracker,
logger: this.logger, logger: this.logger,
}); });
this.deviceState = new DeviceState({ this.deviceState = new DeviceState({
eventTracker: this.eventTracker,
logger: this.logger, logger: this.logger,
apiBinder: this.apiBinder, apiBinder: this.apiBinder,
}); });
@ -55,7 +51,6 @@ export class Supervisor {
this.deviceState.applications.proxyvisor.bindToAPI(this.apiBinder); this.deviceState.applications.proxyvisor.bindToAPI(this.apiBinder);
this.api = new SupervisorAPI({ this.api = new SupervisorAPI({
eventTracker: this.eventTracker,
routers: [this.apiBinder.router, this.deviceState.router], routers: [this.apiBinder.router, this.deviceState.router],
healthchecks: [ healthchecks: [
this.apiBinder.healthcheck.bind(this.apiBinder), this.apiBinder.healthcheck.bind(this.apiBinder),
@ -69,16 +64,10 @@ export class Supervisor {
await db.initialized; await db.initialized;
await config.initialized; await config.initialized;
await eventTracker.initialized;
const conf = await config.getMany(startupConfigFields); const conf = await config.getMany(startupConfigFields);
// We can't print to the dashboard until the logger
// has started up, so we leave a trail of breadcrumbs
// in the logs in case runtime fails to get to the
// first dashboard logs
log.debug('Starting event tracker');
await this.eventTracker.init(conf);
log.debug('Starting logging infrastructure'); log.debug('Starting logging infrastructure');
this.logger.init({ this.logger.init({
enableLogs: conf.loggingEnabled, enableLogs: conf.loggingEnabled,

View File

@ -216,9 +216,6 @@ describe('deviceState', () => {
let deviceState: DeviceState; let deviceState: DeviceState;
before(async () => { before(async () => {
await prepare(); await prepare();
const eventTracker = {
track: console.log,
};
stub(Service as any, 'extendEnvVars').callsFake((env) => { stub(Service as any, 'extendEnvVars').callsFake((env) => {
env['ADDITIONAL_ENV_VAR'] = 'foo'; env['ADDITIONAL_ENV_VAR'] = 'foo';
@ -231,7 +228,6 @@ describe('deviceState', () => {
}); });
deviceState = new DeviceState({ deviceState = new DeviceState({
eventTracker: eventTracker as any,
logger: logger as any, logger: logger as any,
apiBinder: null as any, apiBinder: null as any,
}); });

View File

@ -1,67 +1,96 @@
import { Mixpanel } from 'mixpanel';
import * as mixpanel from 'mixpanel'; import * as mixpanel from 'mixpanel';
import { SinonStub, stub } from 'sinon'; import { SinonStub, stub, spy, SinonSpy } from 'sinon';
import { expect } from './lib/chai-config'; import { expect } from './lib/chai-config';
import EventTracker from '../src/event-tracker'; import log from '../src/lib/supervisor-console';
import supervisorVersion = require('../src/lib/supervisor-version'); import supervisorVersion = require('../src/lib/supervisor-version');
import * as config from '../src/config';
describe('EventTracker', () => { describe('EventTracker', () => {
let eventTrackerOffline: EventTracker; let logEventStub: SinonStub;
let eventTracker: EventTracker;
let initStub: SinonStub;
before(() => { before(() => {
initStub = stub(mixpanel, 'init').callsFake( logEventStub = stub(log, 'event');
(token) =>
(({
token,
track: stub().returns(undefined),
} as unknown) as Mixpanel),
);
eventTrackerOffline = new EventTracker(); delete require.cache[require.resolve('../src/event-tracker')];
eventTracker = new EventTracker(); });
return stub(EventTracker.prototype as any, 'logEvent');
afterEach(() => {
logEventStub.reset();
}); });
after(() => { after(() => {
(EventTracker.prototype as any).logEvent.restore(); logEventStub.restore();
return initStub.restore();
}); });
it('initializes in unmanaged mode', () => { describe('Unmanaged', () => {
const promise = eventTrackerOffline.init({ let configStub: SinonStub;
let eventTracker: typeof import('../src/event-tracker');
before(async () => {
configStub = stub(config, 'getMany').returns(
Promise.resolve({
unmanaged: true, unmanaged: true,
uuid: 'foobar', uuid: 'foobar',
mixpanelHost: { host: '', path: '' }, mixpanelHost: { host: '', path: '' },
mixpanelToken: '', mixpanelToken: '',
}) as any,
);
eventTracker = await import('../src/event-tracker');
}); });
expect(promise).to.be.fulfilled.then(() => {
// @ts-ignore after(() => {
expect(eventTrackerOffline.client).to.be.null; configStub.restore();
delete require.cache[require.resolve('../src/event-tracker')];
});
it('initializes in unmanaged mode', () => {
expect(eventTracker.initialized).to.be.fulfilled.then(() => {
expect(eventTracker.client).to.be.null;
}); });
}); });
it('logs events in unmanaged mode, with the correct properties', () => { it('logs events in unmanaged mode, with the correct properties', async () => {
eventTrackerOffline.track('Test event', { appId: 'someValue' }); await eventTracker.track('Test event', { appId: 'someValue' });
// @ts-ignore expect(logEventStub).to.be.calledWith(
expect(eventTrackerOffline.logEvent).to.be.calledWith(
'Event:', 'Event:',
'Test event', 'Test event',
JSON.stringify({ appId: 'someValue' }), JSON.stringify({ appId: 'someValue' }),
); );
}); });
});
it('initializes a mixpanel client when not in unmanaged mode', () => { describe('Init', () => {
const promise = eventTracker.init({ let eventTracker: typeof import('../src/event-tracker');
let configStub: SinonStub;
let mixpanelSpy: SinonSpy;
before(async () => {
configStub = stub(config, 'getMany').returns(
Promise.resolve({
mixpanelToken: 'someToken', mixpanelToken: 'someToken',
uuid: 'barbaz', uuid: 'barbaz',
mixpanelHost: { host: '', path: '' }, mixpanelHost: { host: '', path: '' },
unmanaged: false, unmanaged: false,
}) as any,
);
mixpanelSpy = spy(mixpanel, 'init');
eventTracker = await import('../src/event-tracker');
}); });
expect(promise).to.be.fulfilled.then(() => {
after(() => {
configStub.restore();
mixpanelSpy.restore();
delete require.cache[require.resolve('../src/event-tracker')];
});
it('initializes a mixpanel client when not in unmanaged mode', () => {
expect(eventTracker.initialized).to.be.fulfilled.then(() => {
expect(mixpanel.init).to.have.been.calledWith('someToken'); expect(mixpanel.init).to.have.been.calledWith('someToken');
// @ts-ignore // @ts-ignore
expect(eventTracker.client.token).to.equal('someToken'); expect(eventTracker.client.token).to.equal('someToken');
@ -69,11 +98,43 @@ describe('EventTracker', () => {
expect(eventTracker.client.track).to.be.a('function'); expect(eventTracker.client.track).to.be.a('function');
}); });
}); });
});
it('calls the mixpanel client track function with the event, properties and uuid as distinct_id', () => { describe('Managed', () => {
eventTracker.track('Test event 2', { appId: 'someOtherValue' }); let eventTracker: typeof import('../src/event-tracker');
// @ts-ignore let configStub: SinonStub;
expect(eventTracker.logEvent).to.be.calledWith( let mixpanelStub: SinonStub;
before(async () => {
configStub = stub(config, 'getMany').returns(
Promise.resolve({
mixpanelToken: 'someToken',
uuid: 'barbaz',
mixpanelHost: { host: '', path: '' },
unmanaged: false,
}) as any,
);
mixpanelStub = stub(mixpanel, 'init').returns({
token: 'someToken',
track: stub(),
} as any);
eventTracker = await import('../src/event-tracker');
await eventTracker.initialized;
});
after(() => {
configStub.restore();
mixpanelStub.restore();
delete require.cache[require.resolve('../src/event-tracker')];
});
it('calls the mixpanel client track function with the event, properties and uuid as distinct_id', async () => {
await eventTracker.track('Test event 2', { appId: 'someOtherValue' });
expect(logEventStub).to.be.calledWith(
'Event:', 'Event:',
'Test event 2', 'Test event 2',
JSON.stringify({ appId: 'someOtherValue' }), JSON.stringify({ appId: 'someOtherValue' }),
@ -87,9 +148,9 @@ describe('EventTracker', () => {
}); });
}); });
it('can be passed an Error and it is added to the event properties', () => { it('can be passed an Error and it is added to the event properties', async () => {
const theError = new Error('something went wrong'); const theError = new Error('something went wrong');
eventTracker.track('Error event', theError); await eventTracker.track('Error event', theError);
// @ts-ignore // @ts-ignore
expect(eventTracker.client.track).to.be.calledWith('Error event', { expect(eventTracker.client.track).to.be.calledWith('Error event', {
error: { error: {
@ -102,7 +163,7 @@ describe('EventTracker', () => {
}); });
}); });
it('hides service environment variables, to avoid logging keys or secrets', () => { it('hides service environment variables, to avoid logging keys or secrets', async () => {
const props = { const props = {
service: { service: {
appId: '1', appId: '1',
@ -113,7 +174,7 @@ describe('EventTracker', () => {
}, },
}, },
}; };
eventTracker.track('Some app event', props); await eventTracker.track('Some app event', props);
// @ts-ignore // @ts-ignore
expect(eventTracker.client.track).to.be.calledWith('Some app event', { expect(eventTracker.client.track).to.be.calledWith('Some app event', {
service: { appId: '1' }, service: { appId: '1' },
@ -124,50 +185,65 @@ describe('EventTracker', () => {
}); });
it('should handle being passed no properties object', () => { it('should handle being passed no properties object', () => {
expect(eventTracker.track('no-options')).to.not.throw; expect(eventTracker.track('no-options')).to.be.fulfilled;
});
}); });
return describe('Rate limiting', () => { describe('Rate limiting', () => {
it('should rate limit events of the same type', () => { let eventTracker: typeof import('../src/event-tracker');
// @ts-ignore let mixpanelStub: SinonStub;
eventTracker.client.track.reset();
eventTracker.track('test', {}); before(async () => {
eventTracker.track('test', {}); mixpanelStub = stub(mixpanel, 'init').returns({
eventTracker.track('test', {}); track: stub(),
eventTracker.track('test', {}); } as any);
eventTracker.track('test', {}); eventTracker = await import('../src/event-tracker');
await eventTracker.initialized;
// @ts-ignore
expect(eventTracker.client.track).to.have.callCount(1);
}); });
it('should rate limit events of the same type with different arguments', () => { after(() => {
// @ts-ignore mixpanelStub.restore();
eventTracker.client.track.reset();
eventTracker.track('test2', { a: 1 }); delete require.cache[require.resolve('../src/event-tracker')];
eventTracker.track('test2', { b: 2 });
eventTracker.track('test2', { c: 3 });
eventTracker.track('test2', { d: 4 });
eventTracker.track('test2', { e: 5 });
// @ts-ignore
expect(eventTracker.client.track).to.have.callCount(1);
}); });
it('should not rate limit events of different types', () => { it('should rate limit events of the same type', async () => {
// @ts-ignore // @ts-expect-error resetting a non-stub typed function
eventTracker.client.track.reset(); eventTracker.client?.track.reset();
eventTracker.track('test3', { a: 1 }); await eventTracker.track('test', {});
eventTracker.track('test4', { b: 2 }); await eventTracker.track('test', {});
eventTracker.track('test5', { c: 3 }); await eventTracker.track('test', {});
eventTracker.track('test6', { d: 4 }); await eventTracker.track('test', {});
eventTracker.track('test7', { e: 5 }); await eventTracker.track('test', {});
// @ts-ignore expect(eventTracker.client?.track).to.have.callCount(1);
expect(eventTracker.client.track).to.have.callCount(5); });
it('should rate limit events of the same type with different arguments', async () => {
// @ts-expect-error resetting a non-stub typed function
eventTracker.client?.track.reset();
await eventTracker.track('test2', { a: 1 });
await eventTracker.track('test2', { b: 2 });
await eventTracker.track('test2', { c: 3 });
await eventTracker.track('test2', { d: 4 });
await eventTracker.track('test2', { e: 5 });
expect(eventTracker.client?.track).to.have.callCount(1);
});
it('should not rate limit events of different types', async () => {
// @ts-expect-error resetting a non-stub typed function
eventTracker.client?.track.reset();
await eventTracker.track('test3', { a: 1 });
await eventTracker.track('test4', { b: 2 });
await eventTracker.track('test5', { c: 3 });
await eventTracker.track('test6', { d: 4 });
await eventTracker.track('test7', { e: 5 });
expect(eventTracker.client?.track).to.have.callCount(5);
}); });
}); });
}); });

View File

@ -7,6 +7,7 @@ import ApiBinder from '../src/api-binder';
import prepare = require('./lib/prepare'); import prepare = require('./lib/prepare');
import * as config from '../src/config'; import * as config from '../src/config';
import DeviceState from '../src/device-state'; import DeviceState from '../src/device-state';
import * as eventTracker from '../src/event-tracker';
import Log from '../src/lib/supervisor-console'; import Log from '../src/lib/supervisor-console';
import chai = require('./lib/chai-config'); import chai = require('./lib/chai-config');
import balenaAPI = require('./lib/mocked-balena-api'); import balenaAPI = require('./lib/mocked-balena-api');
@ -28,10 +29,6 @@ const initModels = async (obj: Dictionary<any>, filename: string) => {
(config.configJsonBackend as any).cache = await (config.configJsonBackend as any).read(); (config.configJsonBackend as any).cache = await (config.configJsonBackend as any).read();
await config.generateRequiredFields(); await config.generateRequiredFields();
obj.eventTracker = {
track: stub().callsFake((ev, props) => console.log(ev, props)),
} as any;
obj.logger = { obj.logger = {
clearOutOfDateDBLogs: () => { clearOutOfDateDBLogs: () => {
/* noop */ /* noop */
@ -40,11 +37,9 @@ const initModels = async (obj: Dictionary<any>, filename: string) => {
obj.apiBinder = new ApiBinder({ obj.apiBinder = new ApiBinder({
logger: obj.logger, logger: obj.logger,
eventTracker: obj.eventTracker,
}); });
obj.deviceState = new DeviceState({ obj.deviceState = new DeviceState({
eventTracker: obj.eventTracker,
logger: obj.logger, logger: obj.logger,
apiBinder: obj.apiBinder, apiBinder: obj.apiBinder,
}); });
@ -65,6 +60,14 @@ const mockProvisioningOpts = {
describe('ApiBinder', () => { describe('ApiBinder', () => {
let server: Server; let server: Server;
beforeEach(() => {
stub(eventTracker, 'track');
});
afterEach(() => {
// @ts-expect-error Restoring a non-stub type function
eventTracker.track.restore();
});
before(() => { before(() => {
spy(balenaAPI.balenaBackend!, 'registerHandler'); spy(balenaAPI.balenaBackend!, 'registerHandler');
server = balenaAPI.listen(3000); server = balenaAPI.listen(3000);
@ -102,9 +105,7 @@ describe('ApiBinder', () => {
// @ts-ignore // @ts-ignore
balenaAPI.balenaBackend!.registerHandler.resetHistory(); balenaAPI.balenaBackend!.registerHandler.resetHistory();
expect(components.eventTracker.track).to.be.calledWith( expect(eventTracker.track).to.be.calledWith('Device bootstrap success');
'Device bootstrap success',
);
}); });
}); });

View File

@ -7,6 +7,9 @@ import * as sinon from 'sinon';
import { Logger } from '../src/logger'; import { Logger } from '../src/logger';
import { ContainerLogs } from '../src/logging/container'; import { ContainerLogs } from '../src/logging/container';
import * as eventTracker from '../src/event-tracker';
import { stub } from 'sinon';
describe('Logger', function () { describe('Logger', function () {
beforeEach(function () { beforeEach(function () {
this._req = new stream.PassThrough(); this._req = new stream.PassThrough();
@ -20,12 +23,9 @@ describe('Logger', function () {
this.requestStub = sinon.stub(https, 'request').returns(this._req); this.requestStub = sinon.stub(https, 'request').returns(this._req);
this.fakeEventTracker = { this.eventTrackerStub = stub(eventTracker, 'track');
track: sinon.spy(),
};
// @ts-ignore missing db property this.logger = new Logger();
this.logger = new Logger({ eventTracker: this.fakeEventTracker });
return this.logger.init({ return this.logger.init({
apiEndpoint: 'https://example.com', apiEndpoint: 'https://example.com',
uuid: 'deadbeef', uuid: 'deadbeef',
@ -38,6 +38,7 @@ describe('Logger', function () {
afterEach(function () { afterEach(function () {
this.requestStub.restore(); this.requestStub.restore();
this.eventTrackerStub.restore();
}); });
it('waits the grace period before sending any logs', function () { it('waits the grace period before sending any logs', function () {
@ -108,7 +109,7 @@ describe('Logger', function () {
); );
return Promise.delay(5500).then(() => { return Promise.delay(5500).then(() => {
expect(this.fakeEventTracker.track).to.be.calledWith('Some event name', { expect(this.eventTrackerStub).to.be.calledWith('Some event name', {
someProp: 'someVal', someProp: 'someVal',
}); });
const lines = this._req.body.split('\n'); const lines = this._req.body.split('\n');

View File

@ -7,7 +7,6 @@ import Network from '../src/compose/network';
import Service from '../src/compose/service'; import Service from '../src/compose/service';
import Volume from '../src/compose/volume'; import Volume from '../src/compose/volume';
import DeviceState from '../src/device-state'; import DeviceState from '../src/device-state';
import EventTracker from '../src/event-tracker';
import * as dockerUtils from '../src/lib/docker-utils'; import * as dockerUtils from '../src/lib/docker-utils';
import chai = require('./lib/chai-config'); import chai = require('./lib/chai-config');
@ -126,14 +125,12 @@ const dependentDBFormat = {
describe('ApplicationManager', function () { describe('ApplicationManager', function () {
before(async function () { before(async function () {
await prepare(); await prepare();
const eventTracker = new EventTracker();
this.logger = { this.logger = {
clearOutOfDateDBLogs: () => { clearOutOfDateDBLogs: () => {
/* noop */ /* noop */
}, },
} as any; } as any;
this.deviceState = new DeviceState({ this.deviceState = new DeviceState({
eventTracker,
logger: this.logger, logger: this.logger,
apiBinder: null as any, apiBinder: null as any,
}); });

View File

@ -13,7 +13,6 @@ import { createV1Api } from '../../src/device-api/v1';
import { createV2Api } from '../../src/device-api/v2'; import { createV2Api } from '../../src/device-api/v2';
import APIBinder from '../../src/api-binder'; import APIBinder from '../../src/api-binder';
import DeviceState from '../../src/device-state'; import DeviceState from '../../src/device-state';
import EventTracker from '../../src/event-tracker';
import SupervisorAPI from '../../src/supervisor-api'; import SupervisorAPI from '../../src/supervisor-api';
const DB_PATH = './test/data/supervisor-api.sqlite'; const DB_PATH = './test/data/supervisor-api.sqlite';
@ -68,19 +67,17 @@ const STUBBED_VALUES = {
async function create(): Promise<SupervisorAPI> { async function create(): Promise<SupervisorAPI> {
// Get SupervisorAPI construct options // Get SupervisorAPI construct options
const { eventTracker, deviceState, apiBinder } = await createAPIOpts(); const { deviceState, apiBinder } = await createAPIOpts();
// Stub functions // Stub functions
setupStubs(); setupStubs();
// Create ApplicationManager // Create ApplicationManager
const appManager = new ApplicationManager({ const appManager = new ApplicationManager({
eventTracker,
logger: null, logger: null,
deviceState, deviceState,
apiBinder: null, apiBinder: null,
}); });
// Create SupervisorAPI // Create SupervisorAPI
const api = new SupervisorAPI({ const api = new SupervisorAPI({
eventTracker,
routers: [buildRoutes(appManager)], routers: [buildRoutes(appManager)],
healthchecks: [deviceState.healthcheck, apiBinder.healthcheck], healthchecks: [deviceState.healthcheck, apiBinder.healthcheck],
}); });
@ -103,20 +100,15 @@ async function createAPIOpts(): Promise<SupervisorAPIOpts> {
await db.initialized; await db.initialized;
// Initialize and set values for mocked Config // Initialize and set values for mocked Config
await initConfig(); await initConfig();
// Create EventTracker
const tracker = new EventTracker();
// Create deviceState // Create deviceState
const deviceState = new DeviceState({ const deviceState = new DeviceState({
eventTracker: tracker,
logger: null as any, logger: null as any,
apiBinder: null as any, apiBinder: null as any,
}); });
const apiBinder = new APIBinder({ const apiBinder = new APIBinder({
eventTracker: tracker,
logger: null as any, logger: null as any,
}); });
return { return {
eventTracker: tracker,
deviceState, deviceState,
apiBinder, apiBinder,
}; };
@ -165,7 +157,6 @@ function restoreStubs() {
} }
interface SupervisorAPIOpts { interface SupervisorAPIOpts {
eventTracker: EventTracker;
deviceState: DeviceState; deviceState: DeviceState;
apiBinder: APIBinder; apiBinder: APIBinder;
} }