From df6dd1c827f796810117e6eead7549f5122ef6ad Mon Sep 17 00:00:00 2001 From: Andrea Amorosi Date: Thu, 28 Mar 2024 13:57:13 +0100 Subject: [PATCH 01/11] feat(tracer): capture fetch requests --- .../tracer/src/provider/ProviderService.ts | 136 +++++++++++ .../tracer/tests/unit/ProviderService.test.ts | 231 ++++++++++++++++++ 2 files changed, 367 insertions(+) diff --git a/packages/tracer/src/provider/ProviderService.ts b/packages/tracer/src/provider/ProviderService.ts index 2194a8b8d9..6a2c0a3e18 100644 --- a/packages/tracer/src/provider/ProviderService.ts +++ b/packages/tracer/src/provider/ProviderService.ts @@ -21,6 +21,60 @@ const { setLogger, } = xraySdk; import { addUserAgentMiddleware } from '@aws-lambda-powertools/commons'; +import { subscribe } from 'node:diagnostics_channel'; + +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]); +}; + +export interface HttpSubsegment extends Subsegment { + http: { + request?: { + url: string; + method: string; + }; + response?: { + status: number; + content_length?: number; + }; + }; +} + +const isHttpSubsegment = ( + subsegment: Segment | Subsegment | undefined +): subsegment is HttpSubsegment => { + return ( + subsegment !== undefined && 'http' in subsegment && 'parent' in subsegment + ); +}; class ProviderService implements ProviderServiceInterface { public captureAWS(awssdk: T): T { @@ -62,6 +116,88 @@ class ProviderService implements ProviderServiceInterface { captureHTTPsGlobal(require('https')); } + /** + * Instrument `fetch` requests with AWS X-Ray + */ + public captureNativeFetch(): void { + const onRequestStart = (message: unknown): void => { + const { request } = message as { + request: { + origin: string; + }; + }; + + const parentSubsegment = this.getSegment(); + if (parentSubsegment) { + const origin = new URL(request.origin); + const subsegment = parentSubsegment.addNewSubsegment(origin.hostname); + subsegment.addAttribute('namespace', 'remote'); + (subsegment as HttpSubsegment).http = {}; + + this.setSegment(subsegment); + } + }; + + const onResponse = (message: unknown): void => { + const { request, response } = message as { + request: { + origin: string; + method: string; + }; + response: { + statusCode: number; + headers: Uint8Array[]; + }; + }; + + const subsegment = this.getSegment(); + if (isHttpSubsegment(subsegment)) { + const origin = new URL(request.origin); + const method = request.method; + + const status = response.statusCode; + const contentLenght = findHeaderAndDecode( + response.headers, + 'content-length' + ); + + subsegment.http = { + request: { + url: origin.hostname, + method, + }, + 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(); + } + } + }; + + const onRequestEnd = (): void => { + const subsegment = this.getSegment(); + if (isHttpSubsegment(subsegment)) { + subsegment.close(); + this.setSegment(subsegment.parent); + } + }; + + subscribe('undici:request:create', onRequestStart); + subscribe('undici:request:headers', onResponse); + subscribe('undici:request:trailers', onRequestEnd); + } + public getNamespace(): Namespace { return getNamespace(); } diff --git a/packages/tracer/tests/unit/ProviderService.test.ts b/packages/tracer/tests/unit/ProviderService.test.ts index f7fe81ab62..84ff7f2327 100644 --- a/packages/tracer/tests/unit/ProviderService.test.ts +++ b/packages/tracer/tests/unit/ProviderService.test.ts @@ -24,6 +24,8 @@ import http from 'node:http'; import https from 'node:https'; import { DynamoDBClient } from '@aws-sdk/client-dynamodb'; import { addUserAgentMiddleware } from '@aws-lambda-powertools/commons'; +import { channel } from 'node:diagnostics_channel'; +import type { HttpSubsegment } from '../../src/provider/ProviderService.js'; jest.mock('aws-xray-sdk-core', () => ({ ...jest.requireActual('aws-xray-sdk-core'), @@ -358,4 +360,233 @@ describe('Class: ProviderService', () => { expect(segmentSpy).toHaveBeenCalledWith('foo', 'bar', 'baz'); }); }); + + describe('Method: captureNativeFetch', () => { + it('subscribes to the diagnostics channel', async () => { + // Prepare + const provider: ProviderService = new ProviderService(); + + // Act + provider.captureNativeFetch(); + + // Assess + expect(channel('undici:request:create').hasSubscribers).toBe(true); + expect(channel('undici:request:headers').hasSubscribers).toBe(true); + expect(channel('undici:request:trailers').hasSubscribers).toBe(true); + }); + + const mockFetch = ({ + origin, + method, + statusCode, + headers, + }: { + origin: string; + method?: string; + statusCode?: number; + headers?: { [key: string]: string }; + }): void => { + const requestStart = channel('undici:request:create'); + const response = channel('undici:request:headers'); + const requestEnd = channel('undici:request:trailers'); + + requestStart.publish({ + request: { + origin, + }, + }); + + const encoder = new TextEncoder(); + const encodedHeaders = []; + for (const [key, value] of Object.entries(headers ?? {})) { + encodedHeaders.push(encoder.encode(key)); + encodedHeaders.push(encoder.encode(value)); + } + response.publish({ + request: { + origin, + method: method ?? 'GET', + }, + response: { + statusCode: statusCode ?? 200, + headers: encodedHeaders, + }, + }); + requestEnd.publish({}); + }; + + it('traces a successful request', async () => { + // Prepare + const provider: ProviderService = new ProviderService(); + const segment = new Subsegment('## dummySegment'); + const subsegment = segment.addNewSubsegment('httpbin.org'); + jest + .spyOn(segment, 'addNewSubsegment') + .mockImplementationOnce(() => subsegment); + jest + .spyOn(provider, 'getSegment') + .mockImplementationOnce(() => segment) + .mockImplementationOnce(() => subsegment) + .mockImplementationOnce(() => subsegment); + jest.spyOn(subsegment, 'close'); + + // Act + provider.captureNativeFetch(); + mockFetch({ + origin: 'http://httpbin.org/get', + headers: { + 'content-length': '100', + }, + }); + + // Assess + expect(segment.addNewSubsegment).toHaveBeenCalledTimes(1); + expect(segment.addNewSubsegment).toHaveBeenCalledWith('httpbin.org'); + expect((subsegment as HttpSubsegment).http).toEqual({ + request: { + url: 'httpbin.org', + method: 'GET', + }, + response: { + status: 200, + content_length: 100, + }, + }); + expect(subsegment.close).toHaveBeenCalledTimes(1); + }); + + it('excludes the content_length header when invalid or not found', async () => { + // Prepare + const provider: ProviderService = new ProviderService(); + const segment = new Subsegment('## dummySegment'); + const subsegment = segment.addNewSubsegment('httpbin.org'); + jest + .spyOn(segment, 'addNewSubsegment') + .mockImplementationOnce(() => subsegment); + jest + .spyOn(provider, 'getSegment') + .mockImplementationOnce(() => segment) + .mockImplementationOnce(() => subsegment) + .mockImplementationOnce(() => subsegment); + + // Act + provider.captureNativeFetch(); + mockFetch({ + origin: 'http://httpbin.org/get', + headers: { + 'content-type': 'application/json', + }, + }); + + // Assess + expect((subsegment as HttpSubsegment).http).toEqual({ + request: { + url: 'httpbin.org', + method: 'GET', + }, + response: { + status: 200, + }, + }); + }); + + it('adds a throttle flag to the segment when the status code is 429', async () => { + // Prepare + const provider: ProviderService = new ProviderService(); + const segment = new Subsegment('## dummySegment'); + const subsegment = segment.addNewSubsegment('httpbin.org'); + jest.spyOn(subsegment, 'addThrottleFlag'); + jest + .spyOn(segment, 'addNewSubsegment') + .mockImplementationOnce(() => subsegment); + jest + .spyOn(provider, 'getSegment') + .mockImplementationOnce(() => segment) + .mockImplementationOnce(() => subsegment) + .mockImplementationOnce(() => subsegment); + + // Act + provider.captureNativeFetch(); + mockFetch({ + origin: 'http://httpbin.org/get', + statusCode: 429, + }); + + // Assess + expect((subsegment as HttpSubsegment).http).toEqual( + expect.objectContaining({ + response: { + status: 429, + }, + }) + ); + expect(subsegment.addThrottleFlag).toHaveBeenCalledTimes(1); + }); + + it('adds an error flag to the segment when the status code is 4xx', async () => { + // Prepare + const provider: ProviderService = new ProviderService(); + const segment = new Subsegment('## dummySegment'); + const subsegment = segment.addNewSubsegment('httpbin.org'); + jest.spyOn(subsegment, 'addErrorFlag'); + jest + .spyOn(segment, 'addNewSubsegment') + .mockImplementationOnce(() => subsegment); + jest + .spyOn(provider, 'getSegment') + .mockImplementationOnce(() => segment) + .mockImplementationOnce(() => subsegment) + .mockImplementationOnce(() => subsegment); + + // Act + provider.captureNativeFetch(); + mockFetch({ + origin: 'http://httpbin.org/get', + statusCode: 404, + }); + + // Assess + expect((subsegment as HttpSubsegment).http).toEqual( + expect.objectContaining({ + response: { + status: 404, + }, + }) + ); + expect(subsegment.addErrorFlag).toHaveBeenCalledTimes(1); + }); + + it('adds a fault flag to the segment when the status code is 5xx', async () => { + // Prepare + const provider: ProviderService = new ProviderService(); + const segment = new Subsegment('## dummySegment'); + const subsegment = segment.addNewSubsegment('httpbin.org'); + jest.spyOn(subsegment, 'addFaultFlag'); + jest + .spyOn(segment, 'addNewSubsegment') + .mockImplementationOnce(() => subsegment); + jest + .spyOn(provider, 'getSegment') + .mockImplementationOnce(() => segment) + .mockImplementationOnce(() => subsegment) + .mockImplementationOnce(() => subsegment); + + // Act + provider.captureNativeFetch(); + mockFetch({ + origin: 'http://httpbin.org/get', + statusCode: 500, + }); + + // Assess + expect((subsegment as HttpSubsegment).http).toEqual( + expect.objectContaining({ + response: { + status: 500, + }, + }) + ); + expect(subsegment.addFaultFlag).toHaveBeenCalledTimes(1); + }); + }); }); From aa1acdcb4668257b0fe58f40ec907f549b715a3e Mon Sep 17 00:00:00 2001 From: Andrea Amorosi Date: Thu, 28 Mar 2024 16:17:09 +0100 Subject: [PATCH 02/11] chore: clean up code --- packages/tracer/src/Tracer.ts | 3 +- .../tracer/src/provider/ProviderService.ts | 122 ++++++------------ packages/tracer/src/provider/utilities.ts | 53 ++++++++ ...ServiceInterface.ts => ProviderService.ts} | 50 ++++++- packages/tracer/tests/helpers/mockRequests.ts | 52 ++++++++ .../tracer/tests/unit/ProviderService.test.ts | 65 ++-------- packages/tracer/tests/unit/Tracer.test.ts | 2 +- 7 files changed, 213 insertions(+), 134 deletions(-) create mode 100644 packages/tracer/src/provider/utilities.ts rename packages/tracer/src/types/{ProviderServiceInterface.ts => ProviderService.ts} (53%) create mode 100644 packages/tracer/tests/helpers/mockRequests.ts diff --git a/packages/tracer/src/Tracer.ts b/packages/tracer/src/Tracer.ts index 4b6f40f59a..7be6674705 100644 --- a/packages/tracer/src/Tracer.ts +++ b/packages/tracer/src/Tracer.ts @@ -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; @@ -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 diff --git a/packages/tracer/src/provider/ProviderService.ts b/packages/tracer/src/provider/ProviderService.ts index 6a2c0a3e18..934ca77ca1 100644 --- a/packages/tracer/src/provider/ProviderService.ts +++ b/packages/tracer/src/provider/ProviderService.ts @@ -1,8 +1,11 @@ -import { Namespace } from 'cls-hooked'; +import type { Namespace } from 'cls-hooked'; import type { ProviderServiceInterface, ContextMissingStrategy, -} from '../types/ProviderServiceInterface.js'; + HttpSubsegment, + MessageOnRequestStart, + MessageOnResponse, +} from '../types/ProviderService.js'; import type { Segment, Subsegment, Logger } from 'aws-xray-sdk-core'; import xraySdk from 'aws-xray-sdk-core'; const { @@ -22,59 +25,7 @@ const { } = xraySdk; import { addUserAgentMiddleware } from '@aws-lambda-powertools/commons'; import { subscribe } from 'node:diagnostics_channel'; - -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]); -}; - -export interface HttpSubsegment extends Subsegment { - http: { - request?: { - url: string; - method: string; - }; - response?: { - status: number; - content_length?: number; - }; - }; -} - -const isHttpSubsegment = ( - subsegment: Segment | Subsegment | undefined -): subsegment is HttpSubsegment => { - return ( - subsegment !== undefined && 'http' in subsegment && 'parent' in subsegment - ); -}; +import { findHeaderAndDecode, isHttpSubsegment } from './utilities.js'; class ProviderService implements ProviderServiceInterface { public captureAWS(awssdk: T): T { @@ -116,16 +67,36 @@ class ProviderService implements ProviderServiceInterface { captureHTTPsGlobal(require('https')); } + public getNamespace(): Namespace { + return getNamespace(); + } + + public getSegment(): Segment | Subsegment | undefined { + 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 captureNativeFetch(): void { + 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 { - request: { - origin: string; - }; - }; + const { request } = message as MessageOnRequestStart; const parentSubsegment = this.getSegment(); if (parentSubsegment) { @@ -138,17 +109,15 @@ class ProviderService implements ProviderServiceInterface { } }; + /** + * Enrich the subsegment with the request and response details. + * + * @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 { request, response } = message as { - request: { - origin: string; - method: string; - }; - response: { - statusCode: number; - headers: Uint8Array[]; - }; - }; + const { request, response } = message as MessageOnResponse; const subsegment = this.getSegment(); if (isHttpSubsegment(subsegment)) { @@ -185,6 +154,9 @@ class ProviderService implements ProviderServiceInterface { } }; + /** + * Close the subsegment at the end of the request. + */ const onRequestEnd = (): void => { const subsegment = this.getSegment(); if (isHttpSubsegment(subsegment)) { @@ -198,14 +170,6 @@ class ProviderService implements ProviderServiceInterface { subscribe('undici:request:trailers', onRequestEnd); } - public getNamespace(): Namespace { - return getNamespace(); - } - - public getSegment(): Segment | Subsegment | undefined { - return getSegment(); - } - public putAnnotation(key: string, value: string | number | boolean): void { const segment = this.getSegment(); if (segment === undefined) { diff --git a/packages/tracer/src/provider/utilities.ts b/packages/tracer/src/provider/utilities.ts new file mode 100644 index 0000000000..db47494f31 --- /dev/null +++ b/packages/tracer/src/provider/utilities.ts @@ -0,0 +1,53 @@ +import type { HttpSubsegment } from '../types/ProviderService.js'; +import type { Segment, Subsegment } from 'aws-xray-sdk-core'; + +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' + ); +}; + +export { findHeaderAndDecode, isHttpSubsegment }; diff --git a/packages/tracer/src/types/ProviderServiceInterface.ts b/packages/tracer/src/types/ProviderService.ts similarity index 53% rename from packages/tracer/src/types/ProviderServiceInterface.ts rename to packages/tracer/src/types/ProviderService.ts index 5a704fc705..ebedf3af8f 100644 --- a/packages/tracer/src/types/ProviderServiceInterface.ts +++ b/packages/tracer/src/types/ProviderService.ts @@ -40,9 +40,57 @@ 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; + }; + }; +} + +/** + * Partial shape of the message sent to the `undici:request:create` diagnostics channel + */ +type MessageOnRequestStart = { + request: { + origin: string; + method: string; + }; +}; + +/** + * Partial shape of the message sent to the `undici:request:headers` diagnostics channel + */ +type MessageOnResponse = MessageOnRequestStart & { + response: { + statusCode: number; + headers: Uint8Array[]; + }; +}; + +export type { + ProviderServiceInterface, + ContextMissingStrategy, + HttpSubsegment, + MessageOnRequestStart, + MessageOnResponse, +}; diff --git a/packages/tracer/tests/helpers/mockRequests.ts b/packages/tracer/tests/helpers/mockRequests.ts new file mode 100644 index 0000000000..54b3e7c39e --- /dev/null +++ b/packages/tracer/tests/helpers/mockRequests.ts @@ -0,0 +1,52 @@ +import { channel } from 'node:diagnostics_channel'; + +type MockFetchOptions = { + origin: string; + method?: string; + statusCode?: number; + headers?: { [key: string]: string }; +}; + +/** + * Simulates a fetch request by publishing messages to the undici channel + * + * @see {@link https://nodejs.org/api/diagnostics_channel.html#diagnostics_channel_channel_publish | Diagnostics Channel - Node.js Documentation} + * + * @param options The options for the mock fetch + */ +const mockFetch = ({ + origin, + method, + statusCode, + headers, +}: MockFetchOptions): void => { + const requestStart = channel('undici:request:create'); + const response = channel('undici:request:headers'); + const requestEnd = channel('undici:request:trailers'); + + requestStart.publish({ + request: { + origin, + }, + }); + + const encoder = new TextEncoder(); + const encodedHeaders = []; + for (const [key, value] of Object.entries(headers ?? {})) { + encodedHeaders.push(encoder.encode(key)); + encodedHeaders.push(encoder.encode(value)); + } + response.publish({ + request: { + origin, + method: method ?? 'GET', + }, + response: { + statusCode: statusCode ?? 200, + headers: encodedHeaders, + }, + }); + requestEnd.publish({}); +}; + +export { mockFetch }; diff --git a/packages/tracer/tests/unit/ProviderService.test.ts b/packages/tracer/tests/unit/ProviderService.test.ts index 84ff7f2327..aa87367044 100644 --- a/packages/tracer/tests/unit/ProviderService.test.ts +++ b/packages/tracer/tests/unit/ProviderService.test.ts @@ -3,7 +3,8 @@ * * @group unit/tracer/providerservice */ -import { ProviderService } from '../../src/provider/ProviderService.js'; +import { addUserAgentMiddleware } from '@aws-lambda-powertools/commons'; +import { DynamoDBClient } from '@aws-sdk/client-dynamodb'; import { captureAsyncFunc, captureAWS, @@ -20,12 +21,12 @@ import { setSegment, Subsegment, } from 'aws-xray-sdk-core'; +import { channel } from 'node:diagnostics_channel'; import http from 'node:http'; import https from 'node:https'; -import { DynamoDBClient } from '@aws-sdk/client-dynamodb'; -import { addUserAgentMiddleware } from '@aws-lambda-powertools/commons'; -import { channel } from 'node:diagnostics_channel'; -import type { HttpSubsegment } from '../../src/provider/ProviderService.js'; +import { ProviderService } from '../../src/provider/ProviderService.js'; +import type { HttpSubsegment } from '../../src/types/ProviderService.js'; +import { mockFetch } from '../helpers/mockRequests.js'; jest.mock('aws-xray-sdk-core', () => ({ ...jest.requireActual('aws-xray-sdk-core'), @@ -361,13 +362,13 @@ describe('Class: ProviderService', () => { }); }); - describe('Method: captureNativeFetch', () => { + describe('Method: instrumentFetch', () => { it('subscribes to the diagnostics channel', async () => { // Prepare const provider: ProviderService = new ProviderService(); // Act - provider.captureNativeFetch(); + provider.instrumentFetch(); // Assess expect(channel('undici:request:create').hasSubscribers).toBe(true); @@ -375,46 +376,6 @@ describe('Class: ProviderService', () => { expect(channel('undici:request:trailers').hasSubscribers).toBe(true); }); - const mockFetch = ({ - origin, - method, - statusCode, - headers, - }: { - origin: string; - method?: string; - statusCode?: number; - headers?: { [key: string]: string }; - }): void => { - const requestStart = channel('undici:request:create'); - const response = channel('undici:request:headers'); - const requestEnd = channel('undici:request:trailers'); - - requestStart.publish({ - request: { - origin, - }, - }); - - const encoder = new TextEncoder(); - const encodedHeaders = []; - for (const [key, value] of Object.entries(headers ?? {})) { - encodedHeaders.push(encoder.encode(key)); - encodedHeaders.push(encoder.encode(value)); - } - response.publish({ - request: { - origin, - method: method ?? 'GET', - }, - response: { - statusCode: statusCode ?? 200, - headers: encodedHeaders, - }, - }); - requestEnd.publish({}); - }; - it('traces a successful request', async () => { // Prepare const provider: ProviderService = new ProviderService(); @@ -431,7 +392,7 @@ describe('Class: ProviderService', () => { jest.spyOn(subsegment, 'close'); // Act - provider.captureNativeFetch(); + provider.instrumentFetch(); mockFetch({ origin: 'http://httpbin.org/get', headers: { @@ -470,7 +431,7 @@ describe('Class: ProviderService', () => { .mockImplementationOnce(() => subsegment); // Act - provider.captureNativeFetch(); + provider.instrumentFetch(); mockFetch({ origin: 'http://httpbin.org/get', headers: { @@ -506,7 +467,7 @@ describe('Class: ProviderService', () => { .mockImplementationOnce(() => subsegment); // Act - provider.captureNativeFetch(); + provider.instrumentFetch(); mockFetch({ origin: 'http://httpbin.org/get', statusCode: 429, @@ -539,7 +500,7 @@ describe('Class: ProviderService', () => { .mockImplementationOnce(() => subsegment); // Act - provider.captureNativeFetch(); + provider.instrumentFetch(); mockFetch({ origin: 'http://httpbin.org/get', statusCode: 404, @@ -572,7 +533,7 @@ describe('Class: ProviderService', () => { .mockImplementationOnce(() => subsegment); // Act - provider.captureNativeFetch(); + provider.instrumentFetch(); mockFetch({ origin: 'http://httpbin.org/get', statusCode: 500, diff --git a/packages/tracer/tests/unit/Tracer.test.ts b/packages/tracer/tests/unit/Tracer.test.ts index 04747e1f9f..f293717c58 100644 --- a/packages/tracer/tests/unit/Tracer.test.ts +++ b/packages/tracer/tests/unit/Tracer.test.ts @@ -12,7 +12,7 @@ import { setContextMissingStrategy, Subsegment, } from 'aws-xray-sdk-core'; -import type { ProviderServiceInterface } from '../../src/types/ProviderServiceInterface.js'; +import type { ProviderServiceInterface } from '../../src/types/ProviderService.js'; import type { ConfigServiceInterface } from '../../src/types/ConfigServiceInterface.js'; type CaptureAsyncFuncMock = jest.SpyInstance< From eadbd6033fa5805c68404e1cac75375e5ee5e0d1 Mon Sep 17 00:00:00 2001 From: Andrea Amorosi Date: Thu, 28 Mar 2024 16:36:18 +0100 Subject: [PATCH 03/11] chore: swap dummy url in tests --- .../tracer/tests/unit/ProviderService.test.ts | 26 +++++++++---------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/packages/tracer/tests/unit/ProviderService.test.ts b/packages/tracer/tests/unit/ProviderService.test.ts index aa87367044..e379a98a5b 100644 --- a/packages/tracer/tests/unit/ProviderService.test.ts +++ b/packages/tracer/tests/unit/ProviderService.test.ts @@ -380,7 +380,7 @@ describe('Class: ProviderService', () => { // Prepare const provider: ProviderService = new ProviderService(); const segment = new Subsegment('## dummySegment'); - const subsegment = segment.addNewSubsegment('httpbin.org'); + const subsegment = segment.addNewSubsegment('aws.amazon.com'); jest .spyOn(segment, 'addNewSubsegment') .mockImplementationOnce(() => subsegment); @@ -394,7 +394,7 @@ describe('Class: ProviderService', () => { // Act provider.instrumentFetch(); mockFetch({ - origin: 'http://httpbin.org/get', + origin: 'https://aws.amazon.com/blogs', headers: { 'content-length': '100', }, @@ -402,10 +402,10 @@ describe('Class: ProviderService', () => { // Assess expect(segment.addNewSubsegment).toHaveBeenCalledTimes(1); - expect(segment.addNewSubsegment).toHaveBeenCalledWith('httpbin.org'); + expect(segment.addNewSubsegment).toHaveBeenCalledWith('aws.amazon.com'); expect((subsegment as HttpSubsegment).http).toEqual({ request: { - url: 'httpbin.org', + url: 'aws.amazon.com', method: 'GET', }, response: { @@ -420,7 +420,7 @@ describe('Class: ProviderService', () => { // Prepare const provider: ProviderService = new ProviderService(); const segment = new Subsegment('## dummySegment'); - const subsegment = segment.addNewSubsegment('httpbin.org'); + const subsegment = segment.addNewSubsegment('aws.amazon.com'); jest .spyOn(segment, 'addNewSubsegment') .mockImplementationOnce(() => subsegment); @@ -433,7 +433,7 @@ describe('Class: ProviderService', () => { // Act provider.instrumentFetch(); mockFetch({ - origin: 'http://httpbin.org/get', + origin: 'https://aws.amazon.com/blogs', headers: { 'content-type': 'application/json', }, @@ -442,7 +442,7 @@ describe('Class: ProviderService', () => { // Assess expect((subsegment as HttpSubsegment).http).toEqual({ request: { - url: 'httpbin.org', + url: 'aws.amazon.com', method: 'GET', }, response: { @@ -455,7 +455,7 @@ describe('Class: ProviderService', () => { // Prepare const provider: ProviderService = new ProviderService(); const segment = new Subsegment('## dummySegment'); - const subsegment = segment.addNewSubsegment('httpbin.org'); + const subsegment = segment.addNewSubsegment('aws.amazon.com'); jest.spyOn(subsegment, 'addThrottleFlag'); jest .spyOn(segment, 'addNewSubsegment') @@ -469,7 +469,7 @@ describe('Class: ProviderService', () => { // Act provider.instrumentFetch(); mockFetch({ - origin: 'http://httpbin.org/get', + origin: 'https://aws.amazon.com/blogs', statusCode: 429, }); @@ -488,7 +488,7 @@ describe('Class: ProviderService', () => { // Prepare const provider: ProviderService = new ProviderService(); const segment = new Subsegment('## dummySegment'); - const subsegment = segment.addNewSubsegment('httpbin.org'); + const subsegment = segment.addNewSubsegment('aws.amazon.com'); jest.spyOn(subsegment, 'addErrorFlag'); jest .spyOn(segment, 'addNewSubsegment') @@ -502,7 +502,7 @@ describe('Class: ProviderService', () => { // Act provider.instrumentFetch(); mockFetch({ - origin: 'http://httpbin.org/get', + origin: 'https://aws.amazon.com/blogs', statusCode: 404, }); @@ -521,7 +521,7 @@ describe('Class: ProviderService', () => { // Prepare const provider: ProviderService = new ProviderService(); const segment = new Subsegment('## dummySegment'); - const subsegment = segment.addNewSubsegment('httpbin.org'); + const subsegment = segment.addNewSubsegment('aws.amazon.com'); jest.spyOn(subsegment, 'addFaultFlag'); jest .spyOn(segment, 'addNewSubsegment') @@ -535,7 +535,7 @@ describe('Class: ProviderService', () => { // Act provider.instrumentFetch(); mockFetch({ - origin: 'http://httpbin.org/get', + origin: 'https://aws.amazon.com/blogs', statusCode: 500, }); From 21e09c0a9c3b2133e6d06f6f04e9610a41aba952 Mon Sep 17 00:00:00 2001 From: Andrea Amorosi Date: Tue, 2 Apr 2024 08:40:42 +0200 Subject: [PATCH 04/11] chore: use undici-types --- .../tracer/src/provider/ProviderService.ts | 22 ++++++++++------- packages/tracer/src/provider/utilities.ts | 12 +++++++++- packages/tracer/src/types/ProviderService.ts | 24 +------------------ packages/tracer/tests/helpers/mockRequests.ts | 3 ++- .../tracer/tests/unit/ProviderService.test.ts | 3 ++- 5 files changed, 29 insertions(+), 35 deletions(-) diff --git a/packages/tracer/src/provider/ProviderService.ts b/packages/tracer/src/provider/ProviderService.ts index 934ca77ca1..883bcd244c 100644 --- a/packages/tracer/src/provider/ProviderService.ts +++ b/packages/tracer/src/provider/ProviderService.ts @@ -3,8 +3,6 @@ import type { ProviderServiceInterface, ContextMissingStrategy, HttpSubsegment, - MessageOnRequestStart, - MessageOnResponse, } from '../types/ProviderService.js'; import type { Segment, Subsegment, Logger } from 'aws-xray-sdk-core'; import xraySdk from 'aws-xray-sdk-core'; @@ -25,7 +23,12 @@ const { } = xraySdk; import { addUserAgentMiddleware } from '@aws-lambda-powertools/commons'; import { subscribe } from 'node:diagnostics_channel'; -import { findHeaderAndDecode, isHttpSubsegment } from './utilities.js'; +import { + findHeaderAndDecode, + getOriginURL, + isHttpSubsegment, +} from './utilities.js'; +import type { DiagnosticsChannel } from 'undici-types'; class ProviderService implements ProviderServiceInterface { public captureAWS(awssdk: T): T { @@ -96,11 +99,11 @@ class ProviderService implements ProviderServiceInterface { * @param message The message received from the `undici` channel */ const onRequestStart = (message: unknown): void => { - const { request } = message as MessageOnRequestStart; + const { request } = message as DiagnosticsChannel.RequestCreateMessage; const parentSubsegment = this.getSegment(); - if (parentSubsegment) { - const origin = new URL(request.origin); + if (parentSubsegment && request.origin) { + const origin = getOriginURL(request.origin); const subsegment = parentSubsegment.addNewSubsegment(origin.hostname); subsegment.addAttribute('namespace', 'remote'); (subsegment as HttpSubsegment).http = {}; @@ -117,11 +120,12 @@ class ProviderService implements ProviderServiceInterface { * @param message The message received from the `undici` channel */ const onResponse = (message: unknown): void => { - const { request, response } = message as MessageOnResponse; + const { request, response } = + message as DiagnosticsChannel.RequestHeadersMessage; const subsegment = this.getSegment(); - if (isHttpSubsegment(subsegment)) { - const origin = new URL(request.origin); + if (isHttpSubsegment(subsegment) && request.origin) { + const origin = getOriginURL(request.origin); const method = request.method; const status = response.statusCode; diff --git a/packages/tracer/src/provider/utilities.ts b/packages/tracer/src/provider/utilities.ts index db47494f31..9e1780eb48 100644 --- a/packages/tracer/src/provider/utilities.ts +++ b/packages/tracer/src/provider/utilities.ts @@ -1,5 +1,6 @@ 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(); @@ -50,4 +51,13 @@ const isHttpSubsegment = ( ); }; -export { findHeaderAndDecode, isHttpSubsegment }; +/** + * 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 }; diff --git a/packages/tracer/src/types/ProviderService.ts b/packages/tracer/src/types/ProviderService.ts index ebedf3af8f..edd6fa909a 100644 --- a/packages/tracer/src/types/ProviderService.ts +++ b/packages/tracer/src/types/ProviderService.ts @@ -58,7 +58,7 @@ interface HttpSubsegment extends Subsegment { http: { request?: { url: string; - method: string; + method?: string; }; response?: { status: number; @@ -67,30 +67,8 @@ interface HttpSubsegment extends Subsegment { }; } -/** - * Partial shape of the message sent to the `undici:request:create` diagnostics channel - */ -type MessageOnRequestStart = { - request: { - origin: string; - method: string; - }; -}; - -/** - * Partial shape of the message sent to the `undici:request:headers` diagnostics channel - */ -type MessageOnResponse = MessageOnRequestStart & { - response: { - statusCode: number; - headers: Uint8Array[]; - }; -}; - export type { ProviderServiceInterface, ContextMissingStrategy, HttpSubsegment, - MessageOnRequestStart, - MessageOnResponse, }; diff --git a/packages/tracer/tests/helpers/mockRequests.ts b/packages/tracer/tests/helpers/mockRequests.ts index 54b3e7c39e..4edec93c2d 100644 --- a/packages/tracer/tests/helpers/mockRequests.ts +++ b/packages/tracer/tests/helpers/mockRequests.ts @@ -1,7 +1,8 @@ import { channel } from 'node:diagnostics_channel'; +import type { URL } from 'node:url'; type MockFetchOptions = { - origin: string; + origin: string | URL; method?: string; statusCode?: number; headers?: { [key: string]: string }; diff --git a/packages/tracer/tests/unit/ProviderService.test.ts b/packages/tracer/tests/unit/ProviderService.test.ts index e379a98a5b..d16bc1accd 100644 --- a/packages/tracer/tests/unit/ProviderService.test.ts +++ b/packages/tracer/tests/unit/ProviderService.test.ts @@ -27,6 +27,7 @@ import https from 'node:https'; import { ProviderService } from '../../src/provider/ProviderService.js'; import type { HttpSubsegment } from '../../src/types/ProviderService.js'; import { mockFetch } from '../helpers/mockRequests.js'; +import { URL } from 'node:url'; jest.mock('aws-xray-sdk-core', () => ({ ...jest.requireActual('aws-xray-sdk-core'), @@ -433,7 +434,7 @@ describe('Class: ProviderService', () => { // Act provider.instrumentFetch(); mockFetch({ - origin: 'https://aws.amazon.com/blogs', + origin: new URL('https://aws.amazon.com/blogs'), headers: { 'content-type': 'application/json', }, From 040056b9294b12d3043743872ff3b06aef581ca1 Mon Sep 17 00:00:00 2001 From: Andrea Amorosi Date: Tue, 2 Apr 2024 12:59:30 +0200 Subject: [PATCH 05/11] chore: integration tests --- packages/tracer/src/provider/ProviderService.ts | 12 ++---------- .../e2e/asyncHandler.decorator.test.functionCode.ts | 6 +----- 2 files changed, 3 insertions(+), 15 deletions(-) diff --git a/packages/tracer/src/provider/ProviderService.ts b/packages/tracer/src/provider/ProviderService.ts index 883bcd244c..e86dfccc3e 100644 --- a/packages/tracer/src/provider/ProviderService.ts +++ b/packages/tracer/src/provider/ProviderService.ts @@ -113,7 +113,8 @@ class ProviderService implements ProviderServiceInterface { }; /** - * Enrich the subsegment with the request and response details. + * Enrich the subsegment with the request and 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` * @@ -155,15 +156,7 @@ class ProviderService implements ProviderServiceInterface { } else if (status >= 500 && status < 600) { subsegment.addFaultFlag(); } - } - }; - /** - * Close the subsegment at the end of the request. - */ - const onRequestEnd = (): void => { - const subsegment = this.getSegment(); - if (isHttpSubsegment(subsegment)) { subsegment.close(); this.setSegment(subsegment.parent); } @@ -171,7 +164,6 @@ class ProviderService implements ProviderServiceInterface { subscribe('undici:request:create', onRequestStart); subscribe('undici:request:headers', onResponse); - subscribe('undici:request:trailers', onRequestEnd); } public putAnnotation(key: string, value: string | number | boolean): void { diff --git a/packages/tracer/tests/e2e/asyncHandler.decorator.test.functionCode.ts b/packages/tracer/tests/e2e/asyncHandler.decorator.test.functionCode.ts index 6d60a7b53e..b810c3b67a 100644 --- a/packages/tracer/tests/e2e/asyncHandler.decorator.test.functionCode.ts +++ b/packages/tracer/tests/e2e/asyncHandler.decorator.test.functionCode.ts @@ -2,7 +2,6 @@ import { Tracer } from '../../src/index.js'; import type { Context } from 'aws-lambda'; import { DynamoDBClient } from '@aws-sdk/client-dynamodb'; import { DynamoDBDocumentClient, PutCommand } from '@aws-sdk/lib-dynamodb'; -import axios from 'axios'; const serviceName = process.env.EXPECTED_SERVICE_NAME ?? 'MyFunctionWithStandardHandler'; @@ -55,10 +54,7 @@ export class MyFunctionBase { Item: { id: `${serviceName}-${event.invocation}-sdkv3` }, }) ); - await axios.get( - 'https://docs.powertools.aws.dev/lambda/typescript/latest/', - { timeout: 5000 } - ); + await fetch('https://docs.powertools.aws.dev/lambda/typescript/latest/'); const res = this.myMethod(); if (event.throw) { From 729b95b0a0050a7e7f7065d2be5e72e222885ff1 Mon Sep 17 00:00:00 2001 From: Andrea Amorosi Date: Tue, 2 Apr 2024 13:01:55 +0200 Subject: [PATCH 06/11] tests: update tests --- packages/tracer/tests/unit/ProviderService.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/tracer/tests/unit/ProviderService.test.ts b/packages/tracer/tests/unit/ProviderService.test.ts index d16bc1accd..b104a199e3 100644 --- a/packages/tracer/tests/unit/ProviderService.test.ts +++ b/packages/tracer/tests/unit/ProviderService.test.ts @@ -374,7 +374,6 @@ describe('Class: ProviderService', () => { // Assess expect(channel('undici:request:create').hasSubscribers).toBe(true); expect(channel('undici:request:headers').hasSubscribers).toBe(true); - expect(channel('undici:request:trailers').hasSubscribers).toBe(true); }); it('traces a successful request', async () => { From cb4badeb99ffc10b3d98351e91ab2e048798b8a5 Mon Sep 17 00:00:00 2001 From: Andrea Amorosi Date: Tue, 2 Apr 2024 13:49:06 +0200 Subject: [PATCH 07/11] docs: update docs to mention fetch --- docs/core/tracer.md | 9 +++------ docs/snippets/tracer/captureHTTP.ts | 3 +-- packages/tracer/src/types/Tracer.ts | 3 +++ 3 files changed, 7 insertions(+), 8 deletions(-) diff --git a/docs/core/tracer.md b/docs/core/tracer.md index c6af66a67a..f3f4f5cc00 100644 --- a/docs/core/tracer.md +++ b/docs/core/tracer.md @@ -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. @@ -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" @@ -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", diff --git a/docs/snippets/tracer/captureHTTP.ts b/docs/snippets/tracer/captureHTTP.ts index 506fbb0778..5c12693839 100644 --- a/docs/snippets/tracer/captureHTTP.ts +++ b/docs/snippets/tracer/captureHTTP.ts @@ -1,5 +1,4 @@ import { Tracer } from '@aws-lambda-powertools/tracer'; -import axios from 'axios'; // (1) new Tracer({ serviceName: 'serverlessAirline' }); @@ -7,5 +6,5 @@ export const handler = async ( _event: unknown, _context: unknown ): Promise => { - await axios.get('https://httpbin.org/status/200'); + await fetch('https://httpbin.org/status/200'); }; diff --git a/packages/tracer/src/types/Tracer.ts b/packages/tracer/src/types/Tracer.ts index ac2e399707..c7b4100431 100644 --- a/packages/tracer/src/types/Tracer.ts +++ b/packages/tracer/src/types/Tracer.ts @@ -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; }; From d8a28ba730692ad7939c687d561988e0acdf6b51 Mon Sep 17 00:00:00 2001 From: Andrea Amorosi Date: Tue, 2 Apr 2024 14:24:30 +0200 Subject: [PATCH 08/11] tests: update integration tests for fetch --- .../e2e/asyncHandler.decorator.test.functionCode.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/packages/tracer/tests/e2e/asyncHandler.decorator.test.functionCode.ts b/packages/tracer/tests/e2e/asyncHandler.decorator.test.functionCode.ts index b810c3b67a..05668a7b9f 100644 --- a/packages/tracer/tests/e2e/asyncHandler.decorator.test.functionCode.ts +++ b/packages/tracer/tests/e2e/asyncHandler.decorator.test.functionCode.ts @@ -2,6 +2,7 @@ import { Tracer } from '../../src/index.js'; import type { Context } from 'aws-lambda'; import { DynamoDBClient } from '@aws-sdk/client-dynamodb'; import { DynamoDBDocumentClient, PutCommand } from '@aws-sdk/lib-dynamodb'; +import axios from 'axios'; const serviceName = process.env.EXPECTED_SERVICE_NAME ?? 'MyFunctionWithStandardHandler'; @@ -54,7 +55,13 @@ export class MyFunctionBase { Item: { id: `${serviceName}-${event.invocation}-sdkv3` }, }) ); - await fetch('https://docs.powertools.aws.dev/lambda/typescript/latest/'); + 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) { From acf6161d860bbe3f889a0752f1445d84d06aa474 Mon Sep 17 00:00:00 2001 From: Andrea Amorosi Date: Wed, 3 Apr 2024 13:26:10 +0200 Subject: [PATCH 09/11] improv: handle failed connection case --- .../tracer/src/provider/ProviderService.ts | 49 +++++++++++---- packages/tracer/tests/helpers/mockRequests.ts | 49 ++++++++++----- .../tracer/tests/unit/ProviderService.test.ts | 51 ++++++++++++++++ packages/tracer/tests/unit/random.test.ts | 60 +++++++++++++++++++ 4 files changed, 182 insertions(+), 27 deletions(-) create mode 100644 packages/tracer/tests/unit/random.test.ts diff --git a/packages/tracer/src/provider/ProviderService.ts b/packages/tracer/src/provider/ProviderService.ts index e86dfccc3e..dd5de88c26 100644 --- a/packages/tracer/src/provider/ProviderService.ts +++ b/packages/tracer/src/provider/ProviderService.ts @@ -104,16 +104,24 @@ class ProviderService implements ProviderServiceInterface { 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 = {}; + + (subsegment as HttpSubsegment).http = { + request: { + url: origin.hostname, + method, + }, + }; this.setSegment(subsegment); } }; /** - * Enrich the subsegment with the request and response details, and close it. + * 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` @@ -121,14 +129,10 @@ class ProviderService implements ProviderServiceInterface { * @param message The message received from the `undici` channel */ const onResponse = (message: unknown): void => { - const { request, response } = - message as DiagnosticsChannel.RequestHeadersMessage; + const { response } = message as DiagnosticsChannel.RequestHeadersMessage; const subsegment = this.getSegment(); - if (isHttpSubsegment(subsegment) && request.origin) { - const origin = getOriginURL(request.origin); - const method = request.method; - + if (isHttpSubsegment(subsegment)) { const status = response.statusCode; const contentLenght = findHeaderAndDecode( response.headers, @@ -136,10 +140,7 @@ class ProviderService implements ProviderServiceInterface { ); subsegment.http = { - request: { - url: origin.hostname, - method, - }, + ...subsegment.http, response: { status, ...(contentLenght && { @@ -162,8 +163,32 @@ class ProviderService implements ProviderServiceInterface { } }; + /** + * 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 { diff --git a/packages/tracer/tests/helpers/mockRequests.ts b/packages/tracer/tests/helpers/mockRequests.ts index 4edec93c2d..8c5865e8d3 100644 --- a/packages/tracer/tests/helpers/mockRequests.ts +++ b/packages/tracer/tests/helpers/mockRequests.ts @@ -4,9 +4,17 @@ import type { URL } from 'node:url'; type MockFetchOptions = { origin: string | URL; method?: string; - statusCode?: number; headers?: { [key: string]: string }; -}; +} & ( + | { + statusCode?: never; + throwError?: boolean; + } + | { + statusCode: number; + throwError?: never; + } +); /** * Simulates a fetch request by publishing messages to the undici channel @@ -20,34 +28,45 @@ const mockFetch = ({ method, statusCode, headers, + throwError, }: MockFetchOptions): void => { - const requestStart = channel('undici:request:create'); - const response = channel('undici:request:headers'); - const requestEnd = channel('undici:request:trailers'); + const requestCreateChannel = channel('undici:request:create'); + const responseHeadersChannel = channel('undici:request:headers'); + const errorChannel = channel('undici:request:error'); - requestStart.publish({ - request: { - origin, - }, + const request = { + origin, + method: method ?? 'GET', + }; + + requestCreateChannel.publish({ + request, }); + if (throwError) { + const error = new AggregateError('Mock fetch error'); + + errorChannel.publish({ + request, + error, + }); + + throw error; + } + const encoder = new TextEncoder(); const encodedHeaders = []; for (const [key, value] of Object.entries(headers ?? {})) { encodedHeaders.push(encoder.encode(key)); encodedHeaders.push(encoder.encode(value)); } - response.publish({ - request: { - origin, - method: method ?? 'GET', - }, + responseHeadersChannel.publish({ + request, response: { statusCode: statusCode ?? 200, headers: encodedHeaders, }, }); - requestEnd.publish({}); }; export { mockFetch }; diff --git a/packages/tracer/tests/unit/ProviderService.test.ts b/packages/tracer/tests/unit/ProviderService.test.ts index b104a199e3..61a0a7c383 100644 --- a/packages/tracer/tests/unit/ProviderService.test.ts +++ b/packages/tracer/tests/unit/ProviderService.test.ts @@ -374,6 +374,7 @@ describe('Class: ProviderService', () => { // Assess expect(channel('undici:request:create').hasSubscribers).toBe(true); expect(channel('undici:request:headers').hasSubscribers).toBe(true); + expect(channel('undici:request:error').hasSubscribers).toBe(true); }); it('traces a successful request', async () => { @@ -390,6 +391,7 @@ describe('Class: ProviderService', () => { .mockImplementationOnce(() => subsegment) .mockImplementationOnce(() => subsegment); jest.spyOn(subsegment, 'close'); + jest.spyOn(provider, 'setSegment'); // Act provider.instrumentFetch(); @@ -414,6 +416,7 @@ describe('Class: ProviderService', () => { }, }); expect(subsegment.close).toHaveBeenCalledTimes(1); + expect(provider.setSegment).toHaveBeenLastCalledWith(segment); }); it('excludes the content_length header when invalid or not found', async () => { @@ -429,6 +432,8 @@ describe('Class: ProviderService', () => { .mockImplementationOnce(() => segment) .mockImplementationOnce(() => subsegment) .mockImplementationOnce(() => subsegment); + jest.spyOn(subsegment, 'close'); + jest.spyOn(provider, 'setSegment'); // Act provider.instrumentFetch(); @@ -449,6 +454,8 @@ describe('Class: ProviderService', () => { status: 200, }, }); + expect(subsegment.close).toHaveBeenCalledTimes(1); + expect(provider.setSegment).toHaveBeenLastCalledWith(segment); }); it('adds a throttle flag to the segment when the status code is 429', async () => { @@ -465,6 +472,8 @@ describe('Class: ProviderService', () => { .mockImplementationOnce(() => segment) .mockImplementationOnce(() => subsegment) .mockImplementationOnce(() => subsegment); + jest.spyOn(subsegment, 'close'); + jest.spyOn(provider, 'setSegment'); // Act provider.instrumentFetch(); @@ -482,6 +491,8 @@ describe('Class: ProviderService', () => { }) ); expect(subsegment.addThrottleFlag).toHaveBeenCalledTimes(1); + expect(subsegment.close).toHaveBeenCalledTimes(1); + expect(provider.setSegment).toHaveBeenLastCalledWith(segment); }); it('adds an error flag to the segment when the status code is 4xx', async () => { @@ -498,6 +509,8 @@ describe('Class: ProviderService', () => { .mockImplementationOnce(() => segment) .mockImplementationOnce(() => subsegment) .mockImplementationOnce(() => subsegment); + jest.spyOn(subsegment, 'close'); + jest.spyOn(provider, 'setSegment'); // Act provider.instrumentFetch(); @@ -515,6 +528,8 @@ describe('Class: ProviderService', () => { }) ); expect(subsegment.addErrorFlag).toHaveBeenCalledTimes(1); + expect(subsegment.close).toHaveBeenCalledTimes(1); + expect(provider.setSegment).toHaveBeenLastCalledWith(segment); }); it('adds a fault flag to the segment when the status code is 5xx', async () => { @@ -531,6 +546,8 @@ describe('Class: ProviderService', () => { .mockImplementationOnce(() => segment) .mockImplementationOnce(() => subsegment) .mockImplementationOnce(() => subsegment); + jest.spyOn(subsegment, 'close'); + jest.spyOn(provider, 'setSegment'); // Act provider.instrumentFetch(); @@ -548,6 +565,40 @@ describe('Class: ProviderService', () => { }) ); expect(subsegment.addFaultFlag).toHaveBeenCalledTimes(1); + expect(subsegment.close).toHaveBeenCalledTimes(1); + expect(provider.setSegment).toHaveBeenLastCalledWith(segment); }); }); + + it('closes the segment and adds a fault flag when the connection fails', async () => { + // Prepare + const provider: ProviderService = new ProviderService(); + const segment = new Subsegment('## dummySegment'); + const subsegment = segment.addNewSubsegment('aws.amazon.com'); + jest.spyOn(subsegment, 'addError'); + jest + .spyOn(segment, 'addNewSubsegment') + .mockImplementationOnce(() => subsegment); + jest + .spyOn(provider, 'getSegment') + .mockImplementationOnce(() => segment) + .mockImplementationOnce(() => subsegment) + .mockImplementationOnce(() => subsegment); + jest.spyOn(subsegment, 'close'); + jest.spyOn(provider, 'setSegment'); + + // Act + provider.instrumentFetch(); + try { + mockFetch({ + origin: 'https://aws.amazon.com/blogs', + throwError: true, + }); + } catch {} + + // Assess + expect(subsegment.addError).toHaveBeenCalledTimes(1); + expect(subsegment.close).toHaveBeenCalledTimes(1); + expect(provider.setSegment).toHaveBeenLastCalledWith(segment); + }); }); diff --git a/packages/tracer/tests/unit/random.test.ts b/packages/tracer/tests/unit/random.test.ts new file mode 100644 index 0000000000..d23ab0a60b --- /dev/null +++ b/packages/tracer/tests/unit/random.test.ts @@ -0,0 +1,60 @@ +import Namespace from 'cls-hooked'; +import { Segment } from 'aws-xray-sdk-core'; +import type { Context } from 'aws-lambda'; +import type { LambdaInterface } from '@aws-lambda-powertools/commons/types'; +import { Tracer } from '../../src/Tracer'; + +describe('Stuff', () => { + let ns: Namespace.Namespace; + + beforeEach(() => { + ns = Namespace.createNamespace('AWSXRay'); + ns.enter(ns.createContext()); + const facade = new Segment('facade'); + ns.set('segment', facade); + }); + + it('should do stuff', async () => { + const tracer = new Tracer(); + + class Lambda implements LambdaInterface { + private readonly memberVariable: string; + + public constructor(memberVariable: string) { + this.memberVariable = memberVariable; + } + + @tracer.captureLambdaHandler() + public async handler( + _event: unknown, + _context: Context + ): Promise { + try { + await fetch( + // 'https://docs.powertools.aws.dev/lambda/typescript/latest/' + 'http://localhost:3000' + ); + } catch {} + + return `memberVariable:${this.memberVariable}`; + } + } + + // Act / Assess + const lambda = new Lambda('someValue'); + const handler = lambda.handler.bind(lambda); + + const res = await handler({}, {} as Context); + expect(res).toBe('memberVariable:someValue'); + + const segment = ns.get('segment'); + // make assertions on the segment + expect(segment.name).toBe('facade'); + expect(segment.subsegments.length).toBe(1); + expect(segment.subsegments[0].name).toBe('## index.handler'); + expect(segment.subsegments[0].end_time).toBeDefined(); + expect(segment.subsegments[0].subsegments.length).toBe(1); + expect(segment.subsegments[0].subsegments[0].name).toBe('localhost'); + expect(segment.subsegments[0].subsegments[0].end_time).toBeDefined(); + }); +}); From 17a6e2059df981360ed6fe28af63ff76f9354f2f Mon Sep 17 00:00:00 2001 From: Andrea Amorosi Date: Fri, 5 Apr 2024 10:52:03 +0200 Subject: [PATCH 10/11] chore: removed leftover file --- packages/tracer/tests/unit/random.test.ts | 60 ----------------------- 1 file changed, 60 deletions(-) delete mode 100644 packages/tracer/tests/unit/random.test.ts diff --git a/packages/tracer/tests/unit/random.test.ts b/packages/tracer/tests/unit/random.test.ts deleted file mode 100644 index d23ab0a60b..0000000000 --- a/packages/tracer/tests/unit/random.test.ts +++ /dev/null @@ -1,60 +0,0 @@ -import Namespace from 'cls-hooked'; -import { Segment } from 'aws-xray-sdk-core'; -import type { Context } from 'aws-lambda'; -import type { LambdaInterface } from '@aws-lambda-powertools/commons/types'; -import { Tracer } from '../../src/Tracer'; - -describe('Stuff', () => { - let ns: Namespace.Namespace; - - beforeEach(() => { - ns = Namespace.createNamespace('AWSXRay'); - ns.enter(ns.createContext()); - const facade = new Segment('facade'); - ns.set('segment', facade); - }); - - it('should do stuff', async () => { - const tracer = new Tracer(); - - class Lambda implements LambdaInterface { - private readonly memberVariable: string; - - public constructor(memberVariable: string) { - this.memberVariable = memberVariable; - } - - @tracer.captureLambdaHandler() - public async handler( - _event: unknown, - _context: Context - ): Promise { - try { - await fetch( - // 'https://docs.powertools.aws.dev/lambda/typescript/latest/' - 'http://localhost:3000' - ); - } catch {} - - return `memberVariable:${this.memberVariable}`; - } - } - - // Act / Assess - const lambda = new Lambda('someValue'); - const handler = lambda.handler.bind(lambda); - - const res = await handler({}, {} as Context); - expect(res).toBe('memberVariable:someValue'); - - const segment = ns.get('segment'); - // make assertions on the segment - expect(segment.name).toBe('facade'); - expect(segment.subsegments.length).toBe(1); - expect(segment.subsegments[0].name).toBe('## index.handler'); - expect(segment.subsegments[0].end_time).toBeDefined(); - expect(segment.subsegments[0].subsegments.length).toBe(1); - expect(segment.subsegments[0].subsegments[0].name).toBe('localhost'); - expect(segment.subsegments[0].subsegments[0].end_time).toBeDefined(); - }); -}); From 69c029039aa40957a3b7219358b14c680e96a8a3 Mon Sep 17 00:00:00 2001 From: Andrea Amorosi Date: Fri, 5 Apr 2024 14:25:57 +0200 Subject: [PATCH 11/11] Trigger Build