Skip to content

Commit bb11bd9

Browse files
authored
feat(telemetry): metrics with missing fields are warned in the logs. (aws#7196)
## Problem A metric was forwarded from Flare with missing fields. This resulted in some confusion about why the metric wasn't showing up properly in Kibana. ## Solution - refactor validation to check for missingFields and log a warning. - Ideally, we would throw in CI for these, but there is currently a significant number of metrics emitted without required fields. - add tests for this validation. ## Future Work - Fix existing cases of emitting with missing required fields, so we can throw in CI when this happens. --- - 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.
1 parent 98b0d5d commit bb11bd9

File tree

8 files changed

+290
-44
lines changed

8 files changed

+290
-44
lines changed

packages/core/src/shared/logger/logger.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ export type LogTopic =
2020
| 'unknown'
2121
| 'nextEditPrediction'
2222
| 'resourceCache'
23+
| 'telemetry'
2324

2425
class ErrorLog {
2526
constructor(

packages/core/src/shared/telemetry/util.ts

Lines changed: 37 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import { env, version } from 'vscode'
88
import * as os from 'os'
99
import { getLogger } from '../logger/logger'
1010
import { fromExtensionManifest, Settings } from '../settings'
11-
import { memoize, once } from '../utilities/functionUtils'
11+
import { memoize, once, oncePerUniqueArg } from '../utilities/functionUtils'
1212
import {
1313
isInDevEnv,
1414
extensionVersion,
@@ -19,7 +19,7 @@ import {
1919
} from '../vscode/env'
2020
import { addTypeName } from '../utilities/typeConstructors'
2121
import globals, { isWeb } from '../extensionGlobals'
22-
import { mapMetadata } from './telemetryLogger'
22+
import { mapMetadata, MetadataObj } from './telemetryLogger'
2323
import { Result } from './telemetry.gen'
2424
import { MetricDatum } from './clienttelemetry'
2525
import { isValidationExemptMetric } from './exemptMetrics'
@@ -312,35 +312,51 @@ export async function getComputeEnvType(): Promise<EnvType> {
312312

313313
/**
314314
* Validates that emitted telemetry metrics
315-
* 1. contain a result property and
315+
* 1. contain a result property
316316
* 2. contain a reason propery if result = 'Failed'.
317+
* 3. are not missing fields
317318
*/
318-
export function validateMetricEvent(event: MetricDatum, fatal: boolean) {
319+
export function validateMetricEvent(event: MetricDatum, fatal: boolean, isExempt = isValidationExemptMetric) {
320+
if (!isExempt(event.MetricName) && event.Metadata) {
321+
const metadata = mapMetadata([])(event.Metadata)
322+
validateMetadata(event.MetricName, metadata, fatal)
323+
}
324+
}
325+
326+
function validateMetadata(metricName: string, metadata: MetadataObj, fatal: boolean) {
319327
const failedStr: Result = 'Failed'
320-
const telemetryRunDocsStr =
328+
const preferRunSuffix =
321329
' Consider using `.run()` instead of `.emit()`, which will set these properties automatically. ' +
322330
'See https://github.com/aws/aws-toolkit-vscode/blob/master/docs/telemetry.md#guidelines'
323-
324-
if (!isValidationExemptMetric(event.MetricName) && event.Metadata) {
325-
const metadata = mapMetadata([])(event.Metadata)
326-
let msg = 'telemetry: invalid Metric: '
327-
328-
if (metadata.result === undefined) {
329-
msg += `"${event.MetricName}" emitted without the \`result\` property, which is always required.`
330-
} else if (metadata.result === failedStr && metadata.reason === undefined) {
331-
msg += `"${event.MetricName}" emitted with result=Failed but without the \`reason\` property.`
332-
} else {
333-
return // Validation passed.
334-
}
335-
336-
msg += telemetryRunDocsStr
331+
const msgPrefix = 'invalid Metric: '
332+
const logger = getTelemetryLogger()
333+
const logOrThrow = (msg: string, includeSuffix: boolean) => {
334+
const fullMsg = msgPrefix + msg + (includeSuffix ? preferRunSuffix : '')
335+
logger.warn(fullMsg)
337336
if (fatal) {
338-
throw new Error(msg)
337+
throw new Error('telemetry: ' + fullMsg)
339338
}
340-
getLogger().warn(msg)
339+
}
340+
341+
if (metadata.result === undefined) {
342+
logOrThrow(`"${metricName}" emitted without the \`result\` property, which is always required.`, true)
343+
} else if (metadata.result === failedStr && metadata.reason === undefined) {
344+
logOrThrow(`"${metricName}" emitted with result=Failed but without the \`reason\` property.`, true)
345+
}
346+
347+
// TODO: there are many instances in the toolkit where we emit metrics with missing fields. If those can be removed, we can configure this to throw in CI.
348+
if (metadata.missingFields) {
349+
const logMsg = `${msgPrefix} "${metricName}" emitted with missing fields: ${metadata.missingFields}`
350+
logWarningOnce(logMsg)
341351
}
342352
}
343353

354+
function getTelemetryLogger() {
355+
return getLogger('telemetry')
356+
}
357+
358+
const logWarningOnce = oncePerUniqueArg((m: string) => getTelemetryLogger().warn(m))
359+
344360
/**
345361
* Potentially helpful values for the 'source' field in telemetry.
346362
*/

packages/core/src/shared/utilities/collectionUtils.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -580,25 +580,25 @@ export function isPresent<T>(value: T | undefined): value is T {
580580
return value !== undefined
581581
}
582582

583-
export class CircularBuffer {
584-
private buffer = new Set<number>()
583+
export class CircularBuffer<T> {
584+
private buffer = new Set<T>()
585585
private maxSize: number
586586

587587
constructor(size: number) {
588588
this.maxSize = size
589589
}
590590

591-
add(value: number): void {
591+
add(value: T): void {
592592
if (this.buffer.size >= this.maxSize) {
593593
// Set iterates its keys in insertion-order.
594594
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Set
595595
const firstKey = this.buffer.keys().next().value
596-
this.buffer.delete(firstKey)
596+
this.buffer.delete(firstKey as T)
597597
}
598598
this.buffer.add(value)
599599
}
600600

601-
contains(value: number): boolean {
601+
contains(value: T): boolean {
602602
return this.buffer.has(value)
603603
}
604604

packages/core/src/shared/utilities/functionUtils.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
* SPDX-License-Identifier: Apache-2.0
44
*/
55

6+
import { CircularBuffer } from './collectionUtils'
67
import { Timeout } from './timeoutUtils'
78

89
/**
@@ -156,3 +157,43 @@ export function keyedDebounce<T, U extends any[], K extends string = string>(
156157
return promise
157158
}
158159
}
160+
/**
161+
* Creates a function that runs only for unique arguments that haven't been seen before.
162+
*
163+
* This utility tracks all unique inputs it has seen and only executes the callback
164+
* for new inputs. Unlike `onceChanged` which only compares with the previous invocation,
165+
* this function maintains a history of all arguments it has processed.
166+
*
167+
* @param fn The function to execute for unique arguments
168+
* @param key A function that returns a unique string for each argument set, defaults to joining with ":" seperating
169+
* @param overflow The maximum number of unique arguments to store.
170+
* @returns A wrapped function that only executes for new unique arguments
171+
*
172+
* @example
173+
* ```ts
174+
* const logOnce = oncePerUniqueArg((message) => console.log(message))
175+
*
176+
* logOnce('hello') // prints: hello
177+
* logOnce('world') // prints: world
178+
* logOnce('hello') // nothing happens (already seen)
179+
* logOnce('test') // prints: test
180+
* ```
181+
*/
182+
export function oncePerUniqueArg<T, U extends any[]>(
183+
fn: (...args: U) => T,
184+
options?: { key?: (...args: U) => string; overflow?: number }
185+
): (...args: U) => T | undefined {
186+
const seen = new CircularBuffer(options?.overflow ?? 1000)
187+
const keyMap = options?.key ?? ((...args: U) => args.map(String).join(':'))
188+
189+
return (...args) => {
190+
const signature = keyMap(...args)
191+
192+
if (!seen.contains(signature)) {
193+
seen.add(signature)
194+
return fn(...args)
195+
}
196+
197+
return undefined
198+
}
199+
}

packages/core/src/shared/utilities/processUtils.ts

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import * as crossSpawn from 'cross-spawn'
88
import * as logger from '../logger/logger'
99
import { Timeout, CancellationError, waitUntil } from './timeoutUtils'
1010
import { PollingSet } from './pollingSet'
11-
import { CircularBuffer } from './collectionUtils'
11+
import { oncePerUniqueArg } from './functionUtils'
1212

1313
export interface RunParameterContext {
1414
/** Reports an error parsed from the stdin/stdout streams. */
@@ -81,7 +81,6 @@ export interface ProcessStats {
8181
export class ChildProcessTracker {
8282
static readonly pollingInterval: number = 10000 // Check usage every 10 seconds
8383
static readonly logger = logger.getLogger('childProcess')
84-
static readonly loggedPids = new CircularBuffer(1000)
8584
#processByPid: Map<number, ChildProcess> = new Map<number, ChildProcess>()
8685
#pids: PollingSet<number>
8786

@@ -137,12 +136,12 @@ export class ChildProcessTracker {
137136
}
138137
}
139138

140-
public static logOnce(pid: number, msg: string) {
141-
if (!ChildProcessTracker.loggedPids.contains(pid)) {
142-
ChildProcessTracker.loggedPids.add(pid)
139+
public static logOnce = oncePerUniqueArg(
140+
(_pid: number, msg: string) => {
143141
ChildProcessTracker.logger.warn(msg)
144-
}
145-
}
142+
},
143+
{ key: (pid, _) => `${pid}` }
144+
)
146145

147146
public add(childProcess: ChildProcess) {
148147
const pid = childProcess.pid()

packages/core/src/test/shared/telemetry/util.test.ts

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,12 +15,15 @@ import {
1515
SessionId,
1616
telemetryClientIdEnvKey,
1717
TelemetryConfig,
18+
validateMetricEvent,
1819
} from '../../../shared/telemetry/util'
1920
import { extensionVersion } from '../../../shared/vscode/env'
2021
import { FakeMemento } from '../../fakeExtensionContext'
2122
import { GlobalState } from '../../../shared/globalState'
2223
import { randomUUID } from 'crypto'
2324
import { isUuid } from '../../../shared/crypto'
25+
import { MetricDatum } from '../../../shared/telemetry/clienttelemetry'
26+
import { assertLogsContain } from '../../globalSetup.test'
2427

2528
describe('TelemetryConfig', function () {
2629
const settingKey = 'aws.telemetry'
@@ -281,3 +284,110 @@ describe('getUserAgent', function () {
281284
assert.strictEqual(beforeClient, platformPair())
282285
})
283286
})
287+
288+
describe('validateMetricEvent', function () {
289+
it('does not validate exempt metrics', function () {
290+
const metricEvent: MetricDatum = {
291+
MetricName: 'exempt_metric',
292+
Value: 1,
293+
Unit: 'None',
294+
Metadata: [{ Key: 'result', Value: 'Succeeded' }],
295+
} as MetricDatum
296+
297+
validateMetricEvent(metricEvent, true, (_) => true)
298+
assert.throws(() => assertLogsContain('invalid Metric', false, 'warn'))
299+
})
300+
301+
it('passes validation for metrics with proper result property', function () {
302+
const metricEvent: MetricDatum = {
303+
MetricName: 'valid_metric',
304+
Value: 1,
305+
Unit: 'None',
306+
Metadata: [{ Key: 'result', Value: 'Succeeded' }],
307+
} as MetricDatum
308+
309+
validateMetricEvent(metricEvent, true, (_) => false)
310+
assert.throws(() => assertLogsContain('invalid Metric', false, 'warn'))
311+
})
312+
313+
it('passes validation for metrics with Failed result and reason property', function () {
314+
const metricEvent: MetricDatum = {
315+
MetricName: 'valid_failed_metric',
316+
Value: 1,
317+
Unit: 'None',
318+
Metadata: [
319+
{ Key: 'result', Value: 'Failed' },
320+
{ Key: 'reason', Value: 'Something went wrong' },
321+
],
322+
} as MetricDatum
323+
324+
validateMetricEvent(metricEvent, true, (_) => false)
325+
})
326+
327+
it('fails validation for metrics missing result property when fatal=true', function () {
328+
const metricEvent: MetricDatum = {
329+
MetricName: 'invalid_metric_no_result',
330+
Value: 1,
331+
Unit: 'None',
332+
Metadata: [{ Key: 'someOtherProperty', Value: 'value' }],
333+
} as MetricDatum
334+
335+
assert.throws(
336+
() => validateMetricEvent(metricEvent, true, (_) => false),
337+
/emitted without the `result` property/
338+
)
339+
})
340+
341+
it('logs warning for metrics missing result property when fatal=false', function () {
342+
const metricEvent: MetricDatum = {
343+
MetricName: 'invalid_metric_no_result',
344+
Value: 1,
345+
Unit: 'None',
346+
Metadata: [{ Key: 'someOtherProperty', Value: 'value' }],
347+
} as MetricDatum
348+
349+
validateMetricEvent(metricEvent, false, (_) => false)
350+
assertLogsContain('invalid Metric', false, 'warn')
351+
})
352+
353+
it('fails validation for metrics with Failed result but missing reason property when fatal=true', function () {
354+
const metricEvent: MetricDatum = {
355+
MetricName: 'invalid_metric_failed_no_reason',
356+
Value: 1,
357+
Unit: 'None',
358+
Metadata: [{ Key: 'result', Value: 'Failed' }],
359+
} as MetricDatum
360+
361+
assert.throws(
362+
() => validateMetricEvent(metricEvent, true),
363+
/emitted with result=Failed but without the `reason` property/
364+
)
365+
})
366+
367+
it('logs warning for metrics with Failed result but missing reason property when fatal=false', function () {
368+
const metricEvent: MetricDatum = {
369+
MetricName: 'invalid_metric_failed_no_reason',
370+
Value: 1,
371+
Unit: 'None',
372+
Metadata: [{ Key: 'result', Value: 'Failed' }],
373+
} as MetricDatum
374+
375+
validateMetricEvent(metricEvent, false)
376+
assertLogsContain('invalid Metric', false, 'warn')
377+
})
378+
379+
it('does not fail validation for metrics with missing fields with fatal=true', function () {
380+
const metricEvent: MetricDatum = {
381+
MetricName: 'invalid_metric_missing_fields',
382+
Value: 1,
383+
Unit: 'None',
384+
Metadata: [
385+
{ Key: 'result', Value: 'Succeeded' },
386+
{ Key: 'missingFields', Value: 'field1,field2' },
387+
],
388+
} as MetricDatum
389+
390+
validateMetricEvent(metricEvent, false)
391+
assertLogsContain('invalid Metric', false, 'warn')
392+
})
393+
})

0 commit comments

Comments
 (0)