Skip to content

chore(telemetry): add device ID resolution COMPASS-8443 #1034

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

Merged
merged 3 commits into from
May 5, 2025
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -1338,6 +1338,7 @@
"mongodb-log-writer": "^2.4.1",
"mongodb-query-parser": "^4.3.2",
"mongodb-schema": "^12.6.2",
"node-machine-id": "1.1.12",
"numeral": "^2.0.6",
"query-string": "^7.1.3",
"react": "^18.3.1",
Expand Down
3 changes: 1 addition & 2 deletions src/explorer/helpTree.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,8 +116,7 @@ export default class HelpTree
iconName: 'report',
});

const telemetryUserIdentity =
this._telemetryService?.getTelemetryUserIdentity();
const telemetryUserIdentity = this._telemetryService?.userIdentity;

const atlas = new HelpLinkTreeItem({
title: 'Create Free Atlas Cluster',
Expand Down
2 changes: 1 addition & 1 deletion src/mdbExtensionController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -180,7 +180,7 @@ export default class MDBExtensionController implements vscode.Disposable {
this._explorerController.activateConnectionsTreeView();
this._helpExplorer.activateHelpTreeView();
this._playgroundsExplorer.activatePlaygroundsTreeView();
this._telemetryService.activateSegmentAnalytics();
void this._telemetryService.activateSegmentAnalytics();
this._participantController.createParticipant(this._context);

await this._connectionController.loadSavedConnections();
Expand Down
100 changes: 81 additions & 19 deletions src/telemetry/telemetryService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import path from 'path';
import * as vscode from 'vscode';
import { config } from 'dotenv';
import type { DataService } from 'mongodb-data-service';
import fs from 'fs';
import fs from 'fs/promises';
import { Analytics as SegmentAnalytics } from '@segment/analytics-node';
import { throttle } from 'lodash';

Expand All @@ -18,6 +18,10 @@ import {
SidePanelOpenedTelemetryEvent,
ParticipantResponseFailedTelemetryEvent,
} from './telemetryEvents';
import { createHmac } from 'crypto';

// eslint-disable-next-line @typescript-eslint/no-var-requires
const nodeMachineId = require('node-machine-id');

const log = createLogger('telemetry');
// eslint-disable-next-line @typescript-eslint/no-var-requires
Expand All @@ -26,19 +30,25 @@ const { version } = require('../../package.json');
export type SegmentProperties = {
event: string;
anonymousId: string;
deviceId?: string;
properties: Record<string, any>;
};

/**
* This controller manages telemetry.
*/
export class TelemetryService {
_segmentAnalytics?: SegmentAnalytics;
_segmentAnonymousId: string;
_segmentKey?: string; // The segment API write key.

private _context: vscode.ExtensionContext;
private _shouldTrackTelemetry: boolean; // When tests run the extension, we don't want to track telemetry.
private _segmentAnalytics?: SegmentAnalytics;
public _segmentKey?: string; // The segment API write key.
private eventBuffer: TelemetryEvent[] = [];
private isBufferingEvents = true;
public userIdentity: {
anonymousId: string;
deviceId?: string;
};
private resolveDeviceId: ((value: string) => void) | undefined;
private readonly _context: vscode.ExtensionContext;
private readonly _shouldTrackTelemetry: boolean; // When tests run the extension, we don't want to track telemetry.

constructor(
storageController: StorageController,
Expand All @@ -48,11 +58,12 @@ export class TelemetryService {
const { anonymousId } = storageController.getUserIdentity();
this._context = context;
this._shouldTrackTelemetry = shouldTrackTelemetry || false;
this._segmentAnonymousId = anonymousId;
this._segmentKey = this._readSegmentKey();
this.userIdentity = {
anonymousId,
};
}

private _readSegmentKey(): string | undefined {
private async readSegmentKey(): Promise<string | undefined> {
config({ path: path.join(this._context.extensionPath, '.env') });

try {
Expand All @@ -61,18 +72,21 @@ export class TelemetryService {
'./constants.json'
);
// eslint-disable-next-line no-sync
const constantsFile = fs.readFileSync(segmentKeyFileLocation, 'utf8');
const constantsFile = await fs.readFile(segmentKeyFileLocation, {
encoding: 'utf8',
});
const { segmentKey } = JSON.parse(constantsFile) as {
segmentKey?: string;
};
return segmentKey;
} catch (error) {
log.error('Failed to read segmentKey from the constants file', error);
return;
return undefined;
}
}

activateSegmentAnalytics(): void {
async activateSegmentAnalytics(): Promise<void> {
this._segmentKey = await this.readSegmentKey();
if (!this._segmentKey) {
return;
}
Expand All @@ -83,12 +97,22 @@ export class TelemetryService {
flushInterval: 10000, // 10 seconds is the default libraries' value.
});

const segmentProperties = this.getTelemetryUserIdentity();
this._segmentAnalytics.identify(segmentProperties);
log.info('Segment analytics activated', segmentProperties);
this.userIdentity = await this.getTelemetryUserIdentity();
this._segmentAnalytics.identify(this.userIdentity);
this.isBufferingEvents = false;
log.info('Segment analytics activated', this.userIdentity);

// Process buffered events
if (this.eventBuffer.length > 0) {
Copy link
Contributor

Choose a reason for hiding this comment

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

nit: This conditional doesn't do anything, right?

(alternatively: with these cases I definitely have a mild preference for while (event = this.eventBuffer.shift()), I know it doesn't make a real difference right now, but it's basically the same amount of syntax but provides greater resilience around potential future changes where the buffer or the isBufferingEvents flag could be changed concurrently to the loop)

for (const event of this.eventBuffer) {
this.track(event);
}
this.eventBuffer = [];
}
}

deactivate(): void {
this.resolveDeviceId?.('unknown');
// Flush on demand to make sure that nothing is left in the queue.
void this._segmentAnalytics?.closeAndFlush();
}
Expand Down Expand Up @@ -130,8 +154,13 @@ export class TelemetryService {

track(event: TelemetryEvent): void {
try {
if (this.isBufferingEvents) {
this.eventBuffer.push(event);
return;
}

this._segmentAnalyticsTrack({
...this.getTelemetryUserIdentity(),
...this.userIdentity,
event: event.type,
properties: {
...event.properties,
Expand All @@ -153,9 +182,15 @@ export class TelemetryService {
this.track(new NewConnectionTelemetryEvent(connectionTelemetryProperties));
}

getTelemetryUserIdentity(): { anonymousId: string } {
private async getTelemetryUserIdentity(): Promise<typeof this.userIdentity> {
return {
anonymousId: this._segmentAnonymousId,
anonymousId: this.userIdentity.anonymousId,
deviceId: await Promise.race([
getDeviceId(),
new Promise<string>((resolve) => {
this.resolveDeviceId = resolve;
}),
]),
};
}

Expand Down Expand Up @@ -200,3 +235,30 @@ export class TelemetryService {
{ leading: true, trailing: false }
);
}

/**
* TODO: This hashing function should be moved to devtools-shared.
* @returns A hashed, unique identifier for the running device or `"unknown"` if not known.
*/
export async function getDeviceId({
onError,
}: {
onError?: (error: Error) => void;
} = {}): Promise<string> {
try {
const originalId: string = await nodeMachineId.machineId(true);

// Create a hashed format from the all uppercase version of the machine ID
// to match it exactly with the denisbrodbeck/machineid library that Atlas CLI uses.
const hmac = createHmac('sha256', originalId.toUpperCase());

/** This matches the message used to create the hashes in Atlas CLI */
const DEVICE_ID_HASH_MESSAGE = 'atlascli';

hmac.update(DEVICE_ID_HASH_MESSAGE);
return hmac.digest('hex');
} catch (error) {
onError?.(error as Error);
return 'unknown';
}
}
2 changes: 1 addition & 1 deletion src/test/suite/explorer/helpExplorer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ suite('Help Explorer Test Suite', function () {
assert.strictEqual(atlasHelpItem.label, 'Create Free Atlas Cluster');
assert.strictEqual(atlasHelpItem.url.includes('mongodb.com'), true);
const { anonymousId } =
mdbTestExtension.testExtensionController._telemetryService.getTelemetryUserIdentity();
mdbTestExtension.testExtensionController._telemetryService.userIdentity;
assert.strictEqual(
new URL(atlasHelpItem.url).searchParams.get('ajs_aid'),
anonymousId
Expand Down
Loading
Loading