Skip to content

Commit 524ab3f

Browse files
authored
Attach stack trace for all uncaught exceptions (#378)
1 parent d8d26fe commit 524ab3f

File tree

5 files changed

+264
-25
lines changed

5 files changed

+264
-25
lines changed

src/telemetry/ScopedTelemetry.ts

Lines changed: 23 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
ValueType,
1212
} from '@opentelemetry/api';
1313
import { Closeable } from '../utils/Closeable';
14+
import { errorAttributes } from '../utils/Errors';
1415
import { typeOf } from '../utils/TypeCheck';
1516
import { TelemetryContext } from './TelemetryContext';
1617

@@ -51,6 +52,21 @@ export class ScopedTelemetry implements Closeable {
5152
this.getOrCreateHistogram(name, options)?.record(value, attributes);
5253
}
5354

55+
error(name: string, error: unknown, origin?: 'uncaughtException' | 'unhandledRejection', config?: MetricConfig) {
56+
if (config === undefined) {
57+
this.count(name, 1, {
58+
attributes: errorAttributes(error, origin),
59+
});
60+
} else {
61+
config.attributes = {
62+
...config.attributes,
63+
...errorAttributes(error, origin),
64+
};
65+
66+
this.count(name, 1, config);
67+
}
68+
}
69+
5470
registerGaugeProvider(name: string, provider: () => number, config?: MetricConfig): void {
5571
if (!this.meter) {
5672
return;
@@ -91,7 +107,7 @@ export class ScopedTelemetry implements Closeable {
91107
try {
92108
return fn();
93109
} catch (error) {
94-
this.count(`${name}.fault`, 1, config);
110+
this.error(`${name}.fault`, error, undefined, config);
95111
throw error;
96112
}
97113
}
@@ -102,7 +118,7 @@ export class ScopedTelemetry implements Closeable {
102118
try {
103119
return await fn();
104120
} catch (error) {
105-
this.count(`${name}.fault`, 1, config);
121+
this.error(`${name}.fault`, error, undefined, config);
106122
throw error;
107123
}
108124
}
@@ -122,7 +138,7 @@ export class ScopedTelemetry implements Closeable {
122138
if (trackResponse) this.recordResponse(name, result, config);
123139
return result;
124140
} catch (error) {
125-
this.count(`${name}.fault`, 1, config);
141+
this.error(`${name}.fault`, error, undefined, config);
126142
throw error;
127143
} finally {
128144
this.recordDuration(name, performance.now() - startTime, config);
@@ -149,7 +165,7 @@ export class ScopedTelemetry implements Closeable {
149165
if (trackResponse) this.recordResponse(name, result, config);
150166
return result;
151167
} catch (error) {
152-
this.count(`${name}.fault`, 1, config);
168+
this.error(`${name}.fault`, error, undefined, config);
153169
throw error;
154170
} finally {
155171
this.recordDuration(name, performance.now() - startTime, config);
@@ -243,10 +259,10 @@ export class ScopedTelemetry implements Closeable {
243259
}
244260
}
245261

246-
function generateConfig(config?: MetricConfig) {
247-
const { attributes = {}, unit = '1', valueType = ValueType.DOUBLE, ...options } = config ?? {};
262+
function generateConfig(config?: MetricConfig): { options: MetricOptions; attributes: Attributes } {
263+
const { attributes = {}, unit = '1', valueType = ValueType.DOUBLE, description, advice } = config ?? {};
248264
return {
249-
options: { unit, valueType, ...options },
265+
options: { unit, valueType, description, advice },
250266
attributes: generateAttr(attributes),
251267
};
252268
}

src/telemetry/TelemetryService.ts

Lines changed: 2 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@ import { NodeSDK } from '@opentelemetry/sdk-node';
44
import { v4 } from 'uuid';
55
import { AwsMetadata, ClientInfo } from '../server/InitParams';
66
import { Closeable } from '../utils/Closeable';
7-
import { extractLocationFromStack } from '../utils/Errors';
87
import { LoggerFactory } from './LoggerFactory';
98
import { otelSdk } from './OTELInstrumentation';
109
import { ScopedTelemetry } from './ScopedTelemetry';
@@ -152,26 +151,12 @@ export class TelemetryService implements Closeable {
152151

153152
private registerErrorHandlers(telemetry: ScopedTelemetry): void {
154153
process.on('unhandledRejection', (reason, _promise) => {
155-
const location = reason instanceof Error ? extractLocationFromStack(reason.stack) : {};
156-
telemetry.count('process.promise.unhandled', 1, {
157-
attributes: {
158-
'error.type': reason instanceof Error ? reason.name : typeof reason,
159-
...location,
160-
},
161-
});
162-
154+
telemetry.error('process.promise.unhandled', reason);
163155
void this.metricsReader?.forceFlush();
164156
});
165157

166158
process.on('uncaughtException', (error, origin) => {
167-
telemetry.count('process.exception.uncaught', 1, {
168-
attributes: {
169-
'error.type': error.name,
170-
'error.origin': origin,
171-
...extractLocationFromStack(error.stack),
172-
},
173-
});
174-
159+
telemetry.error('process.exception.uncaught', error, origin);
175160
void this.metricsReader?.forceFlush();
176161
});
177162
}

src/utils/Errors.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { Attributes } from '@opentelemetry/api';
12
import { ErrorCodes, ResponseError } from 'vscode-languageserver';
23
import { determineSensitiveInfo } from './ErrorStackInfo';
34
import { toString } from './String';
@@ -50,3 +51,14 @@ export function extractLocationFromStack(stack?: string): Record<string, string>
5051
result['error.stack'] = lines.slice(1).join('\n');
5152
return result;
5253
}
54+
55+
export function errorAttributes(error: unknown, origin?: 'uncaughtException' | 'unhandledRejection'): Attributes {
56+
const location = error instanceof Error ? extractLocationFromStack(error.stack) : {};
57+
const type = error instanceof Error ? error.name : typeof error;
58+
59+
return {
60+
'error.type': type,
61+
'error.origin': origin ?? 'Unknown',
62+
...location,
63+
};
64+
}

tst/unit/telemetry/ScopedTelemetry.test.ts

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,4 +168,145 @@ describe('ScopedTelemetry', () => {
168168
expect(scopedTelemetry['histograms'].size).toBe(0);
169169
});
170170
});
171+
172+
describe('error', () => {
173+
let mockAdd: ReturnType<typeof vi.fn>;
174+
175+
beforeEach(() => {
176+
mockAdd = vi.fn();
177+
mockMeter.createCounter = vi.fn(() => ({ add: mockAdd }));
178+
});
179+
180+
it('should record error with default origin Unknown when not provided', () => {
181+
const error = new Error('test error');
182+
error.stack = 'Error: test error\n at func (file.ts:10:5)';
183+
184+
scopedTelemetry.error('test.error', error);
185+
186+
expect(mockMeter.createCounter).toHaveBeenCalledWith('test.error', {
187+
unit: '1',
188+
valueType: 1,
189+
description: undefined,
190+
advice: undefined,
191+
});
192+
expect(mockAdd).toHaveBeenCalledWith(1, {
193+
HandlerSource: 'Unknown',
194+
'aws.emf.storage_resolution': 1,
195+
'error.type': 'Error',
196+
'error.origin': 'Unknown',
197+
'error.message': 'Error: test error',
198+
'error.stack': 'at func (file.ts:10:5)',
199+
});
200+
});
201+
202+
it('should record error with uncaughtException origin', () => {
203+
const error = new TypeError('type error');
204+
error.stack = 'TypeError: type error\n at test (test.ts:1:1)';
205+
206+
scopedTelemetry.error('test.error', error, 'uncaughtException');
207+
208+
expect(mockAdd).toHaveBeenCalledWith(1, {
209+
HandlerSource: 'Unknown',
210+
'aws.emf.storage_resolution': 1,
211+
'error.type': 'TypeError',
212+
'error.origin': 'uncaughtException',
213+
'error.message': 'TypeError: type error',
214+
'error.stack': 'at test (test.ts:1:1)',
215+
});
216+
});
217+
218+
it('should record error with unhandledRejection origin', () => {
219+
const error = new Error('rejection');
220+
error.stack = 'Error: rejection\n at promise (p.ts:5:10)';
221+
222+
scopedTelemetry.error('test.error', error, 'unhandledRejection');
223+
224+
expect(mockAdd).toHaveBeenCalledWith(1, {
225+
HandlerSource: 'Unknown',
226+
'aws.emf.storage_resolution': 1,
227+
'error.type': 'Error',
228+
'error.origin': 'unhandledRejection',
229+
'error.message': 'Error: rejection',
230+
'error.stack': 'at promise (p.ts:5:10)',
231+
});
232+
});
233+
234+
it('should merge config attributes with error attributes', () => {
235+
const error = new Error('test');
236+
error.stack = 'Error: test\n at x (x.ts:1:1)';
237+
238+
scopedTelemetry.error('test.error', error, undefined, {
239+
attributes: { custom: 'value', region: 'us-east-1' },
240+
});
241+
242+
expect(mockAdd).toHaveBeenCalledWith(1, {
243+
HandlerSource: 'Unknown',
244+
'aws.emf.storage_resolution': 1,
245+
custom: 'value',
246+
region: 'us-east-1',
247+
'error.type': 'Error',
248+
'error.origin': 'Unknown',
249+
'error.message': 'Error: test',
250+
'error.stack': 'at x (x.ts:1:1)',
251+
});
252+
});
253+
254+
it('should pass config options to counter', () => {
255+
const error = new Error('test');
256+
error.stack = 'Error: test\n at x (x.ts:1:1)';
257+
258+
scopedTelemetry.error('test.error', error, undefined, {
259+
unit: 'errors',
260+
description: 'Error counter',
261+
});
262+
263+
expect(mockMeter.createCounter).toHaveBeenCalledWith('test.error', {
264+
unit: 'errors',
265+
valueType: 1,
266+
description: 'Error counter',
267+
advice: undefined,
268+
});
269+
expect(mockAdd).toHaveBeenCalledWith(1, {
270+
HandlerSource: 'Unknown',
271+
'aws.emf.storage_resolution': 1,
272+
'error.type': 'Error',
273+
'error.origin': 'Unknown',
274+
'error.message': 'Error: test',
275+
'error.stack': 'at x (x.ts:1:1)',
276+
});
277+
});
278+
279+
it('should handle non-Error string value', () => {
280+
scopedTelemetry.error('test.error', 'string error');
281+
282+
expect(mockAdd).toHaveBeenCalledWith(1, {
283+
HandlerSource: 'Unknown',
284+
'aws.emf.storage_resolution': 1,
285+
'error.type': 'string',
286+
'error.origin': 'Unknown',
287+
});
288+
});
289+
290+
it('should handle non-Error null value', () => {
291+
scopedTelemetry.error('test.error', null);
292+
293+
expect(mockAdd).toHaveBeenCalledWith(1, {
294+
HandlerSource: 'Unknown',
295+
'aws.emf.storage_resolution': 1,
296+
'error.type': 'object',
297+
'error.origin': 'Unknown',
298+
});
299+
});
300+
301+
it('should handle non-Error undefined value', () => {
302+
scopedTelemetry.error('test.error', undefined);
303+
304+
expect(mockAdd).toHaveBeenCalledWith(1, {
305+
HandlerSource: 'Unknown',
306+
'aws.emf.storage_resolution': 1,
307+
'error.type': 'undefined',
308+
'error.origin': 'Unknown',
309+
});
310+
});
311+
});
171312
});

tst/unit/utils/Errors.test.ts

Lines changed: 86 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { describe, test, expect } from 'vitest';
2-
import { extractLocationFromStack } from '../../../src/utils/Errors';
2+
import { errorAttributes, extractLocationFromStack } from '../../../src/utils/Errors';
33

44
describe('extractLocationFromStack', () => {
55
test('returns empty object when stack is undefined', () => {
@@ -149,3 +149,88 @@ at Object.Module._extensions..js (node:internal/modules/cjs/loader:1213:10)`,
149149
});
150150
});
151151
});
152+
153+
describe('errorAttributes', () => {
154+
test('returns attributes for Error with stack and default origin', () => {
155+
const error = new Error('test message');
156+
error.stack = 'Error: test message\n at func (file.ts:10:5)';
157+
158+
const result = errorAttributes(error);
159+
160+
expect(result).toEqual({
161+
'error.type': 'Error',
162+
'error.origin': 'Unknown',
163+
'error.message': 'Error: test message',
164+
'error.stack': 'at func (file.ts:10:5)',
165+
});
166+
});
167+
168+
test('returns attributes for custom Error type', () => {
169+
const error = new TypeError('type error');
170+
error.stack = 'TypeError: type error\n at func (file.ts:1:1)';
171+
172+
const result = errorAttributes(error);
173+
174+
expect(result).toEqual({
175+
'error.type': 'TypeError',
176+
'error.origin': 'Unknown',
177+
'error.message': 'TypeError: type error',
178+
'error.stack': 'at func (file.ts:1:1)',
179+
});
180+
});
181+
182+
test('returns attributes with uncaughtException origin', () => {
183+
const error = new Error('test');
184+
error.stack = 'Error: test\n at x (x.ts:1:1)';
185+
186+
const result = errorAttributes(error, 'uncaughtException');
187+
188+
expect(result).toEqual({
189+
'error.type': 'Error',
190+
'error.origin': 'uncaughtException',
191+
'error.message': 'Error: test',
192+
'error.stack': 'at x (x.ts:1:1)',
193+
});
194+
});
195+
196+
test('returns attributes with unhandledRejection origin', () => {
197+
const error = new Error('test');
198+
error.stack = 'Error: test\n at x (x.ts:1:1)';
199+
200+
const result = errorAttributes(error, 'unhandledRejection');
201+
202+
expect(result).toEqual({
203+
'error.type': 'Error',
204+
'error.origin': 'unhandledRejection',
205+
'error.message': 'Error: test',
206+
'error.stack': 'at x (x.ts:1:1)',
207+
});
208+
});
209+
210+
test('returns attributes for non-Error string value', () => {
211+
const result = errorAttributes('string error');
212+
213+
expect(result).toEqual({
214+
'error.type': 'string',
215+
'error.origin': 'Unknown',
216+
});
217+
});
218+
219+
test('returns attributes for non-Error null value', () => {
220+
const result = errorAttributes(null);
221+
222+
expect(result).toEqual({
223+
'error.type': 'object',
224+
'error.origin': 'Unknown',
225+
});
226+
});
227+
228+
test('returns attributes for non-Error undefined value', () => {
229+
const result = errorAttributes(undefined);
230+
231+
expect(result).toEqual({
232+
'error.type': 'undefined',
233+
'error.origin': 'Unknown',
234+
});
235+
});
236+
});

0 commit comments

Comments
 (0)