Repository: serverless/dashboard (language: python)

Issue:
AWS Lambda SDK: Switch from `express` tags to `aws.lambda.http_router`
_Depends on #223_

Additionally improve internal organization of _instrumentation_ utitilties and _instrument_ function


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 ed32073e2..8e948f609 100644
--- a/node/packages/aws-lambda-sdk/docs/sdk-trace.md
+++ b/node/packages/aws-lambda-sdk/docs/sdk-trace.md
@@ -57,6 +57,7 @@ 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 either v1 or v2 version of a payload
+- AWS Function URL
 
 | Name                          | Value                                                           |
 | ----------------------------- | --------------------------------------------------------------- |
@@ -88,6 +89,15 @@ Tags collected if event is sourced by either:
 | `aws.lambda.api_gateway.request.path_parameters` | JSON string of request path parameters                                                           |
 | `aws.lambda.http_router.path`                    | Route path with unresolved param (potentally overriden by router framework as `express` if used) |
 
+##### Function URL
+
+Tags collected if event is sourced by Function URL
+
+| Name                      | Value              |
+| ------------------------- | ------------------ |
+| `aws.lambda.event_source` | `"aws.lambda"`     |
+| `aws.lambda.event_type`   | `"aws.lambda.url"` |
+
 ##### SQS queue message
 
 Tags collected if event is sourced by SQS queue
diff --git a/node/packages/aws-lambda-sdk/instrument/lib/resolve-event-tags.js b/node/packages/aws-lambda-sdk/instrument/lib/resolve-event-tags.js
index 20c0693c9..cc6bb7cae 100644
--- a/node/packages/aws-lambda-sdk/instrument/lib/resolve-event-tags.js
+++ b/node/packages/aws-lambda-sdk/instrument/lib/resolve-event-tags.js
@@ -195,28 +195,44 @@ module.exports = (event) => {
     return;
   }
   if (doesObjectMatchMap(event, httpApiV2EventMap)) {
-    // API Gateway v2 HTTP API (v2 payload) event
+    // API Gateway v2 HTTP API (v2 payload) or Lambda URL event
     const { requestContext } = event;
-    awsLambdaSpan.tags.setMany(
-      { event_source: 'aws.apigateway', event_type: 'aws.apigatewayv2.http.v2' },
-      { prefix: 'aws.lambda' }
-    );
-    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,
-        path_parameter_names: Object.keys(event.pathParameters || {}),
-      },
-      { prefix: 'aws.lambda.api_gateway.request' }
-    );
+    const eventSource = requestContext.domainName.includes('.lambda-url.')
+      ? 'lambdaUrl'
+      : 'httpApi';
+    if (eventSource === 'lambdaUrl') {
+      awsLambdaSpan.tags.setMany(
+        { event_source: 'aws.lambda', event_type: 'aws.lambda.url' },
+        { prefix: 'aws.lambda' }
+      );
+    } else {
+      awsLambdaSpan.tags.setMany(
+        { event_source: 'aws.apigateway', event_type: 'aws.apigatewayv2.http.v2' },
+        { prefix: 'aws.lambda' }
+      );
+      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,
+          path_parameter_names: Object.keys(event.pathParameters || {}),
+        },
+        { prefix: 'aws.lambda.api_gateway.request' }
+      );
+      awsLambdaSpan.tags.set(
+        'aws.lambda.http_router.path',
+        event.routeKey === '$default'
+          ? '$default'
+          : event.routeKey.slice(event.routeKey.indexOf(' ') + 1)
+      );
+    }
     awsLambdaSpan.tags.setMany(
       {
         method: requestContext.http.method,
@@ -228,12 +244,6 @@ module.exports = (event) => {
       },
       { prefix: 'aws.lambda.http' }
     );
-    awsLambdaSpan.tags.set(
-      'aws.lambda.http_router.path',
-      event.routeKey === '$default'
-        ? '$default'
-        : event.routeKey.slice(event.routeKey.indexOf(' ') + 1)
-    );
     return;
   }
 
diff --git a/node/packages/aws-lambda-sdk/instrument/lib/resolve-response-tags.js b/node/packages/aws-lambda-sdk/instrument/lib/resolve-response-tags.js
index e4a485153..a763d0a73 100644
--- a/node/packages/aws-lambda-sdk/instrument/lib/resolve-response-tags.js
+++ b/node/packages/aws-lambda-sdk/instrument/lib/resolve-response-tags.js
@@ -5,8 +5,11 @@ const coerceNaturalNumber = require('type/natural-number/coerce');
 const awsLambdaSpan = (global.serverlessSdk || require('../../')).traceSpans.awsLambda;
 
 module.exports = (response) => {
-  switch (awsLambdaSpan.tags.get('aws.lambda.event_source')) {
-    case 'aws.apigateway':
+  switch (awsLambdaSpan.tags.get('aws.lambda.event_type')) {
+    case 'aws.apigateway.rest':
+    case 'aws.apigatewayv2.http.v1':
+    case 'aws.apigatewayv2.http.v2':
+    case 'aws.lambda.url':
       {
         let statusCode = response?.statusCode;
         if (statusCode == null) {


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 a1be7922a..34c055482 100644
--- a/node/packages/aws-lambda-sdk/test/integration/index.test.js
+++ b/node/packages/aws-lambda-sdk/test/integration/index.test.js
@@ -730,6 +730,98 @@ describe('integration', function () {
 
                   expect(tags.aws.lambda.httpRouter.path).to.equal('/test');
 
+                  expect(JSON.parse(request.data.requestData)).to.have.property('rawPath');
+                  expect(response.data.responseData).to.equal(
+                    JSON.stringify({ statusCode: 200, body: '"ok"' })
+                  );
+                }
+              },
+            },
+          ],
+          [
+            'function-url',
+            {
+              hooks: {
+                afterCreate: async function self(testConfig) {
+                  await awsRequest(Lambda, 'createAlias', {
+                    FunctionName: testConfig.configuration.FunctionName,
+                    FunctionVersion: '$LATEST',
+                    Name: 'url',
+                  });
+                  const deferredFunctionUrl = (async () => {
+                    try {
+                      return (
+                        await awsRequest(Lambda, 'createFunctionUrlConfig', {
+                          AuthType: 'NONE',
+                          FunctionName: testConfig.configuration.FunctionName,
+                          Qualifier: 'url',
+                        })
+                      ).FunctionUrl;
+                    } catch (error) {
+                      if (
+                        error.message.includes('FunctionUrlConfig exists for this Lambda function')
+                      ) {
+                        return (
+                          await awsRequest(Lambda, 'getFunctionUrlConfig', {
+                            FunctionName: testConfig.configuration.FunctionName,
+                            Qualifier: 'url',
+                          })
+                        ).FunctionUrl;
+                      }
+                      throw error;
+                    }
+                  })();
+                  await Promise.all([
+                    deferredFunctionUrl,
+                    awsRequest(Lambda, 'addPermission', {
+                      FunctionName: testConfig.configuration.FunctionName,
+                      Qualifier: 'url',
+                      FunctionUrlAuthType: 'NONE',
+                      Principal: '*',
+                      Action: 'lambda:InvokeFunctionUrl',
+                      StatementId: 'public-function-url',
+                    }),
+                  ]);
+                  testConfig.functionUrl = await deferredFunctionUrl;
+                },
+                beforeDelete: async (testConfig) => {
+                  await awsRequest(Lambda, 'deleteFunctionUrlConfig', {
+                    FunctionName: testConfig.configuration.FunctionName,
+                    Qualifier: 'url',
+                  });
+                },
+              },
+              invoke: async (testConfig) => {
+                const startTime = process.hrtime.bigint();
+                const response = await fetch(`${testConfig.functionUrl}/test?foo=bar`, {
+                  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 }) => {
+                for (const { trace, request, response } of invocationsData) {
+                  const { tags } = trace.spans[0];
+
+                  expect(tags.aws.lambda.eventSource).to.equal('aws.lambda');
+                  expect(tags.aws.lambda.eventType).to.equal('aws.lambda.url');
+
+                  expect(tags.aws.lambda.http).to.have.property('host');
+                  expect(tags.aws.lambda.http).to.have.property('requestHeaderNames');
+                  expect(tags.aws.lambda.http.method).to.equal('POST');
+                  expect(tags.aws.lambda.http.path).to.equal('/test');
+
+                  expect(tags.aws.lambda.http.statusCode.toString()).to.equal('200');
+
                   expect(JSON.parse(request.data.requestData)).to.have.property('rawPath');
                   expect(response.data.responseData).to.equal(
                     JSON.stringify({ statusCode: 200, body: '"ok"' })
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 2fe37232a..cfdbd9114 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
@@ -421,6 +421,75 @@ describe('internal-extension/index.test.js', () => {
     );
   });
 
+  it('should handle function url payload event', async () => {
+    const {
+      trace: {
+        input: {
+          spans: [{ tags }],
+        },
+      },
+      response: { input: response },
+    } = await handleInvocation('api-endpoint', {
+      isApiEndpoint: true,
+      payload: {
+        version: '2.0',
+        routeKey: '$default',
+        rawPath: '/function-url-test',
+        rawQueryString: 'lone=value&multi=one,stillone&multi=two',
+        headers: {
+          'accept-encoding': 'gzip, deflate, br',
+          'sec-fetch-dest': 'document',
+          'user-agent':
+            'Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:105.0) Gecko/20100101 Firefox/105.0',
+        },
+        queryStringParameters: {
+          lone: 'value',
+          multi: 'one,stillone,two',
+        },
+        requestContext: {
+          accountId: 'anonymous',
+          apiId: 'xxx',
+          domainName: 'xxx.lambda-url.us-east-1.on.aws',
+          domainPrefix: 'xxx',
+          http: {
+            method: 'GET',
+            path: '/function-url-test',
+            protocol: 'HTTP/1.1',
+            sourceIp: '80.55.87.22',
+            userAgent:
+              'Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:105.0) Gecko/20100101 Firefox/105.0',
+          },
+          requestId: '71ab96bc-8418-4429-863d-2ad7fcbb70d0',
+          routeKey: '$default',
+          stage: '$default',
+          time: '28/Sep/2022:16:10:24 +0000',
+          timeEpoch: 1664381424747,
+        },
+        isBase64Encoded: false,
+      },
+    });
+
+    expect(tags.aws.lambda.eventSource).to.equal('aws.lambda');
+    expect(tags.aws.lambda.eventType).to.equal('aws.lambda.url');
+    expect(tags.aws.lambda.http.protocol).to.equal('HTTP/1.1');
+    expect(tags.aws.lambda.http.host).to.equal('xxx.lambda-url.us-east-1.on.aws');
+    expect(tags.aws.lambda.http.requestHeaderNames).to.deep.equal([
+      'accept-encoding',
+      'sec-fetch-dest',
+      'user-agent',
+    ]);
+
+    expect(tags.aws.lambda.http.method).to.equal('GET');
+    expect(tags.aws.lambda.http.path).to.equal('/function-url-test');
+    expect(tags.aws.lambda.http.queryParameterNames).to.deep.equal(['lone', 'multi']);
+
+    expect(tags.aws.lambda.http.statusCode.toString()).to.equal('200');
+
+    expect(response.data.responseData).to.deep.equal(
+      JSON.stringify({ statusCode: 200, body: '"ok"' })
+    );
+  });
+
   it('should handle SQS event', async () => {
     const {
       trace: {