diff --git a/package.json b/package.json index 4550491..30e0e26 100644 --- a/package.json +++ b/package.json @@ -58,6 +58,7 @@ "@aws-sdk/signature-v4": "^3.110.0", "@aws-sdk/url-parser": "^3.357.0", "@types/aws-lambda": "^8.10.101", + "aws-xray-sdk-core": "^3.12.0", "axios": "^1.6.0", "lodash": "^4.17.21", "nearley": "2", @@ -66,5 +67,8 @@ }, "publishConfig": { "access": "public" + }, + "resolutions": { + "@aws-sdk/types": "3.357.0" } } diff --git a/src/adapters/helpers/tracing.ts b/src/adapters/helpers/tracing.ts new file mode 100644 index 0000000..2ed5dd7 --- /dev/null +++ b/src/adapters/helpers/tracing.ts @@ -0,0 +1,58 @@ +import { Lambda } from '@aws-sdk/client-lambda'; +import { captureAWSv3Client, setContextMissingStrategy } from 'aws-xray-sdk-core'; + +/** + * Whether X-Ray tracing is active in the current runtime. + * + * AWS injects `_X_AMZN_TRACE_ID` into any Lambda execution environment that has + * active tracing, so its presence is a reliable signal that there is a live + * segment to attach to. Consumers can also force-enable instrumentation via + * `ALPHA_XRAY_TRACING=true` (e.g. for a non-Lambda runtime that manages its own + * segments). + * + * Outside of these cases (local dev, tests, untraced runtimes) we deliberately + * leave alpha's Lambda client untouched: alpha is a published library used + * everywhere, and wrapping an untraced client would emit context-missing errors + * on every invoke. The default behavior for an untraced caller must be + * unchanged. + */ +const isXRayTracingActive = (): boolean => + Boolean(process.env._X_AMZN_TRACE_ID) || process.env.ALPHA_XRAY_TRACING === 'true'; + +// Only soften the context-missing strategy once, and never more than once. +let contextMissingConfigured = false; + +/** + * Wrap alpha's Lambda client with X-Ray so that each `lambda://` invoke emits a + * subsegment naming the downstream Lambda (the target service), with trace + * header propagation. This lights up internal service -> service edges in the + * X-Ray service graph. + * + * `captureAWSv3Client` adds middleware to and returns the *same* client + * instance, so `lambda.destroy()` and the `config.Lambda` injection escape + * hatch keep working transparently against the returned client. + * + * Safety for untraced consumers is the priority here: + * - We only wrap when X-Ray is actually active (see `isXRayTracingActive`). + * - If `AWS_XRAY_CONTEXT_MISSING` is unset, we set the strategy to `LOG_ERROR` + * so a missing segment logs rather than throws. Consumers that need a + * different strategy should set that env var in the runtime environment. + * - Any failure while instrumenting falls back to the original, unwrapped + * client so tracing can never break a real invoke. + */ +export const captureLambdaClient = (lambda: Lambda): Lambda => { + if (!isXRayTracingActive()) { + return lambda; + } + + try { + if (!contextMissingConfigured && !process.env.AWS_XRAY_CONTEXT_MISSING) { + setContextMissingStrategy('LOG_ERROR'); + contextMissingConfigured = true; + } + return captureAWSv3Client(lambda); + } catch { + // Never let tracing instrumentation break a real invoke. + return lambda; + } +}; diff --git a/src/adapters/lambda-invocation.ts b/src/adapters/lambda-invocation.ts index f7903c9..5d8ede7 100644 --- a/src/adapters/lambda-invocation.ts +++ b/src/adapters/lambda-invocation.ts @@ -6,6 +6,7 @@ import { lambdaEvent } from './helpers/lambdaEvent'; import { lambdaResponse, Payload } from './helpers/lambdaResponse'; import { parseLambdaUrl, isAbsoluteURL } from '../utils/url'; import { RequestError } from './helpers/requestError'; +import { captureLambdaClient } from './helpers/tracing'; import { InternalAlphaRequestConfig, AlphaAdapter } from '../types'; import { Alpha } from '../alpha'; import { AbortController } from '@aws-sdk/abort-controller'; @@ -32,7 +33,11 @@ const lambdaInvocationAdapter: AlphaAdapter = async (config) => { }); } - const lambda = new LambdaClass(lambdaOptions); + // Wrap the client with X-Ray (safely, no-op when untraced) so that every + // `lambda://` invoke emits a subsegment naming the downstream service. This + // also covers the `config.Lambda` injection escape hatch, so all internal + // service -> service traffic is traced through this single chokepoint. + const lambda = captureLambdaClient(new LambdaClass(lambdaOptions)); if (config.baseURL && !isAbsoluteURL(config.url as string)) { config.url = `${config.baseURL}${config.url}`; } diff --git a/test/lambda-invocation.test.ts b/test/lambda-invocation.test.ts index dfec611..13c2f6c 100644 --- a/test/lambda-invocation.test.ts +++ b/test/lambda-invocation.test.ts @@ -12,6 +12,16 @@ import { lambdaResponse, Payload, } from '../src/adapters/helpers/lambdaResponse'; +import { captureAWSv3Client } from 'aws-xray-sdk-core'; + +jest.mock('aws-xray-sdk-core', () => ({ + captureAWSv3Client: jest.fn((client) => client), + setContextMissingStrategy: jest.fn(), +})); + +const mockCaptureAWSv3Client = captureAWSv3Client as jest.MockedFunction< + typeof captureAWSv3Client +>; const mockLambda = mockClient(Lambda); const FakeLambda = jest.fn() as jest.MockedClass; @@ -608,3 +618,129 @@ test('lambdaRegion config option is provided to the Lambda client', async () => expect(FakeLambda).toHaveBeenCalledWith({ region: 'ap-southeast-2' }); }); + +describe('X-Ray tracing', () => { + const originalTraceId = process.env._X_AMZN_TRACE_ID; + const originalOptIn = process.env.ALPHA_XRAY_TRACING; + + beforeEach(() => { + // `resetMocks: true` wipes implementations between tests, so (re)install + // the pass-through behavior captureAWSv3Client has by default. + mockCaptureAWSv3Client.mockImplementation((client) => client); + delete process.env._X_AMZN_TRACE_ID; + delete process.env.ALPHA_XRAY_TRACING; + }); + + afterAll(() => { + if (originalTraceId === undefined) { + delete process.env._X_AMZN_TRACE_ID; + } else { + process.env._X_AMZN_TRACE_ID = originalTraceId; + } + if (originalOptIn === undefined) { + delete process.env.ALPHA_XRAY_TRACING; + } else { + process.env.ALPHA_XRAY_TRACING = originalOptIn; + } + }); + + test('a lambda:// invoke wraps the Lambda client when ALPHA_XRAY_TRACING is enabled', async () => { + process.env.ALPHA_XRAY_TRACING = 'true'; + createResponse(mockLambda, { + StatusCode: 200, + Payload: { + body: 'hello!', + headers: { 'test-header': 'some value' }, + statusCode: 200, + }, + }); + + const response = await ctx.alpha.get('/some/path'); + + expect(response.data).toBe('hello!'); + expect(response.status).toBe(200); + expect(mockCaptureAWSv3Client).toHaveBeenCalledTimes(1); + expect(mockCaptureAWSv3Client).toHaveBeenCalledWith(expect.any(Lambda)); + }); + + test('a lambda:// invoke wraps the Lambda client for tracing when X-Ray is active', async () => { + process.env._X_AMZN_TRACE_ID = 'Root=1-5e1b4151-5ac6c58f5b3e6f6f00000000'; + createResponse(mockLambda, { + StatusCode: 200, + Payload: { + body: 'hello!', + headers: { 'test-header': 'some value' }, + statusCode: 200, + }, + }); + + const response = await ctx.alpha.get('/some/path'); + + // The downstream invoke still returns the expected payload... + expect(response.data).toBe('hello!'); + expect(response.status).toBe(200); + + // ...and the Lambda client used for the invoke was wrapped by X-Ray. + expect(mockCaptureAWSv3Client).toHaveBeenCalledTimes(1); + expect(mockCaptureAWSv3Client).toHaveBeenCalledWith(expect.any(Lambda)); + }); + + test('an injected config.Lambda client is still wrapped for tracing', async () => { + process.env._X_AMZN_TRACE_ID = 'Root=1-5e1b4151-5ac6c58f5b3e6f6f00000000'; + createResponse(mockLambda, { + StatusCode: 200, + Payload: { + body: 'test', + statusCode: 200, + }, + }); + + const response = await ctx.alpha.get('/test', { Lambda: FakeLambda }); + + expect(response.data).toBe('test'); + expect(FakeLambda).toHaveBeenCalledTimes(1); + // The instance produced by the injected class is what gets traced. + expect(mockCaptureAWSv3Client).toHaveBeenCalledTimes(1); + expect(mockCaptureAWSv3Client).toHaveBeenCalledWith(expect.any(Lambda)); + }); + + test('the untraced path is safe: the client is not wrapped and the invoke succeeds', async () => { + // No _X_AMZN_TRACE_ID and no opt-in => tracing must be a no-op. + createResponse(mockLambda, { + StatusCode: 200, + Payload: { + body: 'hello!', + headers: { 'test-header': 'some value' }, + statusCode: 200, + }, + }); + + const response = await ctx.alpha.get('/some/path'); + + expect(response.data).toBe('hello!'); + expect(response.status).toBe(200); + expect(mockCaptureAWSv3Client).not.toHaveBeenCalled(); + }); + + test('instrumentation failures never break the invoke (falls back to the unwrapped client)', async () => { + process.env._X_AMZN_TRACE_ID = 'Root=1-5e1b4151-5ac6c58f5b3e6f6f00000000'; + mockCaptureAWSv3Client.mockImplementation(() => { + throw new Error('boom'); + }); + createResponse(mockLambda, { + StatusCode: 200, + Payload: { + body: 'hello!', + statusCode: 200, + }, + }); + + const response = await ctx.alpha.get('/some/path'); + + // The wrap was attempted but threw; the original client still serviced the + // invoke, so consumers are never broken by tracing. + expect(mockCaptureAWSv3Client).toHaveBeenCalledTimes(1); + expect(response.data).toBe('hello!'); + expect(response.status).toBe(200); + }); +}); diff --git a/yarn.lock b/yarn.lock index fba3c88..7483313 100644 --- a/yarn.lock +++ b/yarn.lock @@ -600,12 +600,7 @@ "@aws-sdk/types" "3.110.0" tslib "^2.3.1" -"@aws-sdk/types@3.110.0", "@aws-sdk/types@^3.1.0": - version "3.110.0" - resolved "https://registry.yarnpkg.com/@aws-sdk/types/-/types-3.110.0.tgz#09404533b507925eadf9acf9c4356667048e45bd" - integrity sha512-dLVoqODU3laaqNFPyN1QLtlQnwX4gNPMXptEBIt/iJpuZf66IYJe6WCzVZGt4Zfa1CnUmrlA428AzdcA/KCr2A== - -"@aws-sdk/types@3.357.0": +"@aws-sdk/types@3.110.0", "@aws-sdk/types@3.357.0", "@aws-sdk/types@^3.1.0", "@aws-sdk/types@^3.4.1": version "3.357.0" resolved "https://registry.yarnpkg.com/@aws-sdk/types/-/types-3.357.0.tgz#8491da71a4291cc2661c26a75089e86532b6a3b5" integrity sha512-/riCRaXg3p71BeWnShrai0y0QTdXcouPSM0Cn1olZbzTf7s71aLEewrc96qFrL70XhY4XvnxMpqQh+r43XIL3g== @@ -1939,6 +1934,20 @@ resolved "https://registry.yarnpkg.com/@sinonjs/text-encoding/-/text-encoding-0.7.1.tgz#8da5c6530915653f3a1f38fd5f101d8c3f8079c5" integrity sha512-+iTbntw2IZPb/anVDbypzfQa+ay64MW0Zo8aJ8gZPWMMK6/OubMVb6lUPMagqjOPnmtauXnFCACVl3O7ogjeqQ== +"@smithy/service-error-classification@^2.0.4": + version "2.1.5" + resolved "https://registry.yarnpkg.com/@smithy/service-error-classification/-/service-error-classification-2.1.5.tgz#0568a977cc0db36299d8703a5d8609c1f600c005" + integrity sha512-uBDTIBBEdAQryvHdc5W8sS5YX7RQzF683XrHePVdFmAgKiMofU15FLSM0/HU03hKTnazdNRFa0YHS7+ArwoUSQ== + dependencies: + "@smithy/types" "^2.12.0" + +"@smithy/types@^2.12.0": + version "2.12.0" + resolved "https://registry.yarnpkg.com/@smithy/types/-/types-2.12.0.tgz#c44845f8ba07e5e8c88eda5aed7e6a0c462da041" + integrity sha512-QwYgloJ0sVNBeBuBs65cIkTbfzV/Q6ZNPCJ99EICFEdJYG50nGIY/uYXp+TbsdJReIuPr0a0kXmCvren3MbRRw== + dependencies: + tslib "^2.6.2" + "@swc/core-android-arm-eabi@1.2.207": version "1.2.207" resolved "https://registry.yarnpkg.com/@swc/core-android-arm-eabi/-/core-android-arm-eabi-1.2.207.tgz#c70746aaba3f5bd6064dd9e8442a0edf477960e9" @@ -2093,6 +2102,13 @@ dependencies: "@babel/types" "^7.3.0" +"@types/cls-hooked@^4.3.3": + version "4.3.9" + resolved "https://registry.yarnpkg.com/@types/cls-hooked/-/cls-hooked-4.3.9.tgz#2cff2f7ca961d53213ef626f96afa986a66d3fad" + integrity sha512-CMtHMz6Q/dkfcHarq9nioXH8BDPP+v5xvd+N90lBQ2bdmu06UvnLDqxTKoOJzz4SzIwb/x9i4UXGAAcnUDuIvg== + dependencies: + "@types/node" "*" + "@types/graceful-fs@^4.1.3": version "4.1.5" resolved "https://registry.yarnpkg.com/@types/graceful-fs/-/graceful-fs-4.1.5.tgz#21ffba0d98da4350db64891f92a9e5db3cdb4e15" @@ -2543,11 +2559,23 @@ asap@^2.0.0: resolved "https://registry.yarnpkg.com/asap/-/asap-2.0.6.tgz#e50347611d7e690943208bbdafebcbc2fb866d46" integrity sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA== +async-hook-jl@^1.7.6: + version "1.7.6" + resolved "https://registry.yarnpkg.com/async-hook-jl/-/async-hook-jl-1.7.6.tgz#4fd25c2f864dbaf279c610d73bf97b1b28595e68" + integrity sha512-gFaHkFfSxTjvoxDMYqDuGHlcRyUuamF8s+ZTtJdDzqjws4mCt7v0vuV79/E2Wr2/riMQgtG4/yUtXWs1gZ7JMg== + dependencies: + stack-chain "^1.3.7" + asynckit@^0.4.0: version "0.4.0" resolved "https://registry.yarnpkg.com/asynckit/-/asynckit-0.4.0.tgz#c79ed97f7f34cb8f2ba1bc9790bcc366474b4b79" integrity sha1-x57Zf380y48robyXkLzDZkdLS3k= +atomic-batcher@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/atomic-batcher/-/atomic-batcher-1.0.2.tgz#d16901d10ccec59516c197b9ccd8930689b813b4" + integrity sha512-EFGCRj4kLX1dHv1cDzTk+xbjBFj1GnJDpui52YmEcxxHHEWjYyT6l51U7n6WQ28osZH4S9gSybxe56Vm7vB61Q== + aws-sdk-client-mock@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/aws-sdk-client-mock/-/aws-sdk-client-mock-1.0.0.tgz#d52c70562ae0ff44ac07a4ef4611ff420681abff" @@ -2557,6 +2585,18 @@ aws-sdk-client-mock@^1.0.0: sinon "^11.1.1" tslib "^2.1.0" +aws-xray-sdk-core@^3.12.0: + version "3.12.0" + resolved "https://registry.yarnpkg.com/aws-xray-sdk-core/-/aws-xray-sdk-core-3.12.0.tgz#7337f287f022212467271c690e6eafe1648e928e" + integrity sha512-lwalRdxXRy+Sn49/vN7W507qqmBRk5Fy2o0a9U6XTjL9IV+oR5PUiiptoBrOcaYCiVuGld8OEbNqhm6wvV3m6A== + dependencies: + "@aws-sdk/types" "^3.4.1" + "@smithy/service-error-classification" "^2.0.4" + "@types/cls-hooked" "^4.3.3" + atomic-batcher "^1.0.2" + cls-hooked "^4.2.2" + semver "^7.5.3" + axios@^1.6.0: version "1.15.0" resolved "https://registry.yarnpkg.com/axios/-/axios-1.15.0.tgz#0fcee91ef03d386514474904b27863b2c683bf4f" @@ -2876,6 +2916,15 @@ clone@^1.0.2: resolved "https://registry.yarnpkg.com/clone/-/clone-1.0.4.tgz#da309cc263df15994c688ca902179ca3c7cd7c7e" integrity sha1-2jCcwmPfFZlMaIypAheco8fNfH4= +cls-hooked@^4.2.2: + version "4.2.2" + resolved "https://registry.yarnpkg.com/cls-hooked/-/cls-hooked-4.2.2.tgz#ad2e9a4092680cdaffeb2d3551da0e225eae1908" + integrity sha512-J4Xj5f5wq/4jAvcdgoGsL3G103BtWpZrMo8NEinRltN+xpTZdI+M38pyQqhuFU/P792xkMFvnKSf+Lm81U1bxw== + dependencies: + async-hook-jl "^1.7.6" + emitter-listener "^1.0.1" + semver "^5.4.1" + cmd-shim@^5.0.0: version "5.0.0" resolved "https://registry.yarnpkg.com/cmd-shim/-/cmd-shim-5.0.0.tgz#8d0aaa1a6b0708630694c4dbde070ed94c707724" @@ -3244,6 +3293,13 @@ electron-to-chromium@^1.4.172: resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.4.174.tgz#ffdf57f26dd4558c5aabdb4b190c47af1c4e443b" integrity sha512-JER+w+9MV2MBVFOXxP036bLlNOnzbYAWrWU8sNUwoOO69T3w4564WhM5H5atd8VVS8U4vpi0i0kdoYzm1NPQgQ== +emitter-listener@^1.0.1: + version "1.1.2" + resolved "https://registry.yarnpkg.com/emitter-listener/-/emitter-listener-1.1.2.tgz#56b140e8f6992375b3d7cb2cab1cc7432d9632e8" + integrity sha512-Bt1sBAGFHY9DKY+4/2cV6izcKJUf5T7/gkdmkxzX/qv9CcGH8xSwVRW5mtX03SWJtRTWSOpzCuWN9rBFYZepZQ== + dependencies: + shimmer "^1.2.0" + emittery@^0.10.2: version "0.10.2" resolved "https://registry.yarnpkg.com/emittery/-/emittery-0.10.2.tgz#902eec8aedb8c41938c46e9385e9db7e03182933" @@ -6201,7 +6257,7 @@ semver-regex@^3.1.2: resolved "https://registry.yarnpkg.com/semver-regex/-/semver-regex-3.1.4.tgz#13053c0d4aa11d070a2f2872b6b1e3ae1e1971b4" integrity sha512-6IiqeZNgq01qGf0TId0t3NvKzSvUsjcpdEO3AQNeIjR6A2+ckTnQlDpl4qu1bjRv0RzN3FP9hzFmws3lKqRWkA== -"semver@2 || 3 || 4 || 5": +"semver@2 || 3 || 4 || 5", semver@^5.4.1: version "5.7.2" resolved "https://registry.yarnpkg.com/semver/-/semver-5.7.2.tgz#48d55db737c3287cd4835e17fa13feace1c41ef8" integrity sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g== @@ -6218,6 +6274,11 @@ semver@^7.0.0, semver@^7.1.1, semver@^7.1.2, semver@^7.3.2, semver@^7.3.4, semve dependencies: lru-cache "^6.0.0" +semver@^7.5.3: + version "7.8.1" + resolved "https://registry.yarnpkg.com/semver/-/semver-7.8.1.tgz#bf4970b5e70fda0686363cc18bfe8805d5ed957e" + integrity sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg== + set-blocking@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/set-blocking/-/set-blocking-2.0.0.tgz#045f9782d011ae9a6803ddd382b24392b3d890f7" @@ -6235,6 +6296,11 @@ shebang-regex@^3.0.0: resolved "https://registry.yarnpkg.com/shebang-regex/-/shebang-regex-3.0.0.tgz#ae16f1644d873ecad843b0307b143362d4c42172" integrity sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A== +shimmer@^1.2.0: + version "1.2.1" + resolved "https://registry.yarnpkg.com/shimmer/-/shimmer-1.2.1.tgz#610859f7de327b587efebf501fb43117f9aff337" + integrity sha512-sQTKC1Re/rM6XyFM6fIAGHRPVGvyXfgzIDvzoq608vM+jeyVD0Tu1E6Np0Kc2zAIFWIj963V2800iF/9LPieQw== + signal-exit@^3.0.3, signal-exit@^3.0.7: version "3.0.7" resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-3.0.7.tgz#a9a1767f8af84155114eaabd73f99273c8f59ad9" @@ -6378,6 +6444,11 @@ ssri@^9.0.0, ssri@^9.0.1: dependencies: minipass "^3.1.1" +stack-chain@^1.3.7: + version "1.3.7" + resolved "https://registry.yarnpkg.com/stack-chain/-/stack-chain-1.3.7.tgz#d192c9ff4ea6a22c94c4dd459171e3f00cea1285" + integrity sha512-D8cWtWVdIe/jBA7v5p5Hwl5yOSOrmZPWDPe2KxQ5UAGD+nxbxU0lKXA4h85Ta6+qgdKVL3vUxsbIZjc1kBG7ug== + stack-utils@^2.0.3: version "2.0.3" resolved "https://registry.yarnpkg.com/stack-utils/-/stack-utils-2.0.3.tgz#cd5f030126ff116b78ccb3c027fe302713b61277" @@ -6674,6 +6745,11 @@ tslib@^2.5.0: resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.6.0.tgz#b295854684dbda164e181d259a22cd779dcd7bc3" integrity sha512-7At1WUettjcSRHXCyYtTselblcHl9PJFFVKiCAy/bY97+BPZXSQ2wbq0P9s8tK2G7dFQfNnlJnPAiArVBVBsfA== +tslib@^2.6.2: + version "2.8.1" + resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.8.1.tgz#612efe4ed235d567e8aba5f2a5fab70280ade83f" + integrity sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w== + tsutils@^3.21.0: version "3.21.0" resolved "https://registry.yarnpkg.com/tsutils/-/tsutils-3.21.0.tgz#b48717d394cea6c1e096983eed58e9d61715b623"