Repository: serverless/dashboard (language: python)

Issue:
AWS Lambda SDK: Instrument API Gateway HTTP API (v1 payload) events
They share the same schema as REST API events, so the update is mostly about integration tests configuration


Discussion / hints:

Fix patch:
diff --git a/node/packages/aws-lambda-sdk/docs/sdk-trace.md b/node/packages/aws-lambda-sdk/docs/sdk-trace.md
index 0d4b899e7..02ba96521 100644
--- a/node/packages/aws-lambda-sdk/docs/sdk-trace.md
+++ b/node/packages/aws-lambda-sdk/docs/sdk-trace.md
@@ -51,12 +51,12 @@ Tags set in case of `'error:handled'` outcome
 | `aws.lambda.error_exception_message`    | Error message     |
 | `aws.lambda.error_exception_stacktrace` | Error stack trace |
 
-##### AWS API Gateway, v1 REST API endpoint & v2 HTTP API (v1 payload)
+##### AWS API Gateway
 
 Tags collected if event is sourced by either:
 
 - AWS API Gateway v1 REST API endpoint configured with `AWS_PROXY` integration type.
-- AWS API Gateway v2 HTTP API endpoint configured with v1 version of a payload
+- AWS API Gateway v2 HTTP API endpoint configured with either v1 or v2 version of a payload
 
 | Name                                                     | Value                                                                               |
 | -------------------------------------------------------- | ----------------------------------------------------------------------------------- |
diff --git a/node/packages/aws-lambda-sdk/lib/resolve-event-tags.js b/node/packages/aws-lambda-sdk/lib/resolve-event-tags.js
index 06dce2282..3d153945f 100644
--- a/node/packages/aws-lambda-sdk/lib/resolve-event-tags.js
+++ b/node/packages/aws-lambda-sdk/lib/resolve-event-tags.js
@@ -75,6 +75,30 @@ const apiGatewayEventMap = [
   'isBase64Encoded',
 ];
 
+const httpApiV2EventMap = [
+  'version',
+  'routeKey',
+  'rawPath',
+  'rawQueryString',
+  'headers',
+  [
+    'requestContext',
+    [
+      'accountId',
+      'apiId',
+      'domainName',
+      'domainPrefix',
+      ['http', ['method', 'path', 'protocol', 'sourceIp', 'userAgent']],
+      'requestId',
+      'routeKey',
+      'stage',
+      'time',
+      'timeEpoch',
+    ],
+  ],
+  'isBase64Encoded',
+];
+
 module.exports = (event) => {
   if (!isObject(event)) return;
   if (doesObjectMatchMap(event, apiGatewayEventMap)) {
@@ -102,5 +126,35 @@ module.exports = (event) => {
       },
       { prefix: 'aws.lambda.api_gateway.request' }
     );
+    return;
+  }
+  if (doesObjectMatchMap(event, httpApiV2EventMap)) {
+    // API Gateway v2 HTTP API (v2 payload) event
+    const { requestContext } = event;
+    awsLambdaSpan.tags.setMany(
+      {
+        account_id: requestContext.accountId,
+        api_id: requestContext.apiId,
+        api_stage: requestContext.stage,
+      },
+      { prefix: 'aws.lambda.api_gateway' }
+    );
+    awsLambdaSpan.tags.setMany(
+      {
+        id: requestContext.requestId,
+        time_epoch: requestContext.timeEpoch,
+        protocol: requestContext.http.protocol,
+        domain: requestContext.domainName,
+        headers: JSON.stringify(event.headers || {}),
+        method: requestContext.http.method,
+        path: requestContext.http.path,
+        path_parameters: event.pathParameters ? JSON.stringify(event.pathParameters) : null,
+        query_string_parameters: event.queryStringParameters
+          ? JSON.stringify(event.queryStringParameters)
+          : null,
+      },
+      { prefix: 'aws.lambda.api_gateway.request' }
+    );
+    return;
   }
 };


Test patch:
diff --git a/node/packages/aws-lambda-sdk/test/integration/index.test.js b/node/packages/aws-lambda-sdk/test/integration/index.test.js
index b12392855..d8919515f 100644
--- a/node/packages/aws-lambda-sdk/test/integration/index.test.js
+++ b/node/packages/aws-lambda-sdk/test/integration/index.test.js
@@ -26,6 +26,44 @@ describe('integration', function () {
   this.timeout(120000);
   const coreConfig = {};
 
+  const getCreateHttpApi = (payloadFormatVersion) => async (testConfig) => {
+    const apiId = (testConfig.apiId = (
+      await awsRequest(ApiGatewayV2, 'createApi', {
+        Name: testConfig.configuration.FunctionName,
+        ProtocolType: 'HTTP',
+      })
+    ).ApiId);
+    const deferredAddPermission = awsRequest(Lambda, 'addPermission', {
+      FunctionName: testConfig.configuration.FunctionName,
+      Principal: '*',
+      Action: 'lambda:InvokeFunction',
+      SourceArn: `arn:aws:execute-api:${process.env.AWS_REGION}:${coreConfig.accountId}:${apiId}/*`,
+      StatementId: testConfig.name,
+    });
+    const integrationId = (
+      await awsRequest(ApiGatewayV2, 'createIntegration', {
+        ApiId: apiId,
+        IntegrationType: 'AWS_PROXY',
+        IntegrationUri: `arn:aws:lambda:${process.env.AWS_REGION}:${coreConfig.accountId}:function:${testConfig.configuration.FunctionName}`,
+        PayloadFormatVersion: payloadFormatVersion,
+      })
+    ).IntegrationId;
+
+    await awsRequest(ApiGatewayV2, 'createRoute', {
+      ApiId: apiId,
+      RouteKey: 'POST /test',
+      Target: `integrations/${integrationId}`,
+    });
+
+    await awsRequest(ApiGatewayV2, 'createStage', {
+      ApiId: apiId,
+      StageName: '$default',
+      AutoDeploy: true,
+    });
+
+    await deferredAddPermission;
+  };
+
   const useCasesConfig = new Map([
     [
       'esm-callback/index',
@@ -205,43 +243,53 @@ describe('integration', function () {
             'http-api-v1',
             {
               hooks: {
-                afterCreate: async (testConfig) => {
-                  const apiId = (testConfig.apiId = (
-                    await awsRequest(ApiGatewayV2, 'createApi', {
-                      Name: testConfig.configuration.FunctionName,
-                      ProtocolType: 'HTTP',
-                    })
-                  ).ApiId);
-                  const deferredAddPermission = awsRequest(Lambda, 'addPermission', {
-                    FunctionName: testConfig.configuration.FunctionName,
-                    Principal: '*',
-                    Action: 'lambda:InvokeFunction',
-                    SourceArn: `arn:aws:execute-api:${process.env.AWS_REGION}:${coreConfig.accountId}:${apiId}/*`,
-                    StatementId: testConfig.name,
-                  });
-                  const integrationId = (
-                    await awsRequest(ApiGatewayV2, 'createIntegration', {
-                      ApiId: apiId,
-                      IntegrationType: 'AWS_PROXY',
-                      IntegrationUri: `arn:aws:lambda:${process.env.AWS_REGION}:${coreConfig.accountId}:function:${testConfig.configuration.FunctionName}`,
-                      PayloadFormatVersion: '1.0',
-                    })
-                  ).IntegrationId;
-
-                  await awsRequest(ApiGatewayV2, 'createRoute', {
-                    ApiId: apiId,
-                    RouteKey: 'POST /test',
-                    Target: `integrations/${integrationId}`,
-                  });
-
-                  await awsRequest(ApiGatewayV2, 'createStage', {
-                    ApiId: apiId,
-                    StageName: '$default',
-                    AutoDeploy: true,
-                  });
-
-                  await deferredAddPermission;
+                afterCreate: getCreateHttpApi('1.0'),
+                beforeDelete: async (testConfig) => {
+                  await awsRequest(ApiGatewayV2, 'deleteApi', { ApiId: testConfig.apiId });
                 },
+              },
+              invoke: async (testConfig) => {
+                const startTime = process.hrtime.bigint();
+                const response = await fetch(
+                  `https://${testConfig.apiId}.execute-api.${process.env.AWS_REGION}.amazonaws.com/test`,
+                  {
+                    method: 'POST',
+                    body: JSON.stringify({ some: 'content' }),
+                    headers: {
+                      'Content-Type': 'application/json',
+                    },
+                  }
+                );
+                if (response.status !== 200) {
+                  throw new Error(`Unexpected response status: ${response.status}`);
+                }
+                const payload = { raw: await response.text() };
+                const duration = Math.round(Number(process.hrtime.bigint() - startTime) / 1000000);
+                log.debug('invoke response payload %s', payload.raw);
+                return { duration, payload };
+              },
+              test: ({ invocationsData, testConfig }) => {
+                for (const [, trace] of invocationsData.map((data) => data.trace).entries()) {
+                  const { tags } = trace.spans[0];
+
+                  expect(tags).to.have.property('aws.lambda.api_gateway.account_id');
+                  expect(tags['aws.lambda.api_gateway.api_id']).to.equal(testConfig.apiId);
+                  expect(tags['aws.lambda.api_gateway.api_stage']).to.equal('$default');
+                  expect(tags).to.have.property('aws.lambda.api_gateway.request.id');
+                  expect(tags).to.have.property('aws.lambda.api_gateway.request.time_epoch');
+                  expect(tags).to.have.property('aws.lambda.api_gateway.request.domain');
+                  expect(tags).to.have.property('aws.lambda.api_gateway.request.headers');
+                  expect(tags['aws.lambda.api_gateway.request.method']).to.equal('POST');
+                  expect(tags['aws.lambda.api_gateway.request.path']).to.equal('/test');
+                }
+              },
+            },
+          ],
+          [
+            'http-api-v2',
+            {
+              hooks: {
+                afterCreate: getCreateHttpApi('2.0'),
                 beforeDelete: async (testConfig) => {
                   await awsRequest(ApiGatewayV2, 'deleteApi', { ApiId: testConfig.apiId });
                 },
diff --git a/node/packages/aws-lambda-sdk/test/unit/internal-extension/index.test.js b/node/packages/aws-lambda-sdk/test/unit/internal-extension/index.test.js
index 4c195dfb1..6d846efdf 100644
--- a/node/packages/aws-lambda-sdk/test/unit/internal-extension/index.test.js
+++ b/node/packages/aws-lambda-sdk/test/unit/internal-extension/index.test.js
@@ -57,10 +57,14 @@ const handleInvocation = async (handlerModuleName, options = {}) => {
       error,
       trace: serverlessSdk._lastTrace,
       protoTraceInput: serverlessSdk._lastProtoTrace,
-      protoTraceOutput: TracePayload.decode(serverlessSdk._lastProtoTraceBuffer),
+      protoTraceOutput:
+        serverlessSdk._lastProtoTraceBuffer &&
+        TracePayload.decode(serverlessSdk._lastProtoTraceBuffer),
     };
   });
-  if (!outcome.trace && outcome.error) throw outcome.error;
+  if (outcome.error && (!outcome.protoTraceOutput || options.outcome !== 'error')) {
+    throw outcome.error;
+  }
   const [{ tags }] = outcome.trace.spans;
   expect(outcome.trace.slsTags).to.deep.equal({
     'orgId': process.env.SLS_ORG_ID,
@@ -308,4 +312,77 @@ describe('internal-extension/index.test.js', () => {
       })
     );
   });
+
+  it('should handle API Gateway v2 HTTP API, payload v2 event', async () => {
+    const {
+      trace: {
+        spans: [{ tags }],
+      },
+    } = await handleInvocation('api-endpoint', {
+      isApiEndpoint: true,
+      payload: {
+        version: '2.0',
+        routeKey: 'POST /v2',
+        rawPath: '/v2',
+        rawQueryString: 'lone=value&multi=one,stillone&multi=two',
+        headers: {
+          'content-length': '385',
+          'content-type':
+            'multipart/form-data; boundary=--------------------------419073009317249310175915',
+          'multi': 'one,stillone,two',
+        },
+        queryStringParameters: {
+          lone: 'value',
+          multi: 'one,stillone,two',
+        },
+        requestContext: {
+          accountId: '205994128558',
+          apiId: 'xxx',
+          domainName: 'xxx.execute-api.us-east-1.amazonaws.com',
+          domainPrefix: 'xx',
+          http: {
+            method: 'POST',
+            path: '/v2',
+            protocol: 'HTTP/1.1',
+            sourceIp: '80.55.87.22',
+            userAgent: 'PostmanRuntime/7.29.0',
+          },
+          requestId: 'XyGnwhe0oAMEJJw=',
+          routeKey: 'POST /v2',
+          stage: '$default',
+          time: '01/Sep/2022:13:46:51 +0000',
+          timeEpoch: 1662040011065,
+        },
+        body: 'LS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLTQxOTA3MzAwOTMxNzI0OTMxMDE3NTkxNQ0KQ29udGVudC1EaXNwb3NpdGlvbjogZm9ybS1kYXRhOyBuYW1lPSJMb25lIg0KDQpvbmUNCi0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS00MTkwNzMwMDkzMTcyNDkzMTAxNzU5MTUNCkNvbnRlbnQtRGlzcG9zaXRpb246IGZvcm0tZGF0YTsgbmFtZT0ibXVsdGkiDQoNCm9uZSxzdGlsbG9uZQ0KLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLTQxOTA3MzAwOTMxNzI0OTMxMDE3NTkxNQ0KQ29udGVudC1EaXNwb3NpdGlvbjogZm9ybS1kYXRhOyBuYW1lPSJtdWx0aSINCg0KdHdvDQotLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tNDE5MDczMDA5MzE3MjQ5MzEwMTc1OTE1LS0NCg==',
+        isBase64Encoded: true,
+      },
+    });
+
+    expect(tags.get('aws.lambda.api_gateway.account_id')).to.equal('205994128558');
+    expect(tags.get('aws.lambda.api_gateway.api_id')).to.equal('xxx');
+    expect(tags.get('aws.lambda.api_gateway.api_stage')).to.equal('$default');
+
+    expect(tags.get('aws.lambda.api_gateway.request.id')).to.equal('XyGnwhe0oAMEJJw=');
+    expect(tags.get('aws.lambda.api_gateway.request.time_epoch')).to.equal(1662040011065);
+    expect(tags.get('aws.lambda.api_gateway.request.protocol')).to.equal('HTTP/1.1');
+    expect(tags.get('aws.lambda.api_gateway.request.domain')).to.equal(
+      'xxx.execute-api.us-east-1.amazonaws.com'
+    );
+    expect(tags.get('aws.lambda.api_gateway.request.headers')).to.equal(
+      JSON.stringify({
+        'content-length': '385',
+        'content-type':
+          'multipart/form-data; boundary=--------------------------419073009317249310175915',
+        'multi': 'one,stillone,two',
+      })
+    );
+    expect(tags.get('aws.lambda.api_gateway.request.method')).to.equal('POST');
+    expect(tags.get('aws.lambda.api_gateway.request.path')).to.equal('/v2');
+    expect(tags.get('aws.lambda.api_gateway.request.query_string_parameters')).to.equal(
+      JSON.stringify({
+        lone: 'value',
+        multi: 'one,stillone,two',
+      })
+    );
+  });
 });