Skip to content

feat(tracer): allow disabling result capture for decorators and middleware #1065

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
68 changes: 68 additions & 0 deletions docs/core/tracer.md
Original file line number Diff line number Diff line change
Expand Up @@ -412,6 +412,40 @@ Use **`POWERTOOLS_TRACER_CAPTURE_RESPONSE=false`** environment variable to instr
2. You might manipulate **streaming objects that can be read only once**; this prevents subsequent calls from being empty
3. You might return **more than 64K** of data _e.g., `message too long` error_

### Disabling response capture for targeted methods and handlers

Use the `captureResponse: false` option in both `tracer.captureLambdaHandler()` and `tracer.captureMethod()` decorators to instruct Tracer **not** to serialize function responses as metadata.

=== "method.ts"

```typescript hl_lines="5"
import { Tracer } from '@aws-lambda-powertools/tracer';

const tracer = new Tracer({ serviceName: 'serverlessAirline' });
class MyThing {
@tracer.captureMethod({ captureResponse: false })
myMethod(): string {
/* ... */
return 'foo bar';
}
}
```

=== "handler.ts"

```typescript hl_lines="6"
import { Tracer } from '@aws-lambda-powertools/tracer';
import { LambdaInterface } from '@aws-lambda-powertools/commons';

const tracer = new Tracer({ serviceName: 'serverlessAirline' });
class MyHandler implements LambdaInterface {
@tracer.captureLambdaHandler({ captureResponse: false })
async handler(_event: any, _context: any): Promise<void> {
/* ... */
}
}
```

### Disabling exception auto-capture

Use **`POWERTOOLS_TRACER_CAPTURE_ERROR=false`** environment variable to instruct Tracer **not** to serialize exceptions as metadata.
Expand All @@ -420,6 +454,40 @@ Use **`POWERTOOLS_TRACER_CAPTURE_ERROR=false`** environment variable to instruct

1. You might **return sensitive** information from exceptions, stack traces you might not control

### Disabling exception capture for targeted methods and handlers

Use the `captureError: false` option in both `tracer.captureLambdaHandler()` and `tracer.captureMethod()` decorators to instruct Tracer **not** to serialize exceptions as metadata.

=== "method.ts"

```typescript hl_lines="5"
import { Tracer } from '@aws-lambda-powertools/tracer';

const tracer = new Tracer({ serviceName: 'serverlessAirline' });
class MyThing {
@tracer.captureMethod({ captureError: false })
myMethod(): string {
/* ... */
return 'foo bar';
}
}
```

=== "handler.ts"

```typescript hl_lines="6"
import { Tracer } from '@aws-lambda-powertools/tracer';
import { LambdaInterface } from '@aws-lambda-powertools/commons';

const tracer = new Tracer({ serviceName: 'serverlessAirline' });
class MyHandler implements LambdaInterface {
@tracer.captureLambdaHandler({ captureError: false })
async handler(_event: any, _context: any): Promise<void> {
/* ... */
}
}
```

### Escape hatch mechanism

You can use `tracer.provider` attribute to access all methods provided by the [AWS X-Ray SDK](https://docs.aws.amazon.com/xray-sdk-for-nodejs/latest/reference/AWSXRay.html).
Expand Down
32 changes: 25 additions & 7 deletions packages/tracer/src/Tracer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { Handler } from 'aws-lambda';
import { AsyncHandler, SyncHandler, Utility } from '@aws-lambda-powertools/commons';
import { TracerInterface } from '.';
import { ConfigServiceInterface, EnvironmentVariablesService } from './config';
import { HandlerMethodDecorator, TracerOptions, MethodDecorator } from './types';
import { HandlerMethodDecorator, TracerOptions, TracerCaptureMethodOptions, TracerCaptureLambdaHandlerOptions, MethodDecorator } from './types';
import { ProviderService, ProviderServiceInterface } from './provider';
import { Segment, Subsegment } from 'aws-xray-sdk-core';

Expand Down Expand Up @@ -339,7 +339,7 @@ class Tracer extends Utility implements TracerInterface {
*
* @decorator Class
*/
public captureLambdaHandler(): HandlerMethodDecorator {
public captureLambdaHandler(options?: TracerCaptureLambdaHandlerOptions): HandlerMethodDecorator {
return (_target, _propertyKey, descriptor) => {
/**
* The descriptor.value is the method this decorator decorates, it cannot be undefined.
Expand All @@ -365,9 +365,18 @@ class Tracer extends Utility implements TracerInterface {
let result: unknown;
try {
result = await originalMethod.apply(handlerRef, [ event, context, callback ]);
tracerRef.addResponseAsMetadata(result, process.env._HANDLER);
if (options?.captureResponse ?? true) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
if (options?.captureResponse ?? true) {
if (options && options?.captureResponse === true) {

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we convert all the instances of this if/else to this notation? This is mostly for readability & consistency with the rest of the codebase (example)

Copy link
Contributor Author

@misterjoshua misterjoshua Aug 19, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can make readability improvements here. But, before we do that, I'd like to point out that before this PR, @tracer.captureMethod() was capturing results by default unless the environment variable was set. For the newly proposed logic as provided, I believe these two cases would not capture responses by default:

  • @tracer.captureMethod() -> options will be undefined and the first conjunct of the conditional will be false.
  • @tracer.captureMethod({ captureResponse: undefined }) -> options?.captureResponse will not equal true, therefore the second conjunct of the conditional will be false.

I believe backward compatible logic here would be either !options || options.captureResponse !== false or !(options && options.captureResponse === false), but at least to me, either requires some mental gymnastics to read.

For comparison's sake: To make the code more readable, in these cases in the CDK project, we often do two things:

We put our defaults higher up in a block of code, assigning them earlier, more conspicuously as default values, where the function of nullish coalescing (??) is more obvious. Like this: https://github.com/aws/aws-cdk/blob/1e305e6eed6b4ede78df10cbaadb8b578c1e6baa/packages/%40aws-cdk/aws-lambda-nodejs/lib/function.ts#L94-L96

We also typically provide an empty object for optional arguments so we can avoid the optional chaining (?.) operator. https://github.com/aws/aws-cdk/blob/1e305e6eed6b4ede78df10cbaadb8b578c1e6baa/packages/%40aws-cdk/aws-lambda-nodejs/lib/function.ts#L87

If we took these approaches, perhaps the code could look like this:

public captureLambdaHandler(options: TracerCaptureLambdaHandlerOptions = {}): HandlerMethodDecorator {

  // Capture response by default.
  const captureResponse = options.captureResponse ?? true;

  // ...

  if (captureResponse) {
    // ...
  }

  // ...
}

Let me know what you think.

Copy link
Contributor

@dreamorosi dreamorosi Aug 22, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you for the explanation, I now understand your original proposal & I agree with the syntax :)

I'm going to leave this open so that others can benefit from this explanation.

tracerRef.addResponseAsMetadata(result, process.env._HANDLER);
}

} catch (error) {
tracerRef.addErrorAsMetadata(error as Error);
if (options?.captureError ?? true) {
tracerRef.addErrorAsMetadata(error as Error);
} else {
// Though we aren't adding the error as metadata, we should still
// mark the segment as having an error.
tracerRef.getSegment().addErrorFlag();
}
throw error;
} finally {
subsegment?.close();
Expand Down Expand Up @@ -416,7 +425,7 @@ class Tracer extends Utility implements TracerInterface {
*
* @decorator Class
*/
public captureMethod(): MethodDecorator {
public captureMethod(options?: TracerCaptureMethodOptions): MethodDecorator {
return (_target, _propertyKey, descriptor) => {
// The descriptor.value is the method this decorator decorates, it cannot be undefined.
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
Expand All @@ -435,9 +444,18 @@ class Tracer extends Utility implements TracerInterface {
let result;
try {
result = await originalMethod.apply(this, [...args]);
tracerRef.addResponseAsMetadata(result, originalMethod.name);
if (options?.captureResponse ?? true) {
tracerRef.addResponseAsMetadata(result, originalMethod.name);
}

} catch (error) {
tracerRef.addErrorAsMetadata(error as Error);
if (options?.captureError ?? true) {
tracerRef.addErrorAsMetadata(error as Error);
} else {
// Though we aren't adding the error as metadata, we should still
// mark the segment as having an error.
tracerRef.getSegment().addErrorFlag();
}

throw error;
} finally {
Expand Down
42 changes: 42 additions & 0 deletions packages/tracer/src/types/Tracer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,46 @@ type TracerOptions = {
customConfigService?: ConfigServiceInterface
};

/**
* Options for the captureMethod decorator to be used when decorating a method.
*
* Usage:
* @example
* ```typescript
* const tracer = new Tracer();
*
* class MyThing {
* @tracer.captureMethod({ captureResponse: false, captureError: false })
* myMethod(): string {
* return 'foo bar';
* }
* }
* ```
*/
type TracerCaptureMethodOptions = {
captureResponse?: boolean
captureError?: boolean
};

/**
* Options for the captureLambdaHandler decorator to be used when decorating a method.
*
* Usage:
* @example
* ```typescript
* const tracer = new Tracer();
*
* class MyThing implements LambdaInterface {
* @tracer.captureLambdaHandler({ captureResponse: false, captureError: false })
* async handler(_event: any, _context: any): Promise<void> {}
* }
* ```
*/
type TracerCaptureLambdaHandlerOptions = {
captureResponse?: boolean
captureError?: boolean
};

type HandlerMethodDecorator = (
target: LambdaInterface,
propertyKey: string | symbol,
Expand All @@ -38,6 +78,8 @@ type MethodDecorator = (target: any, propertyKey: string | symbol, descriptor: T

export {
TracerOptions,
TracerCaptureLambdaHandlerOptions,
TracerCaptureMethodOptions,
HandlerMethodDecorator,
MethodDecorator
};
152 changes: 152 additions & 0 deletions packages/tracer/tests/unit/Tracer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -648,6 +648,36 @@ describe('Class: Tracer', () => {

});

test('when used as decorator while captureResponse is set to false, it does not capture the response as metadata', async () => {

// Prepare
const tracer: Tracer = new Tracer();
const newSubsegment: Segment | Subsegment | undefined = new Subsegment('## index.handler');
jest.spyOn(tracer.provider, 'getSegment').mockImplementation(() => newSubsegment);
setContextMissingStrategy(() => null);
const captureAsyncFuncSpy = jest.spyOn(tracer.provider, 'captureAsyncFunc');
class Lambda implements LambdaInterface {

@tracer.captureLambdaHandler({ captureResponse: false })
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
public handler<TEvent, TResult>(_event: TEvent, _context: Context, _callback: Callback<TResult>): void | Promise<TResult> {
return new Promise((resolve, _reject) => resolve({
foo: 'bar'
} as unknown as TResult));
}

}

// Act
await new Lambda().handler(event, context, () => console.log('Lambda invoked!'));

// Assess
expect(captureAsyncFuncSpy).toHaveBeenCalledTimes(1);
expect('metadata' in newSubsegment).toBe(false);

});

test('when used as decorator and with standard config, it captures the response as metadata', async () => {

// Prepare
Expand Down Expand Up @@ -727,6 +757,41 @@ describe('Class: Tracer', () => {

});

test('when used as decorator while captureError is set to false, it does not capture the exceptions', async () => {

// Prepare
const tracer: Tracer = new Tracer();
const newSubsegment: Segment | Subsegment | undefined = new Subsegment('## index.handler');
jest.spyOn(tracer.provider, 'getSegment')
.mockImplementation(() => newSubsegment);
setContextMissingStrategy(() => null);
const captureAsyncFuncSpy = jest.spyOn(tracer.provider, 'captureAsyncFunc');
const addErrorSpy = jest.spyOn(newSubsegment, 'addError');
const addErrorFlagSpy = jest.spyOn(newSubsegment, 'addErrorFlag');
class Lambda implements LambdaInterface {

@tracer.captureLambdaHandler({ captureError: false })
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
public handler<TEvent, TResult>(_event: TEvent, _context: Context, _callback: Callback<TResult>): void | Promise<TResult> {
throw new Error('Exception thrown!');
}

}

// Act & Assess
await expect(new Lambda().handler({}, context, () => console.log('Lambda invoked!'))).rejects.toThrowError(Error);
expect(captureAsyncFuncSpy).toHaveBeenCalledTimes(1);
expect(newSubsegment).toEqual(expect.objectContaining({
name: '## index.handler',
}));
expect('cause' in newSubsegment).toBe(false);
expect(addErrorFlagSpy).toHaveBeenCalledTimes(1);
expect(addErrorSpy).toHaveBeenCalledTimes(0);
expect.assertions(6);

});

test('when used as decorator and with standard config, it captures the exception correctly', async () => {

// Prepare
Expand Down Expand Up @@ -964,6 +1029,52 @@ describe('Class: Tracer', () => {

});

test('when used as decorator and with captureResponse set to false, it does not capture the response as metadata', async () => {

// Prepare
const tracer: Tracer = new Tracer();
const newSubsegment: Segment | Subsegment | undefined = new Subsegment('### dummyMethod');
jest.spyOn(newSubsegment, 'flush').mockImplementation(() => null);
jest.spyOn(tracer.provider, 'getSegment')
.mockImplementation(() => newSubsegment);
setContextMissingStrategy(() => null);
const captureAsyncFuncSpy = jest.spyOn(tracer.provider, 'captureAsyncFunc');
class Lambda implements LambdaInterface {

@tracer.captureMethod({ captureResponse: false })
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
public async dummyMethod(some: string): Promise<string> {
return new Promise((resolve, _reject) => setTimeout(() => resolve(some), 3000));
}

public async handler<TEvent, TResult>(_event: TEvent, _context: Context, _callback: Callback<TResult>): Promise<TResult> {
const result = await this.dummyMethod('foo bar');

return new Promise((resolve, _reject) => resolve(result as unknown as TResult));
}

}

// Act
await new Lambda().handler(event, context, () => console.log('Lambda invoked!'));

// Assess
expect(captureAsyncFuncSpy).toHaveBeenCalledTimes(1);
expect(captureAsyncFuncSpy).toHaveBeenCalledWith('### dummyMethod', expect.anything());
expect(newSubsegment).toEqual(expect.objectContaining({
name: '### dummyMethod',
}));
expect(newSubsegment).not.toEqual(expect.objectContaining({
metadata: {
'hello-world': {
'dummyMethod response': 'foo bar',
},
}
}));

});

test('when used as decorator and with standard config, it captures the exception correctly', async () => {

// Prepare
Expand Down Expand Up @@ -1004,6 +1115,47 @@ describe('Class: Tracer', () => {

});

test('when used as decorator and with captureError set to false, it does not capture the exception', async () => {

// Prepare
const tracer: Tracer = new Tracer();
const newSubsegment: Segment | Subsegment | undefined = new Subsegment('### dummyMethod');
jest.spyOn(tracer.provider, 'getSegment')
.mockImplementation(() => newSubsegment);
setContextMissingStrategy(() => null);
const captureAsyncFuncSpy = createCaptureAsyncFuncMock(tracer.provider);
const addErrorSpy = jest.spyOn(newSubsegment, 'addError');
const addErrorFlagSpy = jest.spyOn(newSubsegment, 'addErrorFlag');
class Lambda implements LambdaInterface {

@tracer.captureMethod({ captureError: false })
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
public async dummyMethod(_some: string): Promise<string> {
throw new Error('Exception thrown!');
}

public async handler<TEvent, TResult>(_event: TEvent, _context: Context, _callback: Callback<TResult>): Promise<TResult> {
const result = await this.dummyMethod('foo bar');

return new Promise((resolve, _reject) => resolve(result as unknown as TResult));
}

}

// Act / Assess
await expect(new Lambda().handler({}, context, () => console.log('Lambda invoked!'))).rejects.toThrowError(Error);
expect(captureAsyncFuncSpy).toHaveBeenCalledTimes(1);
expect(newSubsegment).toEqual(expect.objectContaining({
name: '### dummyMethod',
}));
expect('cause' in newSubsegment).toBe(false);
expect(addErrorFlagSpy).toHaveBeenCalledTimes(1);
expect(addErrorSpy).toHaveBeenCalledTimes(0);
expect.assertions(6);

});

test('when used as decorator and when calling other methods/props in the class they are called in the orginal scope', async () => {

// Prepare
Expand Down