diff --git a/src/telemetry/ScopedTelemetry.ts b/src/telemetry/ScopedTelemetry.ts index b10c20e3..111d6b77 100644 --- a/src/telemetry/ScopedTelemetry.ts +++ b/src/telemetry/ScopedTelemetry.ts @@ -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'; @@ -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; @@ -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; } } @@ -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; } } @@ -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); @@ -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); @@ -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), }; } diff --git a/src/telemetry/TelemetryService.ts b/src/telemetry/TelemetryService.ts index ab5f879b..351d4a3d 100644 --- a/src/telemetry/TelemetryService.ts +++ b/src/telemetry/TelemetryService.ts @@ -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'; @@ -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(); }); } diff --git a/src/utils/Errors.ts b/src/utils/Errors.ts index 46cb5dec..05ab34f2 100644 --- a/src/utils/Errors.ts +++ b/src/utils/Errors.ts @@ -1,3 +1,4 @@ +import { Attributes } from '@opentelemetry/api'; import { ErrorCodes, ResponseError } from 'vscode-languageserver'; import { determineSensitiveInfo } from './ErrorStackInfo'; import { toString } from './String'; @@ -50,3 +51,14 @@ export function extractLocationFromStack(stack?: string): Record 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, + }; +} diff --git a/tst/unit/telemetry/ScopedTelemetry.test.ts b/tst/unit/telemetry/ScopedTelemetry.test.ts index a8e48b7e..d8a14179 100644 --- a/tst/unit/telemetry/ScopedTelemetry.test.ts +++ b/tst/unit/telemetry/ScopedTelemetry.test.ts @@ -168,4 +168,145 @@ describe('ScopedTelemetry', () => { expect(scopedTelemetry['histograms'].size).toBe(0); }); }); + + describe('error', () => { + let mockAdd: ReturnType; + + 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', + }); + }); + }); }); diff --git a/tst/unit/utils/Errors.test.ts b/tst/unit/utils/Errors.test.ts index 950d88e9..486c6a94 100644 --- a/tst/unit/utils/Errors.test.ts +++ b/tst/unit/utils/Errors.test.ts @@ -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', () => { @@ -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', + }); + }); +});