Skip to content

Commit 9c3b617

Browse files
authored
feat: inso analytics [INS-1834] (Kong#9621)
* feat: inso analytics [INS-1834] * formatting + circular import fix * fix: don't lose overrides when memDB contains Settings * refactor: alleviate circular import additions * review changes * reduce refs to db type
1 parent 789769c commit 9c3b617

File tree

23 files changed

+325
-110
lines changed

23 files changed

+325
-110
lines changed

package-lock.json

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/insomnia-inso/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@
5151
"shellwords": "^1.0.1"
5252
},
5353
"dependencies": {
54+
"@segment/analytics-node": "^2.2.1",
5455
"@seald-io/nedb": "^4.1.1",
5556
"@stoplight/spectral-core": "^1.20.0",
5657
"@stoplight/spectral-formats": "^1.8.2",
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { beforeEach, describe, expect, it, vi } from 'vitest';
2+
3+
const mockTrack = vi.fn();
4+
5+
vi.mock('@segment/analytics-node', () => ({
6+
Analytics: vi.fn(() => ({
7+
track: mockTrack,
8+
closeAndFlush: vi.fn(),
9+
})),
10+
}));
11+
12+
vi.mock('./db/adapters/ne-db-adapter', () => ({
13+
default: vi.fn().mockResolvedValue(null),
14+
}));
15+
16+
describe('analytics', () => {
17+
beforeEach(() => {
18+
vi.stubEnv('NODE_ENV', 'production');
19+
vi.resetModules();
20+
mockTrack.mockClear();
21+
});
22+
23+
it('should use the same anonymousId for multiple trackInsoEvent calls', async () => {
24+
const { trackInsoEvent, InsoEvent } = await import('./analytics');
25+
26+
await trackInsoEvent(InsoEvent.lintSpec);
27+
await trackInsoEvent(InsoEvent.exportSpec);
28+
29+
expect(mockTrack).toHaveBeenCalledTimes(2);
30+
31+
const firstCallAnonymousId = mockTrack.mock.calls[0][0].anonymousId;
32+
const secondCallAnonymousId = mockTrack.mock.calls[1][0].anonymousId;
33+
34+
expect(firstCallAnonymousId).toBe(secondCallAnonymousId);
35+
expect(firstCallAnonymousId).toMatch(/^anon_/);
36+
});
37+
});
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
import os from 'node:os';
2+
3+
import { Analytics } from '@segment/analytics-node';
4+
import { getSegmentWriteKey } from 'insomnia/src/common/constants';
5+
import type { Settings } from 'insomnia/src/models/settings';
6+
import { v4 as uuidv4 } from 'uuid';
7+
8+
import packageJson from '../package.json';
9+
import neDbAdapter from './db/adapters/ne-db-adapter';
10+
import { getAppDataDir, getDefaultProductName } from './util';
11+
12+
export enum InsoEvent {
13+
runTest = 'inso_run_test',
14+
runCollection = 'inso_run_collection',
15+
lintSpec = 'inso_lint_spec',
16+
exportSpec = 'inso_export_spec',
17+
script = 'inso_script',
18+
}
19+
20+
const analyticsClient = new Analytics({ writeKey: getSegmentWriteKey() });
21+
let deviceId: string | null = null;
22+
let localSettings: Settings | null = null;
23+
24+
const getLocalSettings = async (): Promise<Settings | null> => {
25+
if (localSettings) {
26+
return localSettings;
27+
}
28+
29+
try {
30+
const appDataDir = getAppDataDir(getDefaultProductName());
31+
const db = await neDbAdapter(appDataDir, ['Settings']);
32+
localSettings = db?.Settings?.[0] ?? null;
33+
return localSettings;
34+
} catch {
35+
return null;
36+
}
37+
};
38+
39+
const getDeviceId = async (): Promise<string> => {
40+
if (deviceId) {
41+
return deviceId;
42+
}
43+
44+
try {
45+
const settings = await getLocalSettings();
46+
if (settings?.deviceId) {
47+
deviceId = settings.deviceId;
48+
return deviceId;
49+
}
50+
} catch {}
51+
52+
deviceId = `anon_${uuidv4()}`;
53+
return deviceId;
54+
};
55+
56+
const getOsName = (): string => {
57+
switch (process.platform) {
58+
case 'darwin': {
59+
return 'mac';
60+
}
61+
case 'win32': {
62+
return 'windows';
63+
}
64+
default: {
65+
return process.platform;
66+
}
67+
}
68+
};
69+
70+
export const trackInsoEvent = async (event: InsoEvent, properties?: Record<string, unknown>): Promise<void> => {
71+
if (process.env.NODE_ENV === 'test') {
72+
return;
73+
}
74+
75+
const settings = await getLocalSettings();
76+
if (settings && !settings.enableAnalytics) {
77+
return;
78+
}
79+
80+
try {
81+
const anonymousId = await getDeviceId();
82+
const version = process.env.VERSION || packageJson.version;
83+
84+
analyticsClient.track(
85+
{
86+
event,
87+
anonymousId,
88+
properties: {
89+
...properties,
90+
platform: 'cli',
91+
},
92+
context: {
93+
app: {
94+
name: 'inso',
95+
version,
96+
},
97+
os: {
98+
name: getOsName(),
99+
version: os.release(),
100+
},
101+
},
102+
},
103+
() => {
104+
// Silently fail
105+
},
106+
);
107+
} catch {
108+
// Silently fail
109+
}
110+
};
111+
112+
export const flushAnalytics = async (): Promise<void> => {
113+
try {
114+
await analyticsClient.closeAndFlush({ timeout: 5000 });
115+
} catch {
116+
// Silently fail
117+
}
118+
};

packages/insomnia-inso/src/cli.ts

Lines changed: 40 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import fs from 'node:fs';
22
import { readFile } from 'node:fs/promises';
3-
import { homedir } from 'node:os';
43
import nodePath from 'node:path';
54

65
import * as commander from 'commander';
@@ -26,10 +25,10 @@ import type { Workspace } from '~/models/workspace';
2625

2726
import type { RequestTestResult } from '../../insomnia-scripting-environment/src/objects';
2827
import packageJson from '../package.json';
28+
import { flushAnalytics, InsoEvent, trackInsoEvent } from './analytics';
2929
import { exportSpecification, writeFileWithCliOptions } from './commands/export-specification';
3030
import { getRuleSetFileFromFolderByFilename, lintSpecification } from './commands/lint-specification';
3131
import { RunCollectionResultReport } from './commands/run-collection/result-report';
32-
import type { Database } from './db';
3332
import { isFile, loadDb } from './db';
3433
import { insomniaExportAdapter } from './db/adapters/insomnia-adapter';
3534
import { loadApiSpec, promptApiSpec } from './db/models/api-spec';
@@ -38,9 +37,12 @@ import type { BaseModel } from './db/models/types';
3837
import { loadTestSuites, promptTestSuites } from './db/models/unit-test-suite';
3938
import { matchIdIsh } from './db/models/util';
4039
import { loadWorkspace, promptWorkspace } from './db/models/workspace';
41-
import { BasicReporter, logger, LogLevel } from './logger';
40+
import type { Database } from './db/types';
41+
import { InsoError } from './errors';
42+
import { BasicReporter, logger,LogLevel } from './logger';
4243
import { logTestResult, logTestResultSummary, reporterTypes, type TestReporter } from './reporter';
4344
import { generateDocumentation } from './scripts/docs';
45+
import { getAppDataDir, getDefaultProductName } from './util';
4446

4547
export interface GlobalOptions {
4648
ci: boolean;
@@ -89,45 +91,6 @@ export const tryToReadInsoConfigFile = async (configFile?: string, workingDir?:
8991
return {};
9092
};
9193

92-
export class InsoError extends Error {
93-
cause?: Error | null;
94-
95-
constructor(message: string, cause?: Error) {
96-
super(message);
97-
this.name = 'InsoError';
98-
this.cause = cause;
99-
}
100-
}
101-
102-
/**
103-
* getAppDataDir returns the data directory for an Electron app,
104-
* it is equivalent to the app.getPath('userData') API in Electron.
105-
* https://www.electronjs.org/docs/api/app#appgetpathname
106-
*/
107-
export function getAppDataDir(app: string): string {
108-
switch (process.platform) {
109-
case 'darwin': {
110-
return nodePath.join(homedir(), 'Library', 'Application Support', app);
111-
}
112-
case 'win32': {
113-
return nodePath.join(process.env.APPDATA || nodePath.join(homedir(), 'AppData', 'Roaming'), app);
114-
}
115-
case 'linux': {
116-
return nodePath.join(process.env.XDG_DATA_HOME || nodePath.join(homedir(), '.config'), app);
117-
}
118-
default: {
119-
throw new Error('Unsupported platform');
120-
}
121-
}
122-
}
123-
export const getDefaultProductName = (): string => {
124-
const name = process.env.DEFAULT_APP_NAME;
125-
if (!name) {
126-
throw new Error('Environment variable DEFAULT_APP_NAME is not set.');
127-
}
128-
return name;
129-
};
130-
13194
const localAppDir = getAppDataDir(getDefaultProductName());
13295

13396
export const getAbsoluteFilePath = ({ workingDir, file }: { workingDir?: string; file: string }) => {
@@ -504,8 +467,15 @@ export const go = (args?: string[]) => {
504467

505468
// TODO: is this necessary?
506469
const success = options.verbose ? await runTestPromise : await noConsoleLog(() => runTestPromise);
470+
471+
await trackInsoEvent(InsoEvent.runTest, { success });
472+
await flushAnalytics();
473+
507474
return process.exit(success ? 0 : 1);
508475
} catch (error) {
476+
await trackInsoEvent(InsoEvent.runTest, { success: false });
477+
await flushAnalytics();
478+
509479
logErrorAndExit(error);
510480
}
511481
return process.exit(1);
@@ -892,11 +862,18 @@ export const go = (args?: string[]) => {
892862
logTestResultSummary(testResultsQueue);
893863

894864
await report.saveReport();
865+
866+
await trackInsoEvent(InsoEvent.runCollection, { success });
867+
await flushAnalytics();
868+
895869
return process.exit(success ? 0 : 1);
896870
} catch (error) {
897871
report.update({ error: (error instanceof Error ? error.message : String(error)) || 'Unknown error' });
898872
await report.saveReport();
899873

874+
await trackInsoEvent(InsoEvent.runCollection, { success: false });
875+
await flushAnalytics();
876+
900877
logErrorAndExit(error);
901878
}
902879
return process.exit(1);
@@ -948,8 +925,15 @@ export const go = (args?: string[]) => {
948925

949926
try {
950927
const { isValid } = await lintSpecification({ specContent, rulesetFileName });
928+
929+
await trackInsoEvent(InsoEvent.lintSpec, { success: isValid });
930+
await flushAnalytics();
931+
951932
return process.exit(isValid ? 0 : 1);
952933
} catch (error) {
934+
await trackInsoEvent(InsoEvent.lintSpec, { success: false });
935+
await flushAnalytics();
936+
953937
logErrorAndExit(error);
954938
}
955939
return process.exit(1);
@@ -980,12 +964,23 @@ export const go = (args?: string[]) => {
980964
options.output && getAbsoluteFilePath({ workingDir: options.workingDir, file: options.output });
981965
if (!outputPath) {
982966
logger.log(toExport);
967+
968+
await trackInsoEvent(InsoEvent.exportSpec, { success: true });
969+
await flushAnalytics();
970+
983971
return process.exit(0);
984972
}
985973
const filePath = await writeFileWithCliOptions(outputPath, toExport);
986974
logger.log(`Specification exported to "${filePath}".`);
975+
976+
await trackInsoEvent(InsoEvent.exportSpec, { success: true });
977+
await flushAnalytics();
978+
987979
return process.exit(0);
988980
} catch (error) {
981+
await trackInsoEvent(InsoEvent.exportSpec, { success: false });
982+
await flushAnalytics();
983+
989984
logErrorAndExit(error);
990985
}
991986
return process.exit(1);
@@ -1020,6 +1015,9 @@ export const go = (args?: string[]) => {
10201015

10211016
logger.debug(`>> ${scriptArgs.slice(1).join(' ')}`);
10221017

1018+
// Track script invocation - the underlying command will track its own success/failure
1019+
await trackInsoEvent(InsoEvent.script);
1020+
10231021
program.parseAsync(scriptArgs).catch(logErrorAndExit);
10241022
});
10251023

packages/insomnia-inso/src/commands/export-specification.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import path from 'node:path';
33

44
import YAML from 'yaml';
55

6-
import { InsoError } from '../cli';
6+
import { InsoError } from '../errors';
77

88
export async function writeFileWithCliOptions(outputPath: string, contents: string): Promise<string> {
99
try {

packages/insomnia-inso/src/commands/lint-specification.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import path from 'node:path';
88
import { oas } from '@stoplight/spectral-rulesets';
99
import { DiagnosticSeverity } from '@stoplight/types';
1010

11-
import { InsoError } from '../cli';
11+
import { InsoError } from '../errors';
1212
import { logger } from '../logger';
1313
export const getRuleSetFileFromFolderByFilename = async (filePath: string) => {
1414
try {

packages/insomnia-inso/src/db/adapters/git-adapter.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,8 @@ import path from 'node:path';
33

44
import YAML from 'yaml';
55

6-
import type { Database, DbAdapter } from '../index';
7-
import { emptyDb } from '../index';
6+
import type { Database, DbAdapter } from '../types';
7+
import { emptyDb } from '../types';
88

99
const gitAdapter: DbAdapter = async (dir, filterTypes) => {
1010
// Confirm if model directories exist

packages/insomnia-inso/src/db/adapters/insomnia-adapter.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,10 @@ import path from 'node:path';
44
import { importInsomniaV5Data } from 'insomnia/src/common/insomnia-v5';
55
import YAML from 'yaml';
66

7-
import { InsoError } from '../../cli';
8-
import type { DbAdapter } from '../index';
9-
import { emptyDb } from '../index';
7+
import { InsoError } from '../../errors';
108
import type { BaseModel } from '../models/types';
9+
import type { DbAdapter } from '../types';
10+
import { emptyDb } from '../types';
1111

1212
/**
1313
* When exporting from Insomnia, the `models.[kind].type` is converted from PascalCase to snake_case.

packages/insomnia-inso/src/db/adapters/ne-db-adapter.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,9 @@ import path from 'node:path';
33

44
import NeDB from '@seald-io/nedb';
55

6-
import type { Database, DbAdapter } from '../index';
7-
import { emptyDb } from '../index';
86
import type { BaseModel } from '../models/types';
7+
import type { Database, DbAdapter } from '../types';
8+
import { emptyDb } from '../types';
99

1010
const neDbAdapter: DbAdapter = async (dir, filterTypes) => {
1111
// Confirm if db files exist

0 commit comments

Comments
 (0)