Skip to content

Commit cc34400

Browse files
dreamorosiam29d
andauthored
feat(tracer): instrument fetch requests (#2293)
* 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 <[email protected]>
1 parent 082b626 commit cc34400

File tree

11 files changed

+548
-20
lines changed

11 files changed

+548
-20
lines changed

Diff for: docs/core/tracer.md

+3-6
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ Tracer is an opinionated thin wrapper for [AWS X-Ray SDK for Node.js](https://gi
88
## Key features
99

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

212212
### Tracing HTTP requests
213213

214-
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.
214+
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.
215215

216216
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.
217217

218218
!!! info
219-
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).
219+
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).
220220
Support to 3rd party HTTP clients is provided on a best effort basis.
221221

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

228-
1. You can install the [axios](https://www.npmjs.com/package/axios) package using `npm i axios`
229-
=== "Example Raw X-Ray Trace excerpt"
230-
231228
```json hl_lines="6 9 12-21"
232229
{
233230
"id": "22883fbc730e3a0b",

Diff for: docs/snippets/tracer/captureHTTP.ts

+1-2
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,10 @@
11
import { Tracer } from '@aws-lambda-powertools/tracer';
2-
import axios from 'axios'; // (1)
32

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

65
export const handler = async (
76
_event: unknown,
87
_context: unknown
98
): Promise<void> => {
10-
await axios.get('https://httpbin.org/status/200');
9+
await fetch('https://httpbin.org/status/200');
1110
};

Diff for: packages/tracer/src/Tracer.ts

+2-1
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ import type {
1616
CaptureMethodOptions,
1717
} from './types/Tracer.js';
1818
import { ProviderService } from './provider/ProviderService.js';
19-
import type { ProviderServiceInterface } from './types/ProviderServiceInterface.js';
19+
import type { ProviderServiceInterface } from './types/ProviderService.js';
2020
import type { Segment, Subsegment } from 'aws-xray-sdk-core';
2121
import xraySdk from 'aws-xray-sdk-core';
2222
const { Subsegment: XraySubsegment } = xraySdk;
@@ -153,6 +153,7 @@ class Tracer extends Utility implements TracerInterface {
153153
this.provider = new ProviderService();
154154
if (this.isTracingEnabled() && this.captureHTTPsRequests) {
155155
this.provider.captureHTTPsGlobal();
156+
this.provider.instrumentFetch();
156157
}
157158
if (!this.isTracingEnabled()) {
158159
// Tell x-ray-sdk to not throw an error if context is missing but tracing is disabled

Diff for: packages/tracer/src/provider/ProviderService.ts

+123-2
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
1-
import { Namespace } from 'cls-hooked';
1+
import type { Namespace } from 'cls-hooked';
22
import type {
33
ProviderServiceInterface,
44
ContextMissingStrategy,
5-
} from '../types/ProviderServiceInterface.js';
5+
HttpSubsegment,
6+
} from '../types/ProviderService.js';
67
import type { Segment, Subsegment, Logger } from 'aws-xray-sdk-core';
78
import xraySdk from 'aws-xray-sdk-core';
89
const {
@@ -21,6 +22,13 @@ const {
2122
setLogger,
2223
} = xraySdk;
2324
import { addUserAgentMiddleware } from '@aws-lambda-powertools/commons';
25+
import { subscribe } from 'node:diagnostics_channel';
26+
import {
27+
findHeaderAndDecode,
28+
getOriginURL,
29+
isHttpSubsegment,
30+
} from './utilities.js';
31+
import type { DiagnosticsChannel } from 'undici-types';
2432

2533
class ProviderService implements ProviderServiceInterface {
2634
public captureAWS<T>(awssdk: T): T {
@@ -70,6 +78,119 @@ class ProviderService implements ProviderServiceInterface {
7078
return getSegment();
7179
}
7280

81+
/**
82+
* Instrument `fetch` requests with AWS X-Ray
83+
*
84+
* The instrumentation is done by subscribing to the `undici` events. When a request is created,
85+
* a new subsegment is created with the hostname of the request.
86+
*
87+
* Then, when the headers are received, the subsegment is updated with the request and response details.
88+
*
89+
* Finally, when the request is completed, the subsegment is closed.
90+
*
91+
* @see {@link https://nodejs.org/api/diagnostics_channel.html#diagnostics_channel_channel_publish | Diagnostics Channel - Node.js Documentation}
92+
*/
93+
public instrumentFetch(): void {
94+
/**
95+
* Create a segment at the start of a request made with `undici` or `fetch`.
96+
*
97+
* @note that `message` must be `unknown` because that's the type expected by `subscribe`
98+
*
99+
* @param message The message received from the `undici` channel
100+
*/
101+
const onRequestStart = (message: unknown): void => {
102+
const { request } = message as DiagnosticsChannel.RequestCreateMessage;
103+
104+
const parentSubsegment = this.getSegment();
105+
if (parentSubsegment && request.origin) {
106+
const origin = getOriginURL(request.origin);
107+
const method = request.method;
108+
109+
const subsegment = parentSubsegment.addNewSubsegment(origin.hostname);
110+
subsegment.addAttribute('namespace', 'remote');
111+
112+
(subsegment as HttpSubsegment).http = {
113+
request: {
114+
url: origin.hostname,
115+
method,
116+
},
117+
};
118+
119+
this.setSegment(subsegment);
120+
}
121+
};
122+
123+
/**
124+
* Enrich the subsegment with the response details, and close it.
125+
* Then, set the parent segment as the active segment.
126+
*
127+
* @note that `message` must be `unknown` because that's the type expected by `subscribe`
128+
*
129+
* @param message The message received from the `undici` channel
130+
*/
131+
const onResponse = (message: unknown): void => {
132+
const { response } = message as DiagnosticsChannel.RequestHeadersMessage;
133+
134+
const subsegment = this.getSegment();
135+
if (isHttpSubsegment(subsegment)) {
136+
const status = response.statusCode;
137+
const contentLenght = findHeaderAndDecode(
138+
response.headers,
139+
'content-length'
140+
);
141+
142+
subsegment.http = {
143+
...subsegment.http,
144+
response: {
145+
status,
146+
...(contentLenght && {
147+
content_length: parseInt(contentLenght),
148+
}),
149+
},
150+
};
151+
152+
if (status === 429) {
153+
subsegment.addThrottleFlag();
154+
}
155+
if (status >= 400 && status < 500) {
156+
subsegment.addErrorFlag();
157+
} else if (status >= 500 && status < 600) {
158+
subsegment.addFaultFlag();
159+
}
160+
161+
subsegment.close();
162+
this.setSegment(subsegment.parent);
163+
}
164+
};
165+
166+
/**
167+
* Add an error to the subsegment when the request fails.
168+
*
169+
* This is used to handle the case when the request fails to establish a connection with the server or timeouts.
170+
* In all other cases, for example, when the server returns a 4xx or 5xx status code, the error is added in the `onResponse` function.
171+
*
172+
* @note that `message` must be `unknown` because that's the type expected by `subscribe`
173+
*
174+
* @param message The message received from the `undici` channel
175+
*/
176+
const onError = (message: unknown): void => {
177+
const { error } = message as DiagnosticsChannel.RequestErrorMessage;
178+
179+
const subsegment = this.getSegment();
180+
if (isHttpSubsegment(subsegment)) {
181+
subsegment.addErrorFlag();
182+
error instanceof Error && subsegment.addError(error, true);
183+
184+
subsegment.close();
185+
this.setSegment(subsegment.parent);
186+
}
187+
};
188+
189+
subscribe('undici:request:create', onRequestStart);
190+
subscribe('undici:request:headers', onResponse);
191+
subscribe('undici:request:error', onError);
192+
}
193+
73194
public putAnnotation(key: string, value: string | number | boolean): void {
74195
const segment = this.getSegment();
75196
if (segment === undefined) {

Diff for: packages/tracer/src/provider/utilities.ts

+63
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import type { HttpSubsegment } from '../types/ProviderService.js';
2+
import type { Segment, Subsegment } from 'aws-xray-sdk-core';
3+
import { URL } from 'node:url';
4+
5+
const decoder = new TextDecoder();
6+
7+
/**
8+
* The `fetch` implementation based on `undici` includes the headers as an array of encoded key-value pairs.
9+
* This function finds the header with the given key and decodes the value.
10+
*
11+
* The function walks through the array of encoded headers and decodes the key of each pair.
12+
* If the key matches the given key, the function returns the decoded value of the next element in the array.
13+
*
14+
* @param encodedHeaders The array of encoded headers
15+
* @param key The key to search for
16+
*/
17+
const findHeaderAndDecode = (
18+
encodedHeaders: Uint8Array[],
19+
key: string
20+
): string | null => {
21+
let foundIndex = -1;
22+
for (let i = 0; i < encodedHeaders.length; i += 2) {
23+
const header = decoder.decode(encodedHeaders[i]);
24+
if (header.toLowerCase() === key) {
25+
foundIndex = i;
26+
break;
27+
}
28+
}
29+
30+
if (foundIndex === -1) {
31+
return null;
32+
}
33+
34+
return decoder.decode(encodedHeaders[foundIndex + 1]);
35+
};
36+
37+
/**
38+
* Type guard to check if the given subsegment is an `HttpSubsegment`
39+
*
40+
* @param subsegment The subsegment to check
41+
*/
42+
const isHttpSubsegment = (
43+
subsegment: Segment | Subsegment | undefined
44+
): subsegment is HttpSubsegment => {
45+
return (
46+
subsegment !== undefined &&
47+
'http' in subsegment &&
48+
'parent' in subsegment &&
49+
'namespace' in subsegment &&
50+
subsegment.namespace === 'remote'
51+
);
52+
};
53+
54+
/**
55+
* Convert the origin url to a URL object when it is a string
56+
*
57+
* @param origin The origin url
58+
*/
59+
const getOriginURL = (origin: string | URL): URL => {
60+
return origin instanceof URL ? origin : new URL(origin);
61+
};
62+
63+
export { findHeaderAndDecode, isHttpSubsegment, getOriginURL };

Diff for: packages/tracer/src/types/ProviderServiceInterface.ts renamed to packages/tracer/src/types/ProviderService.ts

+27-1
Original file line numberDiff line numberDiff line change
@@ -40,9 +40,35 @@ interface ProviderServiceInterface {
4040

4141
captureHTTPsGlobal(): void;
4242

43+
/**
44+
* Instrument `fetch` requests with AWS X-Ray
45+
*/
46+
instrumentFetch(): void;
47+
4348
putAnnotation(key: string, value: string | number | boolean): void;
4449

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

48-
export type { ProviderServiceInterface, ContextMissingStrategy };
53+
/**
54+
* Subsegment that contains information for a request made to a remote service
55+
*/
56+
interface HttpSubsegment extends Subsegment {
57+
namespace: 'remote';
58+
http: {
59+
request?: {
60+
url: string;
61+
method?: string;
62+
};
63+
response?: {
64+
status: number;
65+
content_length?: number;
66+
};
67+
};
68+
}
69+
70+
export type {
71+
ProviderServiceInterface,
72+
ContextMissingStrategy,
73+
HttpSubsegment,
74+
};

Diff for: packages/tracer/src/types/Tracer.ts

+3
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,9 @@ import type { Segment, Subsegment } from 'aws-xray-sdk-core';
2222
type TracerOptions = {
2323
enabled?: boolean;
2424
serviceName?: string;
25+
/**
26+
* Whether to trace outgoing HTTP requests made with the `http`, `https`, or `fetch` modules
27+
*/
2528
captureHTTPsRequests?: boolean;
2629
customConfigService?: ConfigServiceInterface;
2730
};

Diff for: packages/tracer/tests/e2e/asyncHandler.decorator.test.functionCode.ts

+7-4
Original file line numberDiff line numberDiff line change
@@ -55,10 +55,13 @@ export class MyFunctionBase {
5555
Item: { id: `${serviceName}-${event.invocation}-sdkv3` },
5656
})
5757
);
58-
await axios.get(
59-
'https://docs.powertools.aws.dev/lambda/typescript/latest/',
60-
{ timeout: 5000 }
61-
);
58+
const url = 'https://docs.powertools.aws.dev/lambda/typescript/latest/';
59+
// Add conditional behavior because fetch is not available in Node.js 16 - this can be removed once we drop support for Node.js 16
60+
if (process.version.startsWith('v16')) {
61+
await axios.get(url, { timeout: 5000 });
62+
} else {
63+
await fetch(url);
64+
}
6265

6366
const res = this.myMethod();
6467
if (event.throw) {

0 commit comments

Comments
 (0)