Skip to content

Commit 6bd7267

Browse files
atennak1Akila Tennakoonmanodnyab
authored
fix(cloudformation): handle telemetry setting in upgrade path case where setting is not registered (#8343)
## Problem When the extension is upgraded and user does not do a full restart, it is possible for new cloudformation telemetry setting to not be registered yet, resulting in error trying to save it. ## Solution Skip the save and do it the next time the extension starts. --- - Treat all work as PUBLIC. Private `feature/x` branches will not be squash-merged at release time. - Your code changes must meet the guidelines in [CONTRIBUTING.md](https://github.com/aws/aws-toolkit-vscode/blob/master/CONTRIBUTING.md#guidelines). - License: I confirm that my contribution is made under the terms of the Apache 2.0 license. --------- Co-authored-by: Akila Tennakoon <[email protected]> Co-authored-by: manodnyab <[email protected]>
1 parent 220d65c commit 6bd7267

File tree

4 files changed

+259
-43
lines changed

4 files changed

+259
-43
lines changed

packages/core/src/awsService/cloudformation/extension.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ import { AwsCredentialsService, encryptionKey } from './auth/credentials'
5252
import { ExtensionId, ExtensionName, Version, CloudFormationTelemetrySettings } from './extensionConfig'
5353
import { commandKey } from './utils'
5454
import { CloudFormationExplorer } from './explorer/explorer'
55-
import { promptTelemetryOptInWithTimeout } from './telemetryOptIn'
55+
import { handleTelemetryOptIn } from './telemetryOptIn'
5656

5757
import { refreshCommand, StacksManager } from './stacks/stacksManager'
5858
import { StackOverviewWebviewProvider } from './ui/stackOverviewWebviewProvider'
@@ -89,7 +89,7 @@ let clientDisposables: Disposable[] = []
8989

9090
async function startClient(context: ExtensionContext) {
9191
const cfnTelemetrySettings = new CloudFormationTelemetrySettings()
92-
const telemetryEnabled = await promptTelemetryOptInWithTimeout(context, cfnTelemetrySettings)
92+
const telemetryEnabled = await handleTelemetryOptIn(context, cfnTelemetrySettings)
9393

9494
const cfnLspConfig = {
9595
...DevSettings.instance.getServiceConfig('cloudformationLsp', {}),

packages/core/src/awsService/cloudformation/telemetryOptIn.ts

Lines changed: 132 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -7,74 +7,165 @@ import { ExtensionContext, env, Uri, window } from 'vscode'
77
import { CloudFormationTelemetrySettings } from './extensionConfig'
88
import { commandKey } from './utils'
99
import { isAutomation } from '../../shared/vscode/env'
10+
import { getLogger } from '../../shared/logger/logger'
11+
import globals from '../../shared/extensionGlobals'
1012

11-
export async function promptTelemetryOptInWithTimeout(
12-
context: ExtensionContext,
13-
cfnTelemetrySettings: CloudFormationTelemetrySettings
14-
): Promise<boolean> {
15-
const promptPromise = promptTelemetryOptIn(context, cfnTelemetrySettings)
16-
const timeoutPromise = new Promise<false>((resolve) => setTimeout(() => resolve(false), 2500))
13+
enum TelemetryChoice {
14+
Allow = 'Yes, Allow',
15+
Later = 'Not Now',
16+
Never = 'Never',
17+
LearnMore = 'Learn More',
18+
}
1719

18-
const result = await Promise.race([promptPromise, timeoutPromise])
20+
const telemetryKeys = {
21+
hasResponded: commandKey('telemetry.hasResponded'),
22+
lastPromptDate: commandKey('telemetry.lastPromptDate'),
23+
unpersistedResponse: commandKey('telemetry.unpersistedResponse'),
24+
} as const
1925

20-
// Keep prompt alive in background
21-
void promptPromise
26+
const telemetrySettings = {
27+
enabled: 'enabled',
28+
} as const
2229

23-
return result
24-
}
30+
const thirtyDaysMs = 30 * 24 * 60 * 60 * 1000
31+
const promptTimeoutMs = 2500
32+
const telemetryDocsUrl = 'https://github.com/aws-cloudformation/cloudformation-languageserver/tree/main/src/telemetry'
2533

2634
/* eslint-disable aws-toolkits/no-banned-usages */
27-
async function promptTelemetryOptIn(
35+
export async function handleTelemetryOptIn(
2836
context: ExtensionContext,
2937
cfnTelemetrySettings: CloudFormationTelemetrySettings
3038
): Promise<boolean> {
31-
const telemetryEnabled = cfnTelemetrySettings.get('enabled', false)
32-
if (isAutomation()) {
33-
return telemetryEnabled
39+
// If previous choice failed to persist, persist it now and return
40+
const unpersistedResponse = (await context.globalState.get(telemetryKeys.unpersistedResponse)) as string
41+
const hasResponded = context.globalState.get<boolean>(telemetryKeys.hasResponded)
42+
const lastPromptDate = context.globalState.get<number>(telemetryKeys.lastPromptDate)
43+
if (unpersistedResponse) {
44+
// May still raise popup if user lacks permission or file is corrupted
45+
const didSave = await saveTelemetryResponse(unpersistedResponse, cfnTelemetrySettings)
46+
await context.globalState.update(telemetryKeys.unpersistedResponse, undefined)
47+
// If we still couldn't save, clear everything so they get asked again until the file/perms is fixed
48+
if (!didSave) {
49+
getLogger().warn(
50+
'CloudFormation telemetry choice was not saved successfully after restart. Clearing related globalState keys for next restart'
51+
)
52+
await context.globalState.update(telemetryKeys.hasResponded, undefined)
53+
await context.globalState.update(telemetryKeys.lastPromptDate, undefined)
54+
}
55+
return logAndReturnTelemetryChoice(
56+
unpersistedResponse === TelemetryChoice.Allow.toString(),
57+
hasResponded,
58+
lastPromptDate
59+
)
3460
}
3561

36-
const hasResponded = context.globalState.get<boolean>(commandKey('telemetry.hasResponded'), false)
37-
const lastPromptDate = context.globalState.get<number>(commandKey('telemetry.lastPromptDate'), 0)
38-
const now = Date.now()
39-
const thirtyDaysMs = 30 * 24 * 60 * 60 * 1000
62+
// Never throws because we provide a default
63+
const telemetryEnabled = cfnTelemetrySettings.get(telemetrySettings.enabled, false)
64+
65+
if (isAutomation()) {
66+
return logAndReturnTelemetryChoice(telemetryEnabled)
67+
}
4068

4169
// If user has permanently responded, use their choice
4270
if (hasResponded) {
43-
return telemetryEnabled
71+
return logAndReturnTelemetryChoice(telemetryEnabled, hasResponded)
4472
}
4573

4674
// Check if we should show reminder (30 days since last prompt)
47-
const shouldPrompt = lastPromptDate === 0 || now - lastPromptDate >= thirtyDaysMs
75+
const shouldPrompt = lastPromptDate === undefined || globals.clock.Date.now() - lastPromptDate >= thirtyDaysMs
4876
if (!shouldPrompt) {
49-
return telemetryEnabled
77+
return logAndReturnTelemetryChoice(telemetryEnabled, hasResponded, lastPromptDate)
78+
}
79+
80+
// Show prompt but set false if timeout
81+
const promptPromise = promptTelemetryOptIn(context, cfnTelemetrySettings)
82+
const timeoutPromise = new Promise<false>((resolve) =>
83+
globals.clock.setTimeout(() => resolve(false), promptTimeoutMs)
84+
)
85+
const result = await Promise.race([promptPromise, timeoutPromise])
86+
87+
// Keep prompt alive in background
88+
void promptPromise
89+
90+
return logAndReturnTelemetryChoice(result)
91+
}
92+
/**
93+
* Updates the telemetry setting. In case of error, the update calls do not throw.
94+
* They instead raise a popup and return false.
95+
*
96+
* @returns boolean whether the save/update was successful
97+
*/
98+
/* eslint-disable aws-toolkits/no-banned-usages */
99+
async function saveTelemetryResponse(
100+
response: string | undefined,
101+
cfnTelemetrySettings: CloudFormationTelemetrySettings
102+
): Promise<boolean> {
103+
if (response === TelemetryChoice.Allow) {
104+
return await cfnTelemetrySettings.update(telemetrySettings.enabled, true)
105+
} else if (response === TelemetryChoice.Never) {
106+
return await cfnTelemetrySettings.update(telemetrySettings.enabled, false)
107+
} else if (response === TelemetryChoice.Later) {
108+
return await cfnTelemetrySettings.update(telemetrySettings.enabled, false)
50109
}
110+
return false
111+
}
51112

113+
function logAndReturnTelemetryChoice(choice: boolean, hasResponded?: boolean, lastPromptDate?: number): boolean {
114+
getLogger().info(
115+
'CloudFormation telemetry: choice=%s, hasResponded=%s, lastPromptDate=%s',
116+
choice,
117+
hasResponded,
118+
lastPromptDate
119+
)
120+
return choice
121+
}
122+
123+
/* eslint-disable aws-toolkits/no-banned-usages */
124+
async function promptTelemetryOptIn(
125+
context: ExtensionContext,
126+
cfnTelemetrySettings: CloudFormationTelemetrySettings
127+
): Promise<boolean> {
52128
const message =
53129
'Help us improve the AWS CloudFormation Language Server by sharing anonymous telemetry data with AWS. You can change this preference at any time in aws.cloudformation Settings.'
54130

55-
const allow = 'Yes, Allow'
56-
const later = 'Not Now'
57-
const never = 'Never'
58-
const learnMore = 'Learn More'
59-
const response = await window.showInformationMessage(message, allow, later, never, learnMore)
131+
const response = await window.showInformationMessage(
132+
message,
133+
TelemetryChoice.Allow,
134+
TelemetryChoice.Later,
135+
TelemetryChoice.Never,
136+
TelemetryChoice.LearnMore
137+
)
60138

61-
if (response === learnMore) {
62-
await env.openExternal(
63-
Uri.parse('https://github.com/aws-cloudformation/cloudformation-languageserver/tree/main/src/telemetry')
64-
)
139+
if (response === TelemetryChoice.LearnMore) {
140+
await env.openExternal(Uri.parse(telemetryDocsUrl))
65141
return promptTelemetryOptIn(context, cfnTelemetrySettings)
66142
}
67143

68-
if (response === allow) {
69-
await cfnTelemetrySettings.update('enabled', true)
70-
await context.globalState.update(commandKey('telemetry.hasResponded'), true)
71-
} else if (response === never) {
72-
await cfnTelemetrySettings.update('enabled', false)
73-
await context.globalState.update(commandKey('telemetry.hasResponded'), true)
74-
} else if (response === later) {
75-
await cfnTelemetrySettings.update('enabled', false)
76-
await context.globalState.update(commandKey('telemetry.lastPromptDate'), now)
144+
const now = globals.clock.Date.now()
145+
await context.globalState.update(telemetryKeys.lastPromptDate, now)
146+
147+
// There's a chance our settings aren't registered yet from package.json, so we
148+
// see if we can persist to settings first
149+
try {
150+
// Throws (with no popup) if setting is not registered
151+
cfnTelemetrySettings.get(telemetrySettings.enabled)
152+
} catch (err) {
153+
getLogger().warn(err as Error)
154+
// Save the choice in globalState and save to settings next time handleTelemetryOptIn is called
155+
await context.globalState.update(telemetryKeys.unpersistedResponse, response)
156+
if (response === TelemetryChoice.Allow) {
157+
await context.globalState.update(telemetryKeys.hasResponded, true)
158+
return true
159+
} else if (response === TelemetryChoice.Never) {
160+
await context.globalState.update(telemetryKeys.hasResponded, true)
161+
return false
162+
} else if (response === TelemetryChoice.Later) {
163+
return false
164+
}
77165
}
78166

79-
return cfnTelemetrySettings.get('enabled', false)
167+
// At this point should be able to save and get successfully
168+
await saveTelemetryResponse(response, cfnTelemetrySettings)
169+
await context.globalState.update(telemetryKeys.hasResponded, response !== TelemetryChoice.Later)
170+
return cfnTelemetrySettings.get(telemetrySettings.enabled, false)
80171
}
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
/*!
2+
* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
6+
import assert from 'assert'
7+
import * as sinon from 'sinon'
8+
import { ExtensionContext } from 'vscode'
9+
import { handleTelemetryOptIn } from '../../../awsService/cloudformation/telemetryOptIn'
10+
import { CloudFormationTelemetrySettings } from '../../../awsService/cloudformation/extensionConfig'
11+
import { commandKey } from '../../../awsService/cloudformation/utils'
12+
import globals from '../../../shared/extensionGlobals'
13+
14+
describe('telemetryOptIn', function () {
15+
let mockContext: ExtensionContext
16+
let mockSettings: CloudFormationTelemetrySettings
17+
let globalState: Map<string, any>
18+
19+
beforeEach(function () {
20+
globalState = new Map()
21+
22+
mockContext = {
23+
globalState: {
24+
get: (key: string, defaultValue?: any) => globalState.get(key) ?? defaultValue,
25+
update: async (key: string, value: any) => {
26+
globalState.set(key, value)
27+
},
28+
},
29+
} as any
30+
31+
mockSettings = {
32+
get: sinon.stub().returns(false),
33+
update: sinon.stub().resolves(),
34+
} as any
35+
})
36+
37+
describe('promptTelemetryOptIn - automation mode', function () {
38+
it('should return current setting without prompting in automation mode', async function () {
39+
sinon.stub(require('../../../shared/vscode/env'), 'isAutomation').returns(true)
40+
;(mockSettings.get as sinon.SinonStub).returns(true)
41+
42+
const result = await handleTelemetryOptIn(mockContext, mockSettings)
43+
44+
assert.strictEqual(result, true)
45+
})
46+
})
47+
48+
describe('promptTelemetryOptIn - user has responded', function () {
49+
it('should return current setting if user has permanently responded', async function () {
50+
globalState.set(commandKey('telemetry.hasResponded'), true)
51+
;(mockSettings.get as sinon.SinonStub).returns(true)
52+
53+
const result = await handleTelemetryOptIn(mockContext, mockSettings)
54+
55+
assert.strictEqual(result, true)
56+
})
57+
})
58+
59+
describe('promptTelemetryOptIn - prompt timing', function () {
60+
it('should not prompt if less than 30 days since last prompt', async function () {
61+
const now = globals.clock.Date.now()
62+
const twentyDaysAgo = now - 20 * 24 * 60 * 60 * 1000
63+
globalState.set(commandKey('telemetry.lastPromptDate'), twentyDaysAgo)
64+
65+
const result = await handleTelemetryOptIn(mockContext, mockSettings)
66+
67+
assert.strictEqual(result, false)
68+
})
69+
})
70+
71+
describe('promptTelemetryOptIn - unpersisted response', function () {
72+
it('should persist unpersisted Allow response', async function () {
73+
globalState.set(commandKey('telemetry.unpersistedResponse'), 'Yes, Allow')
74+
75+
const result = await handleTelemetryOptIn(mockContext, mockSettings)
76+
77+
assert.strictEqual(result, true)
78+
assert.ok((mockSettings.update as sinon.SinonStub).calledWith('enabled', true))
79+
assert.strictEqual(globalState.get(commandKey('telemetry.unpersistedResponse')), undefined)
80+
})
81+
82+
it('should persist unpersisted Never response', async function () {
83+
globalState.set(commandKey('telemetry.unpersistedResponse'), 'Never')
84+
;(mockSettings.update as sinon.SinonStub).resolves(true)
85+
86+
const result = await handleTelemetryOptIn(mockContext, mockSettings)
87+
88+
assert.strictEqual(result, false)
89+
assert.ok((mockSettings.update as sinon.SinonStub).calledWith('enabled', false))
90+
assert.strictEqual(globalState.get(commandKey('telemetry.unpersistedResponse')), undefined)
91+
})
92+
93+
it('should persist unpersisted Later response', async function () {
94+
const lastPromptDate = globals.clock.Date.now() - 1000
95+
globalState.set(commandKey('telemetry.unpersistedResponse'), 'Not Now')
96+
globalState.set(commandKey('telemetry.lastPromptDate'), lastPromptDate)
97+
;(mockSettings.update as sinon.SinonStub).resolves(true)
98+
99+
const result = await handleTelemetryOptIn(mockContext, mockSettings)
100+
101+
assert.strictEqual(result, false)
102+
assert.ok((mockSettings.update as sinon.SinonStub).calledWith('enabled', false))
103+
assert.strictEqual(globalState.get(commandKey('telemetry.lastPromptDate')), lastPromptDate)
104+
assert.strictEqual(globalState.get(commandKey('telemetry.unpersistedResponse')), undefined)
105+
})
106+
107+
it('should clear all state if setting save fails', async function () {
108+
globalState.set(commandKey('telemetry.unpersistedResponse'), 'Yes, Allow')
109+
globalState.set(commandKey('telemetry.hasResponded'), true)
110+
globalState.set(commandKey('telemetry.lastPromptDate'), globals.clock.Date.now())
111+
;(mockSettings.update as sinon.SinonStub).resolves(false)
112+
113+
const result = await handleTelemetryOptIn(mockContext, mockSettings)
114+
115+
assert.strictEqual(result, true)
116+
assert.strictEqual(globalState.get(commandKey('telemetry.unpersistedResponse')), undefined)
117+
assert.strictEqual(globalState.get(commandKey('telemetry.hasResponded')), undefined)
118+
assert.strictEqual(globalState.get(commandKey('telemetry.lastPromptDate')), undefined)
119+
})
120+
})
121+
})
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
{
2+
"type": "Bug Fix",
3+
"description": "CloudFormation: Handle telemetry setting in upgrade path case where setting is not registered"
4+
}

0 commit comments

Comments
 (0)