Skip to content

Commit 095ed5e

Browse files
committed
feat: only log each warning message once for invalid metrics
1 parent b092ab1 commit 095ed5e

File tree

5 files changed

+114
-10
lines changed

5 files changed

+114
-10
lines changed

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

Lines changed: 9 additions & 3 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,
@@ -329,7 +329,7 @@ function validateMetadata(metricName: string, metadata: MetadataObj, fatal: bool
329329
' Consider using `.run()` instead of `.emit()`, which will set these properties automatically. ' +
330330
'See https://github.com/aws/aws-toolkit-vscode/blob/master/docs/telemetry.md#guidelines'
331331
const msgPrefix = 'invalid Metric: '
332-
const logger = getLogger('telemetry')
332+
const logger = getTelemetryLogger()
333333
const logOrThrow = (msg: string, includeSuffix: boolean) => {
334334
const fullMsg = msgPrefix + msg + (includeSuffix ? preferRunSuffix : '')
335335
logger.warn(fullMsg)
@@ -346,10 +346,16 @@ function validateMetadata(metricName: string, metadata: MetadataObj, fatal: bool
346346

347347
// 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.
348348
if (metadata.missingFields) {
349-
logger.warn(msgPrefix + `"${metricName} emitted with missing fields: ${metadata.missingFields}`, false)
349+
logWarningOnce(`"${metricName}" emitted with missing fields: ${metadata.missingFields}`)
350350
}
351351
}
352352

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

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: 39 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,41 @@ 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 overflow The maximum number of unique arguments to store.
169+
* @returns A wrapped function that only executes for new unique arguments
170+
*
171+
* @example
172+
* ```ts
173+
* const logOnce = oncePerUniqueArg((message) => console.log(message))
174+
*
175+
* logOnce('hello') // prints: hello
176+
* logOnce('world') // prints: world
177+
* logOnce('hello') // nothing happens (already seen)
178+
* logOnce('test') // prints: test
179+
* ```
180+
*/
181+
export function oncePerUniqueArg<T, U extends any[]>(
182+
fn: (...args: U) => T,
183+
overflow?: number
184+
): (...args: U) => T | undefined {
185+
const seen = new CircularBuffer(overflow ?? 1000)
186+
187+
return (...args) => {
188+
const signature = args.map(String).join(':')
189+
190+
if (!seen.contains(signature)) {
191+
seen.add(signature)
192+
return fn(...args)
193+
}
194+
195+
return undefined
196+
}
197+
}

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@ export class ChildProcessTracker {
7474
cpu: 50,
7575
}
7676
static readonly logger = logger.getLogger('childProcess')
77-
static readonly loggedPids = new CircularBuffer(1000)
77+
static readonly loggedPids = new CircularBuffer<number>(1000)
7878
#processByPid: Map<number, ChildProcess> = new Map<number, ChildProcess>()
7979
#pids: PollingSet<number>
8080

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

Lines changed: 60 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
*/
55

66
import assert from 'assert'
7-
import { once, onceChanged, debounce } from '../../../shared/utilities/functionUtils'
7+
import { once, onceChanged, debounce, oncePerUniqueArg } from '../../../shared/utilities/functionUtils'
88
import { installFakeClock } from '../../testUtil'
99

1010
describe('functionUtils', function () {
@@ -48,6 +48,65 @@ describe('functionUtils', function () {
4848
fn('arg1', arg2_)
4949
assert.strictEqual(counter, 3)
5050
})
51+
52+
it('oncePerUniqueArg()', function () {
53+
let counter = 0
54+
const fn = oncePerUniqueArg((s: string) => {
55+
counter++
56+
return `processed-${s}`
57+
})
58+
59+
// First call with unique arg should execute
60+
const result1 = fn('hello')
61+
assert.strictEqual(result1, 'processed-hello')
62+
assert.strictEqual(counter, 1)
63+
64+
// Second call with same arg should not execute
65+
const result2 = fn('hello')
66+
assert.strictEqual(result2, undefined)
67+
assert.strictEqual(counter, 1)
68+
69+
// Call with new arg should execute
70+
const result3 = fn('world')
71+
assert.strictEqual(result3, 'processed-world')
72+
assert.strictEqual(counter, 2)
73+
74+
// Repeated calls with seen args should not execute
75+
fn('hello')
76+
fn('world')
77+
assert.strictEqual(counter, 2)
78+
79+
// New arg should execute
80+
const result4 = fn('test')
81+
assert.strictEqual(result4, 'processed-test')
82+
assert.strictEqual(counter, 3)
83+
})
84+
85+
it('oncePerUniqueArg() with overflow limit', function () {
86+
let counter = 0
87+
// Create function with small overflow limit
88+
const fn = oncePerUniqueArg((s: string) => {
89+
counter++
90+
return counter
91+
}, 2)
92+
93+
// Fill the buffer
94+
fn('one')
95+
fn('two')
96+
assert.strictEqual(counter, 2)
97+
98+
// This should execute since it's a new value
99+
fn('three')
100+
assert.strictEqual(counter, 3)
101+
102+
// 'one' should now be treated as new again since it was evicted
103+
fn('one')
104+
assert.strictEqual(counter, 4, 'one should still be in the buffer')
105+
106+
// 'three' should still be in the buffer (not executed)
107+
fn('three')
108+
assert.strictEqual(counter, 4, 'three should still be in the buffer')
109+
})
51110
})
52111

53112
describe('debounce', function () {

0 commit comments

Comments
 (0)