Skip to content

Commit fba0246

Browse files
hayemaxijustinmk3
andauthored
telemetry: enforce result and reason properties for emitted metrics (#4103)
* telemetry: enforce `result` and `reason` properties for emitted metrics Problem: Different integrations within the toolkit publish metrics differently. The 'result' and 'reason' fields are meant to be standardized, but some integrations are adding their own fields. Solution: Verify that any metrics emitted using `emit()` contain a result property. If the result property is 'Failed', then a reason property must also exists. Any tests that violate this code are failed. We do not want to fail in general because it should not create an exception during production toolkit usage. * refactor: telemetry property check no longer uses spy * Update src/test/globalSetup.test.ts Co-authored-by: Justin M. Keyes <[email protected]> * Update src/test/globalSetup.test.ts Co-authored-by: Justin M. Keyes <[email protected]> * refactor: move telemetry validation out of tests * Update spans.test.ts * Update TESTPLAN.md * Apply suggestions from code review Co-authored-by: Justin M. Keyes <[email protected]> * finish implementation of PR suggestions * update metrics exempt list * fix: pass validation for metric 'aws_modifySetting' --------- Co-authored-by: Justin M. Keyes <[email protected]>
1 parent be66258 commit fba0246

File tree

7 files changed

+124
-11
lines changed

7 files changed

+124
-11
lines changed

docs/TESTPLAN.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,7 @@ modifications/workarounds in `src/test/testRunner.ts`.
8181
- Testing AWS SDK client functionality is cumbersome, verbose, and low-yield.
8282
- Test code uses multiple “mocking” frameworks, which is confusing, error-prone, hard to onboard, and hard to use.
8383
- Coverage not counted for integ tests (because of unresolved tooling issue).
84+
- [Backlog](https://github.com/aws/aws-toolkit-vscode/blob/0f3685ab0dc8af3a214136ebfa233829d5d72b2c/src/shared/telemetry/exemptMetrics.ts) of metrics that do not pass validation but are temporarily exempted to not fail CI.
8485

8586
## Window
8687

src/codewhisperer/commands/basicCommands.ts

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -41,13 +41,19 @@ import { FeatureConfigProvider } from '../service/featureConfigProvider'
4141
export const toggleCodeSuggestions = Commands.declare(
4242
{ id: 'aws.codeWhisperer.toggleCodeSuggestion', compositeKey: { 1: 'source' } },
4343
(suggestionState: CodeSuggestionsState) => async (_: VsCodeCommandArg, source: CodeWhispererSource) => {
44-
const isSuggestionsEnabled = await suggestionState.toggleSuggestions()
45-
await vscode.commands.executeCommand('aws.codeWhisperer.refresh')
46-
telemetry.aws_modifySetting.emit({
47-
settingId: CodeWhispererConstants.autoSuggestionConfig.settingId,
48-
settingState: isSuggestionsEnabled
49-
? CodeWhispererConstants.autoSuggestionConfig.activated
50-
: CodeWhispererConstants.autoSuggestionConfig.deactivated,
44+
await telemetry.aws_modifySetting.run(async span => {
45+
span.record({
46+
settingId: CodeWhispererConstants.autoSuggestionConfig.settingId,
47+
})
48+
49+
const isSuggestionsEnabled = await suggestionState.toggleSuggestions()
50+
span.record({
51+
settingState: isSuggestionsEnabled
52+
? CodeWhispererConstants.autoSuggestionConfig.activated
53+
: CodeWhispererConstants.autoSuggestionConfig.deactivated,
54+
})
55+
56+
await vscode.commands.executeCommand('aws.codeWhisperer.refresh')
5157
})
5258
}
5359
)

src/shared/telemetry/exemptMetrics.ts

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
/*!
2+
* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
6+
/**
7+
* TODO: Fix calls to these metrics and remove this file.
8+
* NOTE: Do NOT add additional metrics here, your PR will be rejected. All new metrics should pass validation.
9+
*
10+
* Checks were added to all emitted telemetry metrics to ensure they contain the correct properties.
11+
* This check does not exist outside test/automated runtimes because the error is not useful
12+
* for the user and doesn't break telemetry.
13+
*
14+
* These are metrics that fail validation and are emitted by code that is covered in test cases.
15+
* This allowlist exists to fix these incrementally and not have CI fail in the meantime.
16+
*/
17+
const validationExemptMetrics: Set<string> = new Set([
18+
'amazonq_runCommand',
19+
'amazonq_isReviewedChanges',
20+
'apigateway_copyUrl',
21+
'aws_loadCredentials',
22+
'aws_validateCredentials',
23+
'cloudwatchlogs_download',
24+
'codeTransform_isDoubleClickedToTriggerInvalidProject',
25+
'codeTransform_jobIsCancelledByUser',
26+
'codeTransform_jobStatusChanged',
27+
'codeTransform_logApiError',
28+
'codeTransform_logApiLatency',
29+
'codewhisperer_userDecision',
30+
'codewhisperer_codeScanIssueHover',
31+
'codewhisperer_codePercentage',
32+
'codewhisperer_userModification',
33+
'codewhisperer_userTriggerDecision',
34+
'dynamicresource_selectResources',
35+
'dynamicresource_copyIdentifier',
36+
'dynamicresource_mutateResource',
37+
'dynamicresource_getResource',
38+
'dynamicresource_listResource',
39+
'ecr_copyRepositoryUri',
40+
'ecr_copyTagUri',
41+
'ecr_createRepository',
42+
'ecr_deleteRepository',
43+
'ecr_deleteTags',
44+
'feedback_result',
45+
'lambda_delete',
46+
's3_uploadObject',
47+
'sam_deploy',
48+
'session_start',
49+
'ssm_deleteDocument',
50+
'ssm_openDocument',
51+
'ssm_publishDocument',
52+
'ssm_updateDocumentVersion',
53+
'stepfunctions_previewstatemachine',
54+
'ui_click',
55+
'vscode_activeRegions',
56+
'vscode_executeCommand',
57+
])
58+
59+
export function isValidationExemptMetric(metricName: string) {
60+
return validationExemptMetrics.has(metricName)
61+
}

src/shared/telemetry/telemetryLogger.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ function isValidEntry(datum: MetadataEntry): datum is Required<MetadataEntry> {
3030
/**
3131
* Telemetry currently sends metadata as an array of key/value pairs, but this is unintuitive for JS
3232
*/
33-
const mapMetadata = (excludeKeys: string[]) => (metadata: Required<MetricDatum>['Metadata']) => {
33+
export const mapMetadata = (excludeKeys: string[]) => (metadata: Required<MetricDatum>['Metadata']) => {
3434
const result: Metadata = {}
3535
return metadata
3636
.filter(isValidEntry)

src/shared/telemetry/telemetryService.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ import { accountMetadataKey, AccountStatus, computeRegionKey } from './telemetry
1717
import { TelemetryLogger } from './telemetryLogger'
1818
import globals from '../extensionGlobals'
1919
import { ClassToInterfaceType } from '../utilities/tsUtils'
20-
import { getClientId } from './util'
20+
import { getClientId, validateMetricEvent } from './util'
2121
import { telemetry } from './telemetry'
2222
import { FileSystemCommon } from '../../srcShared/fs'
2323

@@ -148,6 +148,10 @@ export class DefaultTelemetryService {
148148

149149
public record(event: MetricDatum, awsContext?: AwsContext): void {
150150
if (this.telemetryEnabled) {
151+
// TODO: While this catches cases in code that is tested, untested code will still release incomplete metrics.
152+
// Consider using custom lint rules to address these cases if possible.
153+
validateMetricEvent(event, isAutomation())
154+
151155
const actualAwsContext = awsContext || this.awsContext
152156
const eventWithCommonMetadata = this.injectCommonMetadata(event, actualAwsContext)
153157
this._eventQueue.push(eventWithCommonMetadata)

src/shared/telemetry/util.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,10 @@ import { extensionVersion, isAutomation } from '../vscode/env'
1111
import { v4 as uuidv4 } from 'uuid'
1212
import { addTypeName } from '../utilities/typeConstructors'
1313
import globals from '../extensionGlobals'
14+
import { mapMetadata } from './telemetryLogger'
15+
import { Result } from './telemetry.gen'
16+
import { MetricDatum } from './clienttelemetry'
17+
import { isValidationExemptMetric } from './exemptMetrics'
1418

1519
const legacySettingsTelemetryValueDisable = 'Disable'
1620
const legacySettingsTelemetryValueEnable = 'Enable'
@@ -85,3 +89,40 @@ export async function getUserAgent(
8589

8690
return pairs.join(' ')
8791
}
92+
93+
/**
94+
* Validates that emitted telemetry metrics
95+
* 1. contain a result property and
96+
* 2. contain a reason propery if result = 'Failed'.
97+
*/
98+
export function validateMetricEvent(event: MetricDatum, fatal: boolean) {
99+
const failedStr: Result = 'Failed'
100+
const telemetryRunDocsStr =
101+
'Consider using `.run()` instead of `.emit()`, which will set these properties automatically. ' +
102+
'See https://github.com/aws/aws-toolkit-vscode/blob/master/docs/telemetry.md#guidelines'
103+
104+
if (!isValidationExemptMetric(event.MetricName) && event.Metadata) {
105+
const metadata = mapMetadata([])(event.Metadata)
106+
107+
try {
108+
if (metadata.result === undefined) {
109+
throw new Error(
110+
`Metric \`${event.MetricName}\` was emitted without the \`result\` property. ` +
111+
`This property is always required. ${telemetryRunDocsStr}`
112+
)
113+
}
114+
115+
if (metadata.result === failedStr && metadata.reason === undefined) {
116+
throw new Error(
117+
`Metric \`${event.MetricName}\` was emitted without the \`reason\` property. ` +
118+
`This property is always required when \`result\` = 'Failed'. ${telemetryRunDocsStr}`
119+
)
120+
}
121+
} catch (err: any) {
122+
if (fatal) {
123+
throw err
124+
}
125+
getLogger().warn(`Metric Event did not pass validation: ${(err as Error).message}`)
126+
}
127+
}
128+
}

src/test/shared/telemetry/spans.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,9 +22,9 @@ describe('TelemetrySpan', function () {
2222
})
2323

2424
it('removes passive and value from the metadata', function () {
25-
new TelemetrySpan('foo').emit({ passive: false, value: 100, reason: 'bar' })
25+
new TelemetrySpan('foo').emit({ passive: false, value: 100, result: 'Succeeded', reason: 'bar' })
2626

27-
assertTelemetry('foo' as MetricName, { reason: 'bar' })
27+
assertTelemetry('foo' as MetricName, { result: 'Succeeded', reason: 'bar' })
2828
})
2929

3030
it('records duration if a start time is available', function () {

0 commit comments

Comments
 (0)