Skip to content

Commit ccc411c

Browse files
authored
Add cross-product uuid to all telemetry events (#1619)
* add cross product uuid to all telemetry calls * remove need to ts-ignore * add some unit tests * rework creation to new spec * update creation of uuid and config migration * condense if statements
1 parent e7b6ee8 commit ccc411c

File tree

8 files changed

+231
-4
lines changed

8 files changed

+231
-4
lines changed

.vscode/settings.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
"editor.tabSize": 2,
99
"typescript.updateImportsOnFileMove.enabled": "prompt",
1010
"cSpell.words": [
11+
"appdirs",
1112
"camelcase",
1213
"chokidar",
1314
"conda",

extension/package.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1464,6 +1464,7 @@
14641464
"dependencies": {
14651465
"@hediet/std": "^0.6.0",
14661466
"@vscode/extension-telemetry": "^0.4.10",
1467+
"appdirs": "^1.1.0",
14671468
"execa": "^5.1.1",
14681469
"fs-extra": "^10.0.0",
14691470
"js-yaml": "^4.1.0",
@@ -1473,7 +1474,8 @@
14731474
"lodash.isequal": "^4.5.0",
14741475
"lodash.merge": "^4.6.2",
14751476
"lodash.omit": "^4.5.0",
1476-
"tree-kill": "^1.2.2"
1477+
"tree-kill": "^1.2.2",
1478+
"uuid": "^8.3.2"
14771479
},
14781480
"devDependencies": {
14791481
"@types/chai": "^4.2.22",
@@ -1493,6 +1495,7 @@
14931495
"@types/node": "^16.11.8",
14941496
"@types/react-vega": "^7.0.0",
14951497
"@types/sinon-chai": "^3.2.6",
1498+
"@types/uuid": "^8.3.4",
14961499
"@types/vscode": "^1.64.0",
14971500
"@vscode/test-electron": "^2.1.3",
14981501
"chai": "^4.2.0",

extension/src/fileSystem/index.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,8 @@ import {
44
lstatSync,
55
readdir,
66
readFileSync,
7-
removeSync
7+
removeSync,
8+
writeFileSync
89
} from 'fs-extra'
910
import { load } from 'js-yaml'
1011
import { Uri } from 'vscode'
@@ -91,3 +92,16 @@ export const loadYaml = <T>(path: string): T | undefined => {
9192
Logger.error(`failed to load yaml ${path}`)
9293
}
9394
}
95+
96+
export const loadJson = <T>(path: string): T | undefined => {
97+
try {
98+
return JSON.parse(readFileSync(path).toString()) as T
99+
} catch {
100+
Logger.error(`failed to load JSON from ${path}`)
101+
}
102+
}
103+
104+
export const writeJson = <T extends Record<string, unknown>>(
105+
path: string,
106+
obj: T
107+
): void => writeFileSync(path, JSON.stringify(obj))

extension/src/telemetry/index.test.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
EXTENSION_ID,
77
IEventNamePropertyMapping
88
} from './constants'
9+
import { getUserId } from './uuid'
910

1011
const mockedTelemetryReporter = jest.mocked(TelemetryReporter)
1112

@@ -18,7 +19,9 @@ const mockedPackageJSON = {
1819
version: '0.1.0'
1920
}
2021
const mockedSendTelemetryEvent = jest.fn()
22+
const mockedGetUserId = jest.mocked(getUserId)
2123

24+
jest.mock('./uuid')
2225
jest.mock('@vscode/extension-telemetry')
2326
jest.mock('vscode')
2427

@@ -70,6 +73,8 @@ describe('getTelemetryReporter', () => {
7073

7174
describe('sendTelemetryEvent', () => {
7275
it('should call the reporter with the correct event name and sanitized parameters', () => {
76+
const mockedUserId = 'fbaff2be-6cde-4c94-ae98-b2e35e562712'
77+
mockedGetUserId.mockReturnValueOnce(mockedUserId)
7378
const mockedEventName = 'mockedEvent' as keyof IEventNamePropertyMapping
7479
const mockedEventProperties = {
7580
a: 1,
@@ -95,7 +100,8 @@ describe('sendTelemetryEvent', () => {
95100
a: '1',
96101
b: '{"c":2,"d":{"e":"3"}}',
97102
h: 'some string',
98-
i: 'true'
103+
i: 'true',
104+
user_id: mockedUserId
99105
},
100106
mockedMeasurements
101107
)

extension/src/telemetry/index.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
IEventNamePropertyMapping,
66
ViewOpenedEventName
77
} from './constants'
8+
import { getUserId } from './uuid'
89
import { Logger } from '../common/logger'
910
import { getExtensionVersion } from '../vscode/extensions'
1011

@@ -90,7 +91,7 @@ export const sendTelemetryEvent = <
9091
: undefined
9192
reporter.sendTelemetryEvent(
9293
eventName as string,
93-
sanitizedProperties,
94+
{ ...sanitizedProperties, user_id: getUserId() },
9495
measurements
9596
)
9697
}
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
import { sep } from 'path'
2+
import { v4 } from 'uuid'
3+
import { getUserId, readOrCreateUserId } from './uuid'
4+
import { exists, loadJson, writeJson } from '../fileSystem'
5+
6+
const mockedExists = jest.mocked(exists)
7+
const mockedReadJson = jest.mocked(loadJson)
8+
const mockedWriteJson = jest.mocked(writeJson)
9+
10+
jest.mock('../fileSystem')
11+
12+
beforeEach(() => {
13+
jest.resetAllMocks()
14+
})
15+
16+
const mockedUserId = v4()
17+
const mockedConfig = { 'do-not-track': true, user_id: mockedUserId }
18+
19+
describe('readOrCreateUserId', () => {
20+
it('should create two completely new configs if neither config can be found', () => {
21+
mockedExists.mockReturnValueOnce(false).mockReturnValueOnce(false)
22+
23+
readOrCreateUserId()
24+
expect(mockedReadJson).not.toBeCalled()
25+
expect(mockedWriteJson).toBeCalledTimes(2)
26+
expect(mockedWriteJson).toBeCalledWith(
27+
expect.stringContaining(sep + 'telemetry'),
28+
expect.objectContaining({ user_id: expect.stringContaining('-') })
29+
)
30+
})
31+
32+
it('should migrate the legacy DVC config if the new config cannot be found', () => {
33+
mockedExists.mockReturnValueOnce(true).mockReturnValueOnce(false)
34+
mockedReadJson.mockReturnValueOnce(mockedConfig)
35+
36+
readOrCreateUserId()
37+
expect(mockedWriteJson).toBeCalledTimes(1)
38+
expect(mockedWriteJson).toBeCalledWith(
39+
expect.stringContaining(sep + 'telemetry'),
40+
mockedConfig
41+
)
42+
expect(mockedWriteJson).not.toBeCalledWith(
43+
expect.stringContaining('dvc'),
44+
mockedConfig
45+
)
46+
})
47+
48+
it('should create a legacy config if only the new one can be found', () => {
49+
mockedExists.mockReturnValueOnce(false).mockReturnValueOnce(true)
50+
mockedReadJson.mockReturnValueOnce(mockedConfig)
51+
52+
readOrCreateUserId()
53+
expect(mockedReadJson).toBeCalledTimes(1)
54+
expect(mockedWriteJson).toBeCalledTimes(1)
55+
56+
expect(mockedWriteJson).not.toBeCalledWith(
57+
expect.stringContaining(sep + 'telemetry'),
58+
mockedConfig
59+
)
60+
61+
expect(mockedWriteJson).toBeCalledWith(
62+
expect.stringContaining('dvc'),
63+
expect.objectContaining({ user_id: expect.stringContaining('-') })
64+
)
65+
})
66+
67+
it('should overwrite the user_id in the new config if it differs from the legacy id', () => {
68+
mockedExists.mockReturnValueOnce(true).mockReturnValueOnce(true)
69+
mockedReadJson
70+
.mockReturnValueOnce(mockedConfig)
71+
.mockReturnValueOnce({ 'some-other-info': true, user_id: 'bogus-id' })
72+
73+
readOrCreateUserId()
74+
expect(mockedReadJson).toBeCalledTimes(2)
75+
expect(mockedWriteJson).toBeCalledTimes(1)
76+
77+
expect(mockedWriteJson).toBeCalledWith(
78+
expect.stringContaining(sep + 'telemetry'),
79+
{
80+
'do-not-track': true,
81+
'some-other-info': true,
82+
user_id: expect.not.stringMatching('bogus-id')
83+
}
84+
)
85+
})
86+
})
87+
88+
describe('getUserId', () => {
89+
it('should only try to access the value from the fileSystem on the first call', () => {
90+
mockedExists.mockReturnValueOnce(true)
91+
mockedReadJson.mockReturnValueOnce(mockedConfig)
92+
93+
const user_id = getUserId()
94+
95+
expect(user_id).toStrictEqual(mockedUserId)
96+
expect(mockedExists).toBeCalledTimes(2)
97+
expect(mockedReadJson).toBeCalledTimes(1)
98+
99+
mockedExists.mockClear()
100+
mockedReadJson.mockClear()
101+
102+
getUserId()
103+
getUserId()
104+
getUserId()
105+
106+
expect(mockedExists).not.toBeCalled()
107+
expect(mockedReadJson).not.toBeCalled()
108+
})
109+
})

extension/src/telemetry/uuid.ts

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import { join } from 'path'
2+
import isEmpty from 'lodash.isempty'
3+
import { v4 } from 'uuid'
4+
import { getProcessPlatform } from '../env'
5+
import { exists, loadJson, writeJson } from '../fileSystem'
6+
7+
type UserConfig = {
8+
user_id?: string
9+
}
10+
11+
const loadConfig = (configPath: string): UserConfig => {
12+
if (!exists(configPath)) {
13+
return {}
14+
}
15+
16+
return loadJson<UserConfig>(configPath) || {}
17+
}
18+
19+
const writeMissingConfigs = (
20+
user_id: string,
21+
legacyConfig: UserConfig,
22+
legacyConfigPath: string,
23+
config: UserConfig,
24+
configPath: string
25+
) => {
26+
if (isEmpty(legacyConfig) && isEmpty(config)) {
27+
writeJson(legacyConfigPath, { user_id })
28+
writeJson(configPath, { user_id })
29+
return
30+
}
31+
32+
if (isEmpty(config) || config.user_id !== user_id) {
33+
writeJson(configPath, { ...config, ...legacyConfig, user_id })
34+
return
35+
}
36+
37+
if (isEmpty(legacyConfig)) {
38+
writeJson(legacyConfigPath, { ...config, user_id })
39+
}
40+
}
41+
42+
const readOrCreateConfig = (): string | undefined => {
43+
const { userConfigDir } = require('appdirs') as {
44+
userConfigDir: (appName: string) => string
45+
}
46+
47+
const legacyDirectory =
48+
getProcessPlatform() === 'win32'
49+
? join('iterative', 'dvc', 'user_id')
50+
: join('dvc', 'user_id')
51+
52+
const legacyConfigPath = userConfigDir(legacyDirectory)
53+
const legacyConfig = loadConfig(legacyConfigPath)
54+
55+
const configPath = userConfigDir(join('iterative', 'telemetry'))
56+
const config = loadConfig(configPath)
57+
58+
const user_id = legacyConfig.user_id || config.user_id || v4()
59+
60+
if (legacyConfig.user_id !== user_id || config.user_id !== user_id) {
61+
writeMissingConfigs(
62+
user_id,
63+
legacyConfig,
64+
legacyConfigPath,
65+
config,
66+
configPath
67+
)
68+
}
69+
70+
return user_id
71+
}
72+
73+
export const readOrCreateUserId = () => {
74+
return readOrCreateConfig() || 'unknown'
75+
}
76+
77+
let user_id: string
78+
export const getUserId = (): string => {
79+
if (!user_id) {
80+
user_id = readOrCreateUserId()
81+
}
82+
return user_id
83+
}

yarn.lock

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3466,6 +3466,11 @@
34663466
resolved "https://registry.yarnpkg.com/@types/unist/-/unist-2.0.6.tgz#250a7b16c3b91f672a24552ec64678eeb1d3a08d"
34673467
integrity sha512-PBjIUxZHOuj0R15/xuwJYjFi+KZdNFrehocChv4g5hu6aFroHue8m0lBP0POdK2nKzbw0cgV1mws8+V/JAcEkQ==
34683468

3469+
"@types/uuid@^8.3.4":
3470+
version "8.3.4"
3471+
resolved "https://registry.yarnpkg.com/@types/uuid/-/uuid-8.3.4.tgz#bd86a43617df0594787d38b735f55c805becf1bc"
3472+
integrity sha512-c/I8ZRb51j+pYGAu5CrFMRxqZ2ke4y2grEBO5AUjgSkSk+qT2Ea+OdWElz/OiMf5MNpn2b17kuVBwZLQJXzihw==
3473+
34693474
"@types/vscode@^1.64.0":
34703475
version "1.64.0"
34713476
resolved "https://registry.yarnpkg.com/@types/vscode/-/vscode-1.64.0.tgz#bfd82c8d92dc7824c1be084be1ab46ce20d7fb55"
@@ -4250,6 +4255,11 @@ app-root-dir@^1.0.2:
42504255
resolved "https://registry.yarnpkg.com/app-root-dir/-/app-root-dir-1.0.2.tgz#38187ec2dea7577fff033ffcb12172692ff6e118"
42514256
integrity sha1-OBh+wt6nV3//Az/8sSFyaS/24Rg=
42524257

4258+
appdirs@^1.1.0:
4259+
version "1.1.0"
4260+
resolved "https://registry.yarnpkg.com/appdirs/-/appdirs-1.1.0.tgz#38b9c9e1d7e76908fa9f5c69b79cb03074f8395a"
4261+
integrity sha1-OLnJ4dfnaQj6n1xpt5ywMHT4OVo=
4262+
42534263
append-transform@^2.0.0:
42544264
version "2.0.0"
42554265
resolved "https://registry.yarnpkg.com/append-transform/-/append-transform-2.0.0.tgz#99d9d29c7b38391e6f428d28ce136551f0b77e12"

0 commit comments

Comments
 (0)