Skip to content
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

feat(tracer): instrument fetch requests #2293

Merged
merged 12 commits into from
Apr 5, 2024
9 changes: 3 additions & 6 deletions docs/core/tracer.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ Tracer is an opinionated thin wrapper for [AWS X-Ray SDK for Node.js](https://gi
## Key features

* Auto-capturing cold start and service name as annotations, and responses or full exceptions as metadata.
* Automatically tracing HTTP(S) clients and generating segments for each request.
* Automatically tracing HTTP(S) clients including `fetch` and generating segments for each request.
* Supporting tracing functions via decorators, middleware, and manual instrumentation.
* Supporting tracing AWS SDK v2 and v3 via AWS X-Ray SDK for Node.js.
* Auto-disable tracing when not running in the Lambda environment.
Expand Down Expand Up @@ -211,12 +211,12 @@ If you're looking to shave a few microseconds, or milliseconds depending on your

### Tracing HTTP requests

When your function makes calls to HTTP APIs, Tracer automatically traces those calls and add the API to the service graph as a downstream service.
When your function makes outgoing requests to APIs, Tracer automatically traces those calls and adds the API to the service graph as a downstream service.

You can opt-out from this feature by setting the **`POWERTOOLS_TRACER_CAPTURE_HTTPS_REQUESTS=false`** environment variable or by passing the `captureHTTPSRequests: false` option to the `Tracer` constructor.

!!! info
The following snippet shows how to trace [axios](https://www.npmjs.com/package/axios) requests, but you can use any HTTP client library built on top of [http](https://nodejs.org/api/http.html) or [https](https://nodejs.org/api/https.html).
The following snippet shows how to trace [`fetch`](https://developer.mozilla.org/en-US/docs/Web/API/fetch) requests, but you can use any HTTP client library built on top it, or on [http](https://nodejs.org/api/http.html), and [https](https://nodejs.org/api/https.html).
Support to 3rd party HTTP clients is provided on a best effort basis.

=== "index.ts"
Expand All @@ -225,9 +225,6 @@ You can opt-out from this feature by setting the **`POWERTOOLS_TRACER_CAPTURE_HT
--8<-- "docs/snippets/tracer/captureHTTP.ts"
```

1. You can install the [axios](https://www.npmjs.com/package/axios) package using `npm i axios`
=== "Example Raw X-Ray Trace excerpt"

```json hl_lines="6 9 12-21"
{
"id": "22883fbc730e3a0b",
Expand Down
3 changes: 1 addition & 2 deletions docs/snippets/tracer/captureHTTP.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,10 @@
import { Tracer } from '@aws-lambda-powertools/tracer';
import axios from 'axios'; // (1)

new Tracer({ serviceName: 'serverlessAirline' });

export const handler = async (
_event: unknown,
_context: unknown
): Promise<void> => {
await axios.get('https://httpbin.org/status/200');
await fetch('https://httpbin.org/status/200');
};
3 changes: 2 additions & 1 deletion packages/tracer/src/Tracer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import type {
CaptureMethodOptions,
} from './types/Tracer.js';
import { ProviderService } from './provider/ProviderService.js';
import type { ProviderServiceInterface } from './types/ProviderServiceInterface.js';
import type { ProviderServiceInterface } from './types/ProviderService.js';
import type { Segment, Subsegment } from 'aws-xray-sdk-core';
import xraySdk from 'aws-xray-sdk-core';
const { Subsegment: XraySubsegment } = xraySdk;
Expand Down Expand Up @@ -153,6 +153,7 @@ class Tracer extends Utility implements TracerInterface {
this.provider = new ProviderService();
if (this.isTracingEnabled() && this.captureHTTPsRequests) {
this.provider.captureHTTPsGlobal();
this.provider.instrumentFetch();
}
if (!this.isTracingEnabled()) {
// Tell x-ray-sdk to not throw an error if context is missing but tracing is disabled
Expand Down
125 changes: 123 additions & 2 deletions packages/tracer/src/provider/ProviderService.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { Namespace } from 'cls-hooked';
import type { Namespace } from 'cls-hooked';
import type {
ProviderServiceInterface,
ContextMissingStrategy,
} from '../types/ProviderServiceInterface.js';
HttpSubsegment,
} from '../types/ProviderService.js';
import type { Segment, Subsegment, Logger } from 'aws-xray-sdk-core';
import xraySdk from 'aws-xray-sdk-core';
const {
Expand All @@ -21,6 +22,13 @@ const {
setLogger,
} = xraySdk;
import { addUserAgentMiddleware } from '@aws-lambda-powertools/commons';
import { subscribe } from 'node:diagnostics_channel';
import {
findHeaderAndDecode,
getOriginURL,
isHttpSubsegment,
} from './utilities.js';
import type { DiagnosticsChannel } from 'undici-types';

class ProviderService implements ProviderServiceInterface {
public captureAWS<T>(awssdk: T): T {
Expand Down Expand Up @@ -70,6 +78,119 @@ class ProviderService implements ProviderServiceInterface {
return getSegment();
}

/**
* Instrument `fetch` requests with AWS X-Ray
*
* The instrumentation is done by subscribing to the `undici` events. When a request is created,
* a new subsegment is created with the hostname of the request.
*
* Then, when the headers are received, the subsegment is updated with the request and response details.
*
* Finally, when the request is completed, the subsegment is closed.
*
* @see {@link https://nodejs.org/api/diagnostics_channel.html#diagnostics_channel_channel_publish | Diagnostics Channel - Node.js Documentation}
*/
public instrumentFetch(): void {
/**
* Create a segment at the start of a request made with `undici` or `fetch`.
*
* @note that `message` must be `unknown` because that's the type expected by `subscribe`
*
* @param message The message received from the `undici` channel
*/
const onRequestStart = (message: unknown): void => {
const { request } = message as DiagnosticsChannel.RequestCreateMessage;

const parentSubsegment = this.getSegment();
if (parentSubsegment && request.origin) {
const origin = getOriginURL(request.origin);
const method = request.method;

const subsegment = parentSubsegment.addNewSubsegment(origin.hostname);
subsegment.addAttribute('namespace', 'remote');

(subsegment as HttpSubsegment).http = {
request: {
url: origin.hostname,
method,
},
};

this.setSegment(subsegment);
}
};

/**
* Enrich the subsegment with the response details, and close it.
* Then, set the parent segment as the active segment.
*
* @note that `message` must be `unknown` because that's the type expected by `subscribe`
*
* @param message The message received from the `undici` channel
*/
const onResponse = (message: unknown): void => {
const { response } = message as DiagnosticsChannel.RequestHeadersMessage;

const subsegment = this.getSegment();
if (isHttpSubsegment(subsegment)) {
const status = response.statusCode;
const contentLenght = findHeaderAndDecode(
response.headers,
'content-length'
);

subsegment.http = {
...subsegment.http,
response: {
status,
...(contentLenght && {
content_length: parseInt(contentLenght),
}),
},
};

if (status === 429) {
subsegment.addThrottleFlag();
}
if (status >= 400 && status < 500) {
subsegment.addErrorFlag();
} else if (status >= 500 && status < 600) {
subsegment.addFaultFlag();
}

subsegment.close();
this.setSegment(subsegment.parent);
}
};

/**
* Add an error to the subsegment when the request fails.
*
* This is used to handle the case when the request fails to establish a connection with the server or timeouts.
* In all other cases, for example, when the server returns a 4xx or 5xx status code, the error is added in the `onResponse` function.
*
* @note that `message` must be `unknown` because that's the type expected by `subscribe`
*
* @param message The message received from the `undici` channel
*/
const onError = (message: unknown): void => {
const { error } = message as DiagnosticsChannel.RequestErrorMessage;

const subsegment = this.getSegment();
if (isHttpSubsegment(subsegment)) {
subsegment.addErrorFlag();
error instanceof Error && subsegment.addError(error, true);

subsegment.close();
this.setSegment(subsegment.parent);
}
};

subscribe('undici:request:create', onRequestStart);
subscribe('undici:request:headers', onResponse);
subscribe('undici:request:error', onError);
}

public putAnnotation(key: string, value: string | number | boolean): void {
const segment = this.getSegment();
if (segment === undefined) {
Expand Down
63 changes: 63 additions & 0 deletions packages/tracer/src/provider/utilities.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import type { HttpSubsegment } from '../types/ProviderService.js';
import type { Segment, Subsegment } from 'aws-xray-sdk-core';
import { URL } from 'node:url';

const decoder = new TextDecoder();

/**
* The `fetch` implementation based on `undici` includes the headers as an array of encoded key-value pairs.
* This function finds the header with the given key and decodes the value.
*
* The function walks through the array of encoded headers and decodes the key of each pair.
* If the key matches the given key, the function returns the decoded value of the next element in the array.
*
* @param encodedHeaders The array of encoded headers
* @param key The key to search for
*/
const findHeaderAndDecode = (
encodedHeaders: Uint8Array[],
key: string
): string | null => {
let foundIndex = -1;
for (let i = 0; i < encodedHeaders.length; i += 2) {
const header = decoder.decode(encodedHeaders[i]);
if (header.toLowerCase() === key) {
foundIndex = i;
break;
}
}

if (foundIndex === -1) {
return null;
}

return decoder.decode(encodedHeaders[foundIndex + 1]);
};

/**
* Type guard to check if the given subsegment is an `HttpSubsegment`
*
* @param subsegment The subsegment to check
*/
const isHttpSubsegment = (
subsegment: Segment | Subsegment | undefined
): subsegment is HttpSubsegment => {
return (
subsegment !== undefined &&
'http' in subsegment &&
'parent' in subsegment &&
'namespace' in subsegment &&
subsegment.namespace === 'remote'
);
};

/**
* Convert the origin url to a URL object when it is a string
*
* @param origin The origin url
*/
const getOriginURL = (origin: string | URL): URL => {
return origin instanceof URL ? origin : new URL(origin);
};

export { findHeaderAndDecode, isHttpSubsegment, getOriginURL };
Original file line number Diff line number Diff line change
Expand Up @@ -40,9 +40,35 @@ interface ProviderServiceInterface {

captureHTTPsGlobal(): void;

/**
* Instrument `fetch` requests with AWS X-Ray
*/
instrumentFetch(): void;

putAnnotation(key: string, value: string | number | boolean): void;

putMetadata(key: string, value: unknown, namespace?: string): void;
}

export type { ProviderServiceInterface, ContextMissingStrategy };
/**
* Subsegment that contains information for a request made to a remote service
*/
interface HttpSubsegment extends Subsegment {
namespace: 'remote';
http: {
request?: {
url: string;
method?: string;
};
response?: {
status: number;
content_length?: number;
};
};
}

export type {
ProviderServiceInterface,
ContextMissingStrategy,
HttpSubsegment,
};
3 changes: 3 additions & 0 deletions packages/tracer/src/types/Tracer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@ import type { Segment, Subsegment } from 'aws-xray-sdk-core';
type TracerOptions = {
enabled?: boolean;
serviceName?: string;
/**
* Whether to trace outgoing HTTP requests made with the `http`, `https`, or `fetch` modules
*/
captureHTTPsRequests?: boolean;
customConfigService?: ConfigServiceInterface;
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,10 +55,13 @@ export class MyFunctionBase {
Item: { id: `${serviceName}-${event.invocation}-sdkv3` },
})
);
await axios.get(
'https://docs.powertools.aws.dev/lambda/typescript/latest/',
{ timeout: 5000 }
);
const url = 'https://docs.powertools.aws.dev/lambda/typescript/latest/';
// Add conditional behavior because fetch is not available in Node.js 16 - this can be removed once we drop support for Node.js 16
if (process.version.startsWith('v16')) {
await axios.get(url, { timeout: 5000 });
} else {
await fetch(url);
}

const res = this.myMethod();
if (event.throw) {
Expand Down
Loading