Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 23 additions & 7 deletions src/telemetry/ScopedTelemetry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
ValueType,
} from '@opentelemetry/api';
import { Closeable } from '../utils/Closeable';
import { errorAttributes } from '../utils/Errors';
import { typeOf } from '../utils/TypeCheck';
import { TelemetryContext } from './TelemetryContext';

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

error(name: string, error: unknown, origin?: 'uncaughtException' | 'unhandledRejection', config?: MetricConfig) {
if (config === undefined) {
this.count(name, 1, {
attributes: errorAttributes(error, origin),
});
} else {
config.attributes = {
...config.attributes,
...errorAttributes(error, origin),
};

this.count(name, 1, config);
}
}

registerGaugeProvider(name: string, provider: () => number, config?: MetricConfig): void {
if (!this.meter) {
return;
Expand Down Expand Up @@ -91,7 +107,7 @@ export class ScopedTelemetry implements Closeable {
try {
return fn();
} catch (error) {
this.count(`${name}.fault`, 1, config);
this.error(`${name}.fault`, error, undefined, config);
throw error;
}
}
Expand All @@ -102,7 +118,7 @@ export class ScopedTelemetry implements Closeable {
try {
return await fn();
} catch (error) {
this.count(`${name}.fault`, 1, config);
this.error(`${name}.fault`, error, undefined, config);
throw error;
}
}
Expand All @@ -122,7 +138,7 @@ export class ScopedTelemetry implements Closeable {
if (trackResponse) this.recordResponse(name, result, config);
return result;
} catch (error) {
this.count(`${name}.fault`, 1, config);
this.error(`${name}.fault`, error, undefined, config);
throw error;
} finally {
this.recordDuration(name, performance.now() - startTime, config);
Expand All @@ -149,7 +165,7 @@ export class ScopedTelemetry implements Closeable {
if (trackResponse) this.recordResponse(name, result, config);
return result;
} catch (error) {
this.count(`${name}.fault`, 1, config);
this.error(`${name}.fault`, error, undefined, config);
throw error;
} finally {
this.recordDuration(name, performance.now() - startTime, config);
Expand Down Expand Up @@ -243,10 +259,10 @@ export class ScopedTelemetry implements Closeable {
}
}

function generateConfig(config?: MetricConfig) {
const { attributes = {}, unit = '1', valueType = ValueType.DOUBLE, ...options } = config ?? {};
function generateConfig(config?: MetricConfig): { options: MetricOptions; attributes: Attributes } {
const { attributes = {}, unit = '1', valueType = ValueType.DOUBLE, description, advice } = config ?? {};
return {
options: { unit, valueType, ...options },
options: { unit, valueType, description, advice },
attributes: generateAttr(attributes),
};
}
Expand Down
19 changes: 2 additions & 17 deletions src/telemetry/TelemetryService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import { NodeSDK } from '@opentelemetry/sdk-node';
import { v4 } from 'uuid';
import { AwsMetadata, ClientInfo } from '../server/InitParams';
import { Closeable } from '../utils/Closeable';
import { extractLocationFromStack } from '../utils/Errors';
import { LoggerFactory } from './LoggerFactory';
import { otelSdk } from './OTELInstrumentation';
import { ScopedTelemetry } from './ScopedTelemetry';
Expand Down Expand Up @@ -152,26 +151,12 @@ export class TelemetryService implements Closeable {

private registerErrorHandlers(telemetry: ScopedTelemetry): void {
process.on('unhandledRejection', (reason, _promise) => {
const location = reason instanceof Error ? extractLocationFromStack(reason.stack) : {};
telemetry.count('process.promise.unhandled', 1, {
attributes: {
'error.type': reason instanceof Error ? reason.name : typeof reason,
...location,
},
});

telemetry.error('process.promise.unhandled', reason);
void this.metricsReader?.forceFlush();
});

process.on('uncaughtException', (error, origin) => {
telemetry.count('process.exception.uncaught', 1, {
attributes: {
'error.type': error.name,
'error.origin': origin,
...extractLocationFromStack(error.stack),
},
});

telemetry.error('process.exception.uncaught', error, origin);
void this.metricsReader?.forceFlush();
});
}
Expand Down
12 changes: 12 additions & 0 deletions src/utils/Errors.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { Attributes } from '@opentelemetry/api';
import { ErrorCodes, ResponseError } from 'vscode-languageserver';
import { determineSensitiveInfo } from './ErrorStackInfo';
import { toString } from './String';
Expand Down Expand Up @@ -50,3 +51,14 @@ export function extractLocationFromStack(stack?: string): Record<string, string>
result['error.stack'] = lines.slice(1).join('\n');
return result;
}

export function errorAttributes(error: unknown, origin?: 'uncaughtException' | 'unhandledRejection'): Attributes {
const location = error instanceof Error ? extractLocationFromStack(error.stack) : {};
const type = error instanceof Error ? error.name : typeof error;

return {
'error.type': type,
'error.origin': origin ?? 'Unknown',
...location,
};
}
141 changes: 141 additions & 0 deletions tst/unit/telemetry/ScopedTelemetry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -168,4 +168,145 @@ describe('ScopedTelemetry', () => {
expect(scopedTelemetry['histograms'].size).toBe(0);
});
});

describe('error', () => {
let mockAdd: ReturnType<typeof vi.fn>;

beforeEach(() => {
mockAdd = vi.fn();
mockMeter.createCounter = vi.fn(() => ({ add: mockAdd }));
});

it('should record error with default origin Unknown when not provided', () => {
const error = new Error('test error');
error.stack = 'Error: test error\n at func (file.ts:10:5)';

scopedTelemetry.error('test.error', error);

expect(mockMeter.createCounter).toHaveBeenCalledWith('test.error', {
unit: '1',
valueType: 1,
description: undefined,
advice: undefined,
});
expect(mockAdd).toHaveBeenCalledWith(1, {
HandlerSource: 'Unknown',
'aws.emf.storage_resolution': 1,
'error.type': 'Error',
'error.origin': 'Unknown',
'error.message': 'Error: test error',
'error.stack': 'at func (file.ts:10:5)',
});
});

it('should record error with uncaughtException origin', () => {
const error = new TypeError('type error');
error.stack = 'TypeError: type error\n at test (test.ts:1:1)';

scopedTelemetry.error('test.error', error, 'uncaughtException');

expect(mockAdd).toHaveBeenCalledWith(1, {
HandlerSource: 'Unknown',
'aws.emf.storage_resolution': 1,
'error.type': 'TypeError',
'error.origin': 'uncaughtException',
'error.message': 'TypeError: type error',
'error.stack': 'at test (test.ts:1:1)',
});
});

it('should record error with unhandledRejection origin', () => {
const error = new Error('rejection');
error.stack = 'Error: rejection\n at promise (p.ts:5:10)';

scopedTelemetry.error('test.error', error, 'unhandledRejection');

expect(mockAdd).toHaveBeenCalledWith(1, {
HandlerSource: 'Unknown',
'aws.emf.storage_resolution': 1,
'error.type': 'Error',
'error.origin': 'unhandledRejection',
'error.message': 'Error: rejection',
'error.stack': 'at promise (p.ts:5:10)',
});
});

it('should merge config attributes with error attributes', () => {
const error = new Error('test');
error.stack = 'Error: test\n at x (x.ts:1:1)';

scopedTelemetry.error('test.error', error, undefined, {
attributes: { custom: 'value', region: 'us-east-1' },
});

expect(mockAdd).toHaveBeenCalledWith(1, {
HandlerSource: 'Unknown',
'aws.emf.storage_resolution': 1,
custom: 'value',
region: 'us-east-1',
'error.type': 'Error',
'error.origin': 'Unknown',
'error.message': 'Error: test',
'error.stack': 'at x (x.ts:1:1)',
});
});

it('should pass config options to counter', () => {
const error = new Error('test');
error.stack = 'Error: test\n at x (x.ts:1:1)';

scopedTelemetry.error('test.error', error, undefined, {
unit: 'errors',
description: 'Error counter',
});

expect(mockMeter.createCounter).toHaveBeenCalledWith('test.error', {
unit: 'errors',
valueType: 1,
description: 'Error counter',
advice: undefined,
});
expect(mockAdd).toHaveBeenCalledWith(1, {
HandlerSource: 'Unknown',
'aws.emf.storage_resolution': 1,
'error.type': 'Error',
'error.origin': 'Unknown',
'error.message': 'Error: test',
'error.stack': 'at x (x.ts:1:1)',
});
});

it('should handle non-Error string value', () => {
scopedTelemetry.error('test.error', 'string error');

expect(mockAdd).toHaveBeenCalledWith(1, {
HandlerSource: 'Unknown',
'aws.emf.storage_resolution': 1,
'error.type': 'string',
'error.origin': 'Unknown',
});
});

it('should handle non-Error null value', () => {
scopedTelemetry.error('test.error', null);

expect(mockAdd).toHaveBeenCalledWith(1, {
HandlerSource: 'Unknown',
'aws.emf.storage_resolution': 1,
'error.type': 'object',
'error.origin': 'Unknown',
});
});

it('should handle non-Error undefined value', () => {
scopedTelemetry.error('test.error', undefined);

expect(mockAdd).toHaveBeenCalledWith(1, {
HandlerSource: 'Unknown',
'aws.emf.storage_resolution': 1,
'error.type': 'undefined',
'error.origin': 'Unknown',
});
});
});
});
87 changes: 86 additions & 1 deletion tst/unit/utils/Errors.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, test, expect } from 'vitest';
import { extractLocationFromStack } from '../../../src/utils/Errors';
import { errorAttributes, extractLocationFromStack } from '../../../src/utils/Errors';

describe('extractLocationFromStack', () => {
test('returns empty object when stack is undefined', () => {
Expand Down Expand Up @@ -149,3 +149,88 @@ at Object.Module._extensions..js (node:internal/modules/cjs/loader:1213:10)`,
});
});
});

describe('errorAttributes', () => {
test('returns attributes for Error with stack and default origin', () => {
const error = new Error('test message');
error.stack = 'Error: test message\n at func (file.ts:10:5)';

const result = errorAttributes(error);

expect(result).toEqual({
'error.type': 'Error',
'error.origin': 'Unknown',
'error.message': 'Error: test message',
'error.stack': 'at func (file.ts:10:5)',
});
});

test('returns attributes for custom Error type', () => {
const error = new TypeError('type error');
error.stack = 'TypeError: type error\n at func (file.ts:1:1)';

const result = errorAttributes(error);

expect(result).toEqual({
'error.type': 'TypeError',
'error.origin': 'Unknown',
'error.message': 'TypeError: type error',
'error.stack': 'at func (file.ts:1:1)',
});
});

test('returns attributes with uncaughtException origin', () => {
const error = new Error('test');
error.stack = 'Error: test\n at x (x.ts:1:1)';

const result = errorAttributes(error, 'uncaughtException');

expect(result).toEqual({
'error.type': 'Error',
'error.origin': 'uncaughtException',
'error.message': 'Error: test',
'error.stack': 'at x (x.ts:1:1)',
});
});

test('returns attributes with unhandledRejection origin', () => {
const error = new Error('test');
error.stack = 'Error: test\n at x (x.ts:1:1)';

const result = errorAttributes(error, 'unhandledRejection');

expect(result).toEqual({
'error.type': 'Error',
'error.origin': 'unhandledRejection',
'error.message': 'Error: test',
'error.stack': 'at x (x.ts:1:1)',
});
});

test('returns attributes for non-Error string value', () => {
const result = errorAttributes('string error');

expect(result).toEqual({
'error.type': 'string',
'error.origin': 'Unknown',
});
});

test('returns attributes for non-Error null value', () => {
const result = errorAttributes(null);

expect(result).toEqual({
'error.type': 'object',
'error.origin': 'Unknown',
});
});

test('returns attributes for non-Error undefined value', () => {
const result = errorAttributes(undefined);

expect(result).toEqual({
'error.type': 'undefined',
'error.origin': 'Unknown',
});
});
});
Loading