diff --git a/src/autocomplete/ResourceEntityCompletionProvider.ts b/src/autocomplete/ResourceEntityCompletionProvider.ts index 98545997..80ac6f30 100644 --- a/src/autocomplete/ResourceEntityCompletionProvider.ts +++ b/src/autocomplete/ResourceEntityCompletionProvider.ts @@ -24,7 +24,7 @@ export class ResourceEntityCompletionProvider implements CompletionProvider { this.entityFieldProvider = new EntityFieldCompletionProvider(); } - @Measure({ name: 'getCompletions' }) + @Measure({ name: 'getCompletions', extractContextAttributes: true }) getCompletions(context: Context, params: CompletionParams): CompletionItem[] | undefined { const entityCompletions = this.entityFieldProvider.getCompletions(context, params); diff --git a/src/autocomplete/ResourcePropertyCompletionProvider.ts b/src/autocomplete/ResourcePropertyCompletionProvider.ts index c6d7545f..21dee690 100644 --- a/src/autocomplete/ResourcePropertyCompletionProvider.ts +++ b/src/autocomplete/ResourcePropertyCompletionProvider.ts @@ -48,7 +48,7 @@ export class ResourcePropertyCompletionProvider implements CompletionProvider { constructor(private readonly schemaRetriever: SchemaRetriever) {} - @Measure({ name: 'getCompletions' }) + @Measure({ name: 'getCompletions', extractContextAttributes: true }) getCompletions(context: Context, _params: CompletionParams): CompletionItem[] | undefined { // Use unified property completion method for all scenarios const { completions: propertyCompletions, skipFuzzySearch } = this.getPropertyCompletions(context); diff --git a/src/autocomplete/ResourceStateCompletionProvider.ts b/src/autocomplete/ResourceStateCompletionProvider.ts index feaf557e..6c29d81a 100644 --- a/src/autocomplete/ResourceStateCompletionProvider.ts +++ b/src/autocomplete/ResourceStateCompletionProvider.ts @@ -36,7 +36,7 @@ export class ResourceStateCompletionProvider implements CompletionProvider { private readonly schemaRetriever: SchemaRetriever, ) {} - @Measure({ name: 'getCompletions' }) + @Measure({ name: 'getCompletions', extractContextAttributes: true }) public async getCompletions(context: Context, params: CompletionParams): Promise { const resource = context.entity as Resource; if (!resource?.Type || !resource?.Properties) { diff --git a/src/hover/IntrinsicFunctionArgumentHoverProvider.ts b/src/hover/IntrinsicFunctionArgumentHoverProvider.ts index c6d5d940..a2281bb8 100644 --- a/src/hover/IntrinsicFunctionArgumentHoverProvider.ts +++ b/src/hover/IntrinsicFunctionArgumentHoverProvider.ts @@ -18,7 +18,7 @@ import { HoverProvider } from './HoverProvider'; export class IntrinsicFunctionArgumentHoverProvider implements HoverProvider { constructor(private readonly schemaRetriever: SchemaRetriever) {} - @Measure({ name: 'getInformation' }) + @Measure({ name: 'getInformation', extractContextAttributes: true }) getInformation(context: Context, position?: Position): string | undefined { // Only handle contexts that are inside intrinsic functions if (!context.intrinsicContext.inIntrinsic() || context.isIntrinsicFunc) { diff --git a/src/hover/ParameterAttributeHoverProvider.ts b/src/hover/ParameterAttributeHoverProvider.ts index b3a895aa..624a90cf 100644 --- a/src/hover/ParameterAttributeHoverProvider.ts +++ b/src/hover/ParameterAttributeHoverProvider.ts @@ -18,7 +18,7 @@ export class ParameterAttributeHoverProvider implements HoverProvider { 'Type', ]); - @Measure({ name: 'getInformation' }) + @Measure({ name: 'getInformation', extractContextAttributes: true }) getInformation(context: Context): string | undefined { const attributeName = context.text; diff --git a/src/hover/ResourceSectionHoverProvider.ts b/src/hover/ResourceSectionHoverProvider.ts index 051e9248..8b203ebf 100644 --- a/src/hover/ResourceSectionHoverProvider.ts +++ b/src/hover/ResourceSectionHoverProvider.ts @@ -17,7 +17,7 @@ import { HoverProvider } from './HoverProvider'; export class ResourceSectionHoverProvider implements HoverProvider { constructor(private readonly schemaRetriever: SchemaRetriever) {} - @Measure({ name: 'getInformation' }) + @Measure({ name: 'getInformation', extractContextAttributes: true }) getInformation(context: Context) { const resource = context.getResourceEntity(); if (!resource) { diff --git a/src/telemetry/TelemetryDecorator.ts b/src/telemetry/TelemetryDecorator.ts index e072d40d..054174ba 100644 --- a/src/telemetry/TelemetryDecorator.ts +++ b/src/telemetry/TelemetryDecorator.ts @@ -7,6 +7,7 @@ type ScopeDecoratorOptions = { }; type ScopedMetricsDecoratorOptions = { name: string; + extractContextAttributes?: boolean; } & MetricConfig & ScopeDecoratorOptions; @@ -53,12 +54,40 @@ function createTelemetryMethodDecorator(methodNames: MethodNames) { descriptor.value = function (this: any, ...args: any[]) { const telemetry = TelemetryService.instance.get(scopeName(target, decoratorOptions.scope)); + // Extract Context attributes from arguments if enabled + let enhancedConfig = decoratorOptions; + if (decoratorOptions.extractContextAttributes) { + const contextArg = args.find( + (arg) => + arg?.constructor?.name === 'Context' || + arg?.constructor?.name === 'ContextWithRelatedEntities', + ); + if (contextArg) { + const contextAttributes: Record = {}; + try { + contextAttributes['entity.type'] = contextArg.getEntityType(); + contextAttributes['resource.type'] = contextArg.getResourceEntity()?.Type ?? 'unknown'; + contextAttributes['property.path'] = contextArg.propertyPath?.join('.') ?? 'unknown'; + } catch { + // Ignore errors extracting context attributes + } + + enhancedConfig = { + ...decoratorOptions, + attributes: { + ...decoratorOptions.attributes, + ...contextAttributes, + }, + }; + } + } + if (isAsyncFunction(originalMethod)) { const asyncMethod = telemetry[methodNames.async].bind(telemetry); - return asyncMethod(metricName, () => originalMethod.apply(this, args), decoratorOptions); + return asyncMethod(metricName, () => originalMethod.apply(this, args), enhancedConfig); } else { const syncMethod = telemetry[methodNames.sync].bind(telemetry); - return syncMethod(metricName, () => originalMethod.apply(this, args), decoratorOptions); + return syncMethod(metricName, () => originalMethod.apply(this, args), enhancedConfig); } }; diff --git a/tst/unit/telemetry/ScopedTelemetry.test.ts b/tst/unit/telemetry/ScopedTelemetry.test.ts index d8a14179..a7fb24a6 100644 --- a/tst/unit/telemetry/ScopedTelemetry.test.ts +++ b/tst/unit/telemetry/ScopedTelemetry.test.ts @@ -66,6 +66,23 @@ describe('ScopedTelemetry', () => { expect(() => scopedTelemetry.measure('test', fn)).toThrow('test error'); expect(mockMeter.createCounter).toHaveBeenCalledWith('test.fault', expect.any(Object)); }); + + it('should record fault with error attributes', () => { + const mockCounter = { add: vi.fn() }; + mockMeter.createCounter.mockReturnValue(mockCounter); + + const fn = vi.fn(() => { + throw new TypeError('test error'); + }); + + expect(() => scopedTelemetry.measure('test', fn)).toThrow('test error'); + expect(mockCounter.add).toHaveBeenCalledWith( + 1, + expect.objectContaining({ + 'error.type': 'TypeError', + }), + ); + }); }); describe('measureAsync', () => { @@ -90,6 +107,23 @@ describe('ScopedTelemetry', () => { await expect(scopedTelemetry.measureAsync('test', fn)).rejects.toThrow('test error'); expect(mockMeter.createCounter).toHaveBeenCalledWith('test.fault', expect.any(Object)); }); + + it('should record fault with error attributes on async error', async () => { + const mockCounter = { add: vi.fn() }; + mockMeter.createCounter.mockReturnValue(mockCounter); + + const fn = vi.fn(() => { + return Promise.reject(new ReferenceError('test error')); + }); + + await expect(scopedTelemetry.measureAsync('test', fn)).rejects.toThrow('test error'); + expect(mockCounter.add).toHaveBeenCalledWith( + 1, + expect.objectContaining({ + 'error.type': 'ReferenceError', + }), + ); + }); }); describe('trackExecution', () => { diff --git a/tst/unit/telemetry/TelemetryDecorator.test.ts b/tst/unit/telemetry/TelemetryDecorator.test.ts index f23a9b1c..2cba606a 100644 --- a/tst/unit/telemetry/TelemetryDecorator.test.ts +++ b/tst/unit/telemetry/TelemetryDecorator.test.ts @@ -262,5 +262,72 @@ describe('TelemetryDecorator', () => { valueType: 1, }); }); + + it('should extract context attributes when enabled', () => { + const mockContext = { + constructor: { name: 'Context' }, + getEntityType: () => 'Resource', + getResourceEntity: () => ({ Type: 'AWS::S3::Bucket' }), + propertyPath: ['Resources', 'MyBucket', 'Properties'], + }; + + class TestClass { + @Measure({ name: 'method', extractContextAttributes: true }) + method(_context: any) { + return 'result'; + } + } + + new TestClass().method(mockContext); + + expect(mockTelemetry.measure).toHaveBeenCalledWith('method', expect.any(Function), { + name: 'method', + extractContextAttributes: true, + attributes: { + 'entity.type': 'Resource', + 'resource.type': 'AWS::S3::Bucket', + 'property.path': 'Resources.MyBucket.Properties', + }, + }); + }); + + it('should work without context when extraction enabled', () => { + class TestClass { + @Measure({ name: 'method', extractContextAttributes: true }) + method(_otherParam: string) { + return 'result'; + } + } + + new TestClass().method('test'); + + expect(mockTelemetry.measure).toHaveBeenCalledWith('method', expect.any(Function), { + name: 'method', + extractContextAttributes: true, + }); + }); + + it('should handle context extraction errors gracefully', () => { + const mockContext = { + constructor: { name: 'Context' }, + getResourceEntity: () => { + throw new Error('test error'); + }, + }; + + class TestClass { + @Measure({ name: 'method', extractContextAttributes: true }) + method(_context: any) { + return 'result'; + } + } + + expect(() => new TestClass().method(mockContext)).not.toThrow(); + expect(mockTelemetry.measure).toHaveBeenCalledWith('method', expect.any(Function), { + name: 'method', + extractContextAttributes: true, + attributes: {}, + }); + }); }); });