Skip to content

Commit

Permalink
feat(tracer): instrument fetch requests (#2293)
Browse files Browse the repository at this point in the history
* feat(tracer): capture fetch requests

* chore: clean up code

* chore: swap dummy url in tests

* chore: use undici-types

* chore: integration tests

* tests: update tests

* docs: update docs to mention fetch

* tests: update integration tests for fetch

* improv: handle failed connection case

* chore: removed leftover file

* Trigger Build

---------

Co-authored-by: Alexander Schueren <sha@amazon.com>
  • Loading branch information
dreamorosi and am29d committed Apr 5, 2024
1 parent 082b626 commit cc34400
Show file tree
Hide file tree
Showing 11 changed files with 548 additions and 20 deletions.
9 changes: 3 additions & 6 deletions docs/core/tracer.md
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
@@ -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
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
@@ -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
@@ -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 };
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
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
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

0 comments on commit cc34400

Please sign in to comment.