Skip to content

Commit 06275ee

Browse files
authored
fix(errors): contain throwing LogRecordProcessor to prevent error-event loop (#303)
1 parent bc54414 commit 06275ee

2 files changed

Lines changed: 101 additions & 21 deletions

File tree

packages/instrumentation/src/errors/instrumentation.test.ts

Lines changed: 67 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -72,12 +72,16 @@ const dispatchUnhandledRejection = (
7272
window.dispatchEvent(event);
7373
};
7474

75-
// The instrumentation does not expose its diag logger for testing, so reach
76-
// the internal `_diag` through a single typed accessor rather than casting in
77-
// each test.
75+
// The instrumentation does not expose its `_diag` or `logger` fields for
76+
// testing, so reach the internals through a single typed accessor rather than
77+
// casting in each test.
7878
const internals = (inst: ErrorsInstrumentation | undefined) =>
7979
inst as unknown as {
80-
_diag: { debug: (...args: unknown[]) => void };
80+
_diag: {
81+
debug: (...args: unknown[]) => void;
82+
error: (...args: unknown[]) => void;
83+
};
84+
logger: { emit: (record: unknown) => void };
8185
};
8286

8387
describe('ErrorsInstrumentation', () => {
@@ -403,4 +407,63 @@ describe('ErrorsInstrumentation', () => {
403407
);
404408
});
405409
});
410+
411+
describe('failure containment', () => {
412+
beforeEach(() => {
413+
instrumentation = new ErrorsInstrumentation({ enabled: false });
414+
instrumentation.enable();
415+
});
416+
417+
it('should contain a throwing LogRecordProcessor and surface it via diag', () => {
418+
const accessor = internals(instrumentation);
419+
const diagSpy = vi.spyOn(accessor._diag, 'error');
420+
const emitSpy = vi
421+
.spyOn(accessor.logger, 'emit')
422+
.mockImplementation(() => {
423+
throw new Error('processor exploded');
424+
});
425+
426+
try {
427+
// A throw escaping the listener would re-trigger 'error' and loop.
428+
expect(() =>
429+
dispatchErrorEvent(new ValidationError('boom')),
430+
).not.toThrow();
431+
432+
// Exactly once: the contained throw did not re-enter the listener.
433+
expect(emitSpy).toHaveBeenCalledTimes(1);
434+
expect(diagSpy).toHaveBeenCalledWith(
435+
'failed to record exception',
436+
expect.any(Error),
437+
);
438+
} finally {
439+
emitSpy.mockRestore();
440+
diagSpy.mockRestore();
441+
}
442+
});
443+
444+
it('should contain a rejection reason that throws while its properties are read', () => {
445+
const diagSpy = vi.spyOn(internals(instrumentation)._diag, 'error');
446+
447+
// A promise can reject with any value. This one throws while its `stack`
448+
// is read, exercising the extraction path before emit is ever reached.
449+
const hostileReason = {} as Error;
450+
Object.defineProperty(hostileReason, 'stack', {
451+
get() {
452+
throw new Error('stack getter exploded');
453+
},
454+
});
455+
456+
try {
457+
expect(() => dispatchUnhandledRejection(hostileReason)).not.toThrow();
458+
459+
expect(getErrorLogs()).toHaveLength(0);
460+
expect(diagSpy).toHaveBeenCalledWith(
461+
'failed to record exception',
462+
expect.any(Error),
463+
);
464+
} finally {
465+
diagSpy.mockRestore();
466+
}
467+
});
468+
});
406469
});

packages/instrumentation/src/errors/instrumentation.ts

Lines changed: 34 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -97,26 +97,43 @@ export class ErrorsInstrumentation extends InstrumentationBase<ErrorsInstrumenta
9797
return;
9898
}
9999

100-
let errorAttributes: AnyValueMap;
101-
if (typeof error === 'string') {
102-
errorAttributes = { [ATTR_EXCEPTION_MESSAGE]: error };
103-
} else {
104-
errorAttributes = {
105-
[ATTR_EXCEPTION_TYPE]: error.name,
106-
[ATTR_EXCEPTION_MESSAGE]: error.message,
107-
[ATTR_EXCEPTION_STACKTRACE]: error.stack,
108-
};
109-
}
100+
// Capture as a const so the type narrowing survives into the closure below.
101+
const capturedError = error;
102+
103+
// This runs inside a global error listener, so an escaping throw would
104+
// re-trigger the listener and loop. Both attribute extraction (a rejection
105+
// can carry a value whose `stack` getter throws) and emit (a broken
106+
// LogRecordProcessor) can throw, so contain the whole path.
107+
safeExecuteInTheMiddle(
108+
() => {
109+
let errorAttributes: AnyValueMap;
110+
if (typeof capturedError === 'string') {
111+
errorAttributes = { [ATTR_EXCEPTION_MESSAGE]: capturedError };
112+
} else {
113+
errorAttributes = {
114+
[ATTR_EXCEPTION_TYPE]: capturedError.name,
115+
[ATTR_EXCEPTION_MESSAGE]: capturedError.message,
116+
[ATTR_EXCEPTION_STACKTRACE]: capturedError.stack,
117+
};
118+
}
110119

111-
const customAttributes = this._applyCustomAttributes(error);
120+
const customAttributes = this._applyCustomAttributes(capturedError);
112121

113-
const logRecord: LogRecord = {
114-
eventName: EXCEPTION_EVENT_NAME,
115-
severityNumber: SeverityNumber.ERROR,
116-
attributes: { ...errorAttributes, ...customAttributes },
117-
};
122+
const logRecord: LogRecord = {
123+
eventName: EXCEPTION_EVENT_NAME,
124+
severityNumber: SeverityNumber.ERROR,
125+
attributes: { ...errorAttributes, ...customAttributes },
126+
};
118127

119-
this.logger.emit(logRecord);
128+
this.logger.emit(logRecord);
129+
},
130+
(err) => {
131+
if (err) {
132+
this._diag.error('failed to record exception', err);
133+
}
134+
},
135+
true,
136+
);
120137
}
121138

122139
private _applyCustomAttributes(error: Error | string): Attributes {

0 commit comments

Comments
 (0)