feat(payments-next): Capture error presence in CaptureTimingWithStatsD

Because:

* Currently failing methods and successful methods are in the same bucket, and separating out the timing of the two distribution populations is not trivial

This commit:

* Adds in `error: 'true' | 'false'` to the StatsD call depending on whether the wrapped method throws an error

Closes #PAY-3201
This commit is contained in:
Davey Alvarez 2025-12-05 11:44:26 -08:00
parent 4f18ae8fb1
commit c4d9b53b19
No known key found for this signature in database
GPG Key ID: A538D290868DCC59
2 changed files with 77 additions and 5 deletions

View File

@ -31,6 +31,16 @@ class TestClass {
syncMethodWithCustomHandler() {
return 'Custom Handler';
}
@CaptureTimingWithStatsD()
syncMethodWithError() {
throw new Error('Sync error');
}
@CaptureTimingWithStatsD()
async asyncMethodWithError() {
throw new Error('Async error');
}
}
describe('CaptureTimingWithStatsD', () => {
@ -54,6 +64,7 @@ describe('CaptureTimingWithStatsD', () => {
expect.any(Number),
{
sourceClass: 'TestClass',
error: 'false',
}
);
});
@ -66,6 +77,7 @@ describe('CaptureTimingWithStatsD', () => {
expect.any(Number),
{
sourceClass: 'TestClass',
error: 'false',
}
);
});
@ -75,4 +87,54 @@ describe('CaptureTimingWithStatsD', () => {
expect(mockCallback).toHaveBeenCalledTimes(1);
expect(mockCallback).toHaveBeenCalledWith(expect.any(Number));
});
it('should track error=true for synchronous methods that throw', () => {
expect(() => instance.syncMethodWithError()).toThrow('Sync error');
expect(mockStatsD.timing).toHaveBeenCalledTimes(2);
expect(mockStatsD.timing).toHaveBeenCalledWith(
'TestClass_syncMethodWithError',
expect.any(Number),
{
sourceClass: 'TestClass',
error: 'true',
}
);
});
it('should track error=true for asynchronous methods that throw', async () => {
await expect(instance.asyncMethodWithError()).rejects.toThrow('Async error');
expect(mockStatsD.timing).toHaveBeenCalledTimes(2);
expect(mockStatsD.timing).toHaveBeenCalledWith(
'TestClass_asyncMethodWithError',
expect.any(Number),
{
sourceClass: 'TestClass',
error: 'true',
}
);
});
it('should track error=false for successful synchronous methods', () => {
instance.syncMethod();
expect(mockStatsD.timing).toHaveBeenCalledWith(
'TestClass',
expect.any(Number),
{
methodName: 'syncMethod',
error: 'false',
}
);
});
it('should track error=false for successful asynchronous methods', async () => {
await instance.asyncMethod();
expect(mockStatsD.timing).toHaveBeenCalledWith(
'TestClass',
expect.any(Number),
{
methodName: 'asyncMethod',
error: 'false',
}
);
});
});

View File

@ -22,39 +22,49 @@ export function CaptureTimingWithStatsD<
const originalDef = descriptor.value;
descriptor.value = function (this: T, ...args: any[]) {
const defaultHandler = function (this: T, elapsed: number) {
const defaultHandler = function (this: T, elapsed: number, error = false) {
this.statsd.timing(`${this.constructor.name}_${key}`, elapsed, {
sourceClass: this.constructor.name,
error: error.toString(),
...options?.tags,
});
this.statsd.timing(this.constructor.name, elapsed, {
methodName: key,
error: error.toString(),
...options?.tags,
});
};
const handler = options?.handle || defaultHandler;
const start = performance.now();
const originalReturnValue = originalDef.apply(this, args);
let originalReturnValue;
try {
originalReturnValue = originalDef.apply(this, args);
} catch (err) {
const end = performance.now();
handler.apply(this, [end - start, true]);
throw err;
}
if (originalReturnValue instanceof Promise) {
return originalReturnValue
.then((value) => {
const end = performance.now();
handler.apply(this, [end - start]);
handler.apply(this, [end - start, false]);
return value;
})
.catch((err) => {
const end = performance.now();
handler.apply(this, [end - start]);
handler.apply(this, [end - start, true]);
throw err;
});
}
const end = performance.now();
handler.apply(this, [end - start]);
handler.apply(this, [end - start, false]);
return originalReturnValue;
};