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
2 changes: 1 addition & 1 deletion src/autocomplete/ResourceEntityCompletionProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ export class ResourceEntityCompletionProvider implements CompletionProvider {
this.entityFieldProvider = new EntityFieldCompletionProvider<Resource>();
}

@Measure({ name: 'getCompletions' })
@Measure({ name: 'getCompletions', extractContextAttributes: true })
getCompletions(context: Context, params: CompletionParams): CompletionItem[] | undefined {
const entityCompletions = this.entityFieldProvider.getCompletions(context, params);

Expand Down
2 changes: 1 addition & 1 deletion src/autocomplete/ResourcePropertyCompletionProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
2 changes: 1 addition & 1 deletion src/autocomplete/ResourceStateCompletionProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<CompletionItem[]> {
const resource = context.entity as Resource;
if (!resource?.Type || !resource?.Properties) {
Expand Down
2 changes: 1 addition & 1 deletion src/hover/IntrinsicFunctionArgumentHoverProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
2 changes: 1 addition & 1 deletion src/hover/ParameterAttributeHoverProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
2 changes: 1 addition & 1 deletion src/hover/ResourceSectionHoverProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
33 changes: 31 additions & 2 deletions src/telemetry/TelemetryDecorator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ type ScopeDecoratorOptions = {
};
type ScopedMetricsDecoratorOptions = {
name: string;
extractContextAttributes?: boolean;
} & MetricConfig &
ScopeDecoratorOptions;

Expand Down Expand Up @@ -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<string, string> = {};
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);
}
};

Expand Down
34 changes: 34 additions & 0 deletions tst/unit/telemetry/ScopedTelemetry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand All @@ -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', () => {
Expand Down
67 changes: 67 additions & 0 deletions tst/unit/telemetry/TelemetryDecorator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {},
});
});
});
});
Loading