diff --git a/.gitlab/templates/pipeline.yaml.tpl b/.gitlab/templates/pipeline.yaml.tpl index 363bdbfc3..1b11dc355 100644 --- a/.gitlab/templates/pipeline.yaml.tpl +++ b/.gitlab/templates/pipeline.yaml.tpl @@ -324,6 +324,49 @@ signed layer bundle: - mkdir -p datadog_extension-signed-bundle-${CI_JOB_ID} - cp .layers/datadog_extension-*.zip datadog_extension-signed-bundle-${CI_JOB_ID} +# Integration Tests - Build Java Lambda function +build java lambda: + stage: integration-tests + image: registry.ddbuild.io/images/docker:27.3.1 + tags: ["docker-in-docker:arm64"] + rules: + - when: on_success + needs: [] + artifacts: + expire_in: 1 hour + paths: + - integration-tests/lambda/base-java/target/ + script: + - cd integration-tests/lambda/base-java + - docker run --rm --platform linux/arm64 + -v "$(pwd)":/workspace + -w /workspace + maven:3.9-eclipse-temurin-21-alpine + mvn clean package + +# Integration Tests - Build .NET Lambda function +build dotnet lambda: + stage: integration-tests + image: registry.ddbuild.io/images/docker:27.3.1 + tags: ["docker-in-docker:arm64"] + rules: + - when: on_success + needs: [] + artifacts: + expire_in: 1 hour + paths: + - integration-tests/lambda/base-dotnet/bin/ + script: + - cd integration-tests/lambda/base-dotnet + - docker run --rm --platform linux/arm64 + -v "$(pwd)":/workspace + -w /workspace + mcr.microsoft.com/dotnet/sdk:8.0-alpine + sh -c "apk add --no-cache zip && + dotnet tool install -g Amazon.Lambda.Tools || true && + export PATH=\"\$PATH:/root/.dotnet/tools\" && + dotnet lambda package -o bin/function.zip --function-architecture arm64" + # Integration Tests - Publish arm64 layer with integration test prefix publish integration layer (arm64): stage: integration-tests @@ -362,6 +405,11 @@ integration-deploy: - when: on_success needs: - publish integration layer (arm64) + - build java lambda + - build dotnet lambda + dependencies: + - build java lambda + - build dotnet lambda variables: IDENTIFIER: ${CI_COMMIT_SHORT_SHA} AWS_DEFAULT_REGION: us-east-1 @@ -418,35 +466,55 @@ integration-cleanup-stacks: stage: integration-tests tags: ["arch:amd64"] image: ${CI_DOCKER_TARGET_IMAGE}:${CI_DOCKER_TARGET_VERSION} - when: always rules: - when: always needs: - - integration-test + - job: integration-test + optional: false variables: IDENTIFIER: ${CI_COMMIT_SHORT_SHA} {{ with $environment := (ds "environments").environments.sandbox }} before_script: - EXTERNAL_ID_NAME={{ $environment.external_id }} ROLE_TO_ASSUME={{ $environment.role_to_assume }} AWS_ACCOUNT={{ $environment.account }} source .gitlab/scripts/get_secrets.sh - - curl -fsSL https://deb.nodesource.com/setup_20.x | bash - - - apt-get install -y nodejs - - cd integration-tests - - npm ci {{ end }} script: - echo "Destroying CDK stacks with identifier ${IDENTIFIER}..." - - npx cdk destroy "integ-$IDENTIFIER-*" --force || echo "Failed to destroy some stacks, but continuing..." + - | + # Find all stacks matching the pattern using CloudFormation API + STACKS=$(aws cloudformation list-stacks \ + --stack-status-filter CREATE_COMPLETE UPDATE_COMPLETE UPDATE_ROLLBACK_COMPLETE \ + --query "StackSummaries[?starts_with(StackName, 'integ-${IDENTIFIER}-')].StackName" \ + --output text --region us-east-1) + + if [ -z "$STACKS" ]; then + echo "No stacks found matching pattern integ-${IDENTIFIER}-*" + else + echo "Found stacks to delete: ${STACKS}" + for STACK in $STACKS; do + echo "Deleting stack ${STACK}..." + aws cloudformation delete-stack --stack-name "${STACK}" --region us-east-1 || echo "Failed to delete ${STACK}, continuing..." + done + + # Wait for all deletions to complete + echo "Waiting for stack deletions to complete..." + for STACK in $STACKS; do + echo "Waiting for ${STACK}..." + aws cloudformation wait stack-delete-complete --stack-name "${STACK}" --region us-east-1 || echo "Stack ${STACK} deletion did not complete cleanly, continuing..." + done + + echo "All stacks deleted successfully" + fi # Integration Tests - Cleanup layer integration-cleanup-layer: stage: integration-tests tags: ["arch:amd64"] image: ${CI_DOCKER_TARGET_IMAGE}:${CI_DOCKER_TARGET_VERSION} - when: always rules: - when: always needs: - - integration-cleanup-stacks + - job: integration-cleanup-stacks + optional: false variables: IDENTIFIER: ${CI_COMMIT_SHORT_SHA} {{ with $environment := (ds "environments").environments.sandbox }} @@ -456,7 +524,7 @@ integration-cleanup-layer: script: - echo "Deleting integration test layer with identifier ${IDENTIFIER}..." - | - LAYER_NAME="Datadog-Extension-${IDENTIFIER}" + LAYER_NAME="Datadog-Extension-ARM-${IDENTIFIER}" echo "Looking for layer: ${LAYER_NAME}" # Get all versions of the layer diff --git a/integration-tests/README.md b/integration-tests/README.md index 965940f1e..eea6e75b3 100644 --- a/integration-tests/README.md +++ b/integration-tests/README.md @@ -8,7 +8,40 @@ The general flow is: 3. Wait for data to propagate to Datadog. 4. Call Datadog to get telemetry data and check the data based on test requirements. -For simplicity, integraiton tests are setup to only test against ARM runtimes. +For simplicity, integration tests are setup to only test against ARM runtimes. + +## Test Suites + +### Base Tests + +The base test suite provides basic functionality tests across all supported Lambda runtimes. Also serves as an example for other tests. + +The base tests verify the extension can: +- Collect and forward logs to Datadog +- Generate and send traces with proper span structure +- Detect cold starts + +**Test Coverage:** +- Lambda invocation succeeds (200 status code) +- "Hello world!" log message is sent to Datadog +- One trace is sent to Datadog +- `aws.lambda` span exists with correct properties including `cold_start: 'true'` +- `aws.lambda.cold_start` span is created +- `aws.lambda.load` spand is created for python and node. + +**Build Requirements:** + +For Java and .NET tests, Lambda functions must be built before deployment: + +```bash +# Build Java Lambda (uses Docker) +cd lambda/base-java && ./build.sh + +# Build .NET Lambda (uses Docker) +cd lambda/base-dotnet && ./build.sh +``` + +These builds use Docker to ensure cross-platform compatibility and do not require local Maven or .NET SDK installation. ## Guidelines diff --git a/integration-tests/bin/app.ts b/integration-tests/bin/app.ts index 7918a731d..91e435ee2 100644 --- a/integration-tests/bin/app.ts +++ b/integration-tests/bin/app.ts @@ -1,25 +1,37 @@ #!/usr/bin/env node import 'source-map-support/register'; import * as cdk from 'aws-cdk-lib'; -import { BaseNodeStack } from '../lib/stacks/base-node-stack'; -import { BasePythonStack } from '../lib/stacks/base-python-stack'; -import { getIdentifier } from '../tests/utils/config'; +import {BaseNodeStack} from '../lib/stacks/base-node-stack'; +import {BasePythonStack} from '../lib/stacks/base-python-stack'; +import {BaseJavaStack} from '../lib/stacks/base-java-stack'; +import {BaseDotnetStack} from '../lib/stacks/base-dotnet-stack'; +import {getIdentifier} from '../tests/utils/config'; const app = new cdk.App(); const env = { - account: process.env.CDK_DEFAULT_ACCOUNT || process.env.AWS_ACCOUNT_ID, - region: process.env.CDK_DEFAULT_REGION || process.env.AWS_REGION || 'us-east-1', + account: process.env.CDK_DEFAULT_ACCOUNT || process.env.AWS_ACCOUNT_ID, + region: process.env.CDK_DEFAULT_REGION || process.env.AWS_REGION || 'us-east-1', }; const identifier = getIdentifier(); -new BaseNodeStack(app, `integ-${identifier}-base-node`, { - env, -}); +const stacks = [ + new BaseNodeStack(app, `integ-${identifier}-base-node`, { + env, + }), + new BasePythonStack(app, `integ-${identifier}-base-python`, { + env, + }), + new BaseJavaStack(app, `integ-${identifier}-base-java`, { + env, + }), + new BaseDotnetStack(app, `integ-${identifier}-base-dotnet`, { + env, + }), +] -new BasePythonStack(app, `integ-${identifier}-base-python`, { - env, -}); +// Tag all stacks so we can easily clean them up +stacks.forEach(stack => stack.addStackTag("extension_integration_test", "true")) app.synth(); diff --git a/integration-tests/lambda/base-dotnet/.gitignore b/integration-tests/lambda/base-dotnet/.gitignore new file mode 100644 index 000000000..56044625e --- /dev/null +++ b/integration-tests/lambda/base-dotnet/.gitignore @@ -0,0 +1,4 @@ +bin/ +obj/ +*.user +*.suo diff --git a/integration-tests/lambda/base-dotnet/Function.cs b/integration-tests/lambda/base-dotnet/Function.cs new file mode 100644 index 000000000..f2ed45b3a --- /dev/null +++ b/integration-tests/lambda/base-dotnet/Function.cs @@ -0,0 +1,19 @@ +using Amazon.Lambda.Core; +using System.Collections.Generic; + +[assembly: LambdaSerializer(typeof(Amazon.Lambda.Serialization.SystemTextJson.DefaultLambdaJsonSerializer))] + +namespace Function +{ + public class Handler + { + public Dictionary FunctionHandler(Dictionary input, ILambdaContext context) + { + context.Logger.LogLine("Hello world!"); + return new Dictionary + { + { "statusCode", 200 } + }; + } + } +} diff --git a/integration-tests/lambda/base-dotnet/Function.csproj b/integration-tests/lambda/base-dotnet/Function.csproj new file mode 100644 index 000000000..64fda299c --- /dev/null +++ b/integration-tests/lambda/base-dotnet/Function.csproj @@ -0,0 +1,14 @@ + + + net8.0 + true + Lambda + true + true + + + + + + + diff --git a/integration-tests/lambda/base-dotnet/build.sh b/integration-tests/lambda/base-dotnet/build.sh new file mode 100755 index 000000000..02d876e2e --- /dev/null +++ b/integration-tests/lambda/base-dotnet/build.sh @@ -0,0 +1,35 @@ +#!/bin/bash +set -e + +echo "Building .NET Lambda with Docker (ARM64)..." + +# Check if Docker is available +if ! command -v docker &> /dev/null; then + echo "Error: Docker is not installed or not in PATH" + echo "Please install Docker: https://docs.docker.com/get-docker/" + exit 1 +fi + +# Get the directory of this script +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +# Clean previous build +rm -rf "$SCRIPT_DIR/bin" "$SCRIPT_DIR/obj" + +# Build and package with Docker using ARM64 platform +docker run --rm --platform linux/arm64 \ + -v "$SCRIPT_DIR":/workspace \ + -w /workspace \ + mcr.microsoft.com/dotnet/sdk:8.0-alpine \ + sh -c "apk add --no-cache zip && \ + dotnet tool install -g Amazon.Lambda.Tools || true && \ + export PATH=\"\$PATH:/root/.dotnet/tools\" && \ + dotnet lambda package -o bin/function.zip --function-architecture arm64" + +if [ -f "$SCRIPT_DIR/bin/function.zip" ]; then + echo "✓ Build complete: bin/function.zip" + ls -lh "$SCRIPT_DIR/bin/function.zip" +else + echo "✗ Build failed: bin/function.zip not found" + exit 1 +fi diff --git a/integration-tests/lambda/base-java/.gitignore b/integration-tests/lambda/base-java/.gitignore new file mode 100644 index 000000000..949faa73a --- /dev/null +++ b/integration-tests/lambda/base-java/.gitignore @@ -0,0 +1,5 @@ +target/ +*.class +.classpath +.project +.settings/ diff --git a/integration-tests/lambda/base-java/build.sh b/integration-tests/lambda/base-java/build.sh new file mode 100755 index 000000000..b599643c7 --- /dev/null +++ b/integration-tests/lambda/base-java/build.sh @@ -0,0 +1,32 @@ +#!/bin/bash +set -e + +echo "Building Java Lambda with Docker (ARM64)..." + +# Check if Docker is available +if ! command -v docker &> /dev/null; then + echo "Error: Docker is not installed or not in PATH" + echo "Please install Docker: https://docs.docker.com/get-docker/" + exit 1 +fi + +# Get the directory of this script +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +# Clean previous build +rm -rf "$SCRIPT_DIR/target" + +# Build with Docker using ARM64 platform +docker run --rm --platform linux/arm64 \ + -v "$SCRIPT_DIR":/workspace \ + -w /workspace \ + maven:3.9-eclipse-temurin-21-alpine \ + mvn clean package + +if [ -f "$SCRIPT_DIR/target/function.jar" ]; then + echo "✓ Build complete: target/function.jar" + ls -lh "$SCRIPT_DIR/target/function.jar" +else + echo "✗ Build failed: target/function.jar not found" + exit 1 +fi diff --git a/integration-tests/lambda/base-java/pom.xml b/integration-tests/lambda/base-java/pom.xml new file mode 100644 index 000000000..e2d601479 --- /dev/null +++ b/integration-tests/lambda/base-java/pom.xml @@ -0,0 +1,50 @@ + + + 4.0.0 + + example + base-java-lambda + 1.0.0 + jar + + Base Java Lambda + Base Java Lambda function for Datadog Extension integration testing + + + 21 + 21 + UTF-8 + + + + + com.amazonaws + aws-lambda-java-core + 1.2.3 + + + + + + + org.apache.maven.plugins + maven-shade-plugin + 3.5.0 + + + package + + shade + + + function + false + + + + + + + diff --git a/integration-tests/lambda/base-java/src/main/java/example/Handler.java b/integration-tests/lambda/base-java/src/main/java/example/Handler.java new file mode 100644 index 000000000..bd0e84c30 --- /dev/null +++ b/integration-tests/lambda/base-java/src/main/java/example/Handler.java @@ -0,0 +1,13 @@ +package example; + +import com.amazonaws.services.lambda.runtime.Context; +import com.amazonaws.services.lambda.runtime.RequestHandler; +import java.util.Map; + +public class Handler implements RequestHandler, Map> { + @Override + public Map handleRequest(Map event, Context context) { + context.getLogger().log("Hello world!"); + return Map.of("statusCode", 200); + } +} diff --git a/integration-tests/lib/stacks/base-dotnet-stack.ts b/integration-tests/lib/stacks/base-dotnet-stack.ts new file mode 100644 index 000000000..18647f596 --- /dev/null +++ b/integration-tests/lib/stacks/base-dotnet-stack.ts @@ -0,0 +1,37 @@ +import * as cdk from 'aws-cdk-lib'; +import * as lambda from 'aws-cdk-lib/aws-lambda'; +import { Construct } from 'constructs'; +import { + createLogGroup, + defaultDatadogEnvVariables, + defaultDatadogSecretPolicy, + getExtensionLayer, + getDotnet8Layer +} from '../util'; + +export class BaseDotnetStack extends cdk.Stack { + constructor(scope: Construct, id: string, props: cdk.StackProps) { + super(scope, id, props); + + const dotnetFunctionName = `${id}-lambda`; + const dotnetFunction = new lambda.Function(this, dotnetFunctionName, { + runtime: lambda.Runtime.DOTNET_8, + architecture: lambda.Architecture.ARM_64, + handler: 'Function::Function.Handler::FunctionHandler', + code: lambda.Code.fromAsset('./lambda/base-dotnet/bin/function.zip'), + functionName: dotnetFunctionName, + timeout: cdk.Duration.seconds(30), + memorySize: 256, + environment: { + ...defaultDatadogEnvVariables, + DD_SERVICE: dotnetFunctionName, + AWS_LAMBDA_EXEC_WRAPPER: '/opt/datadog_wrapper', + }, + logGroup: createLogGroup(this, dotnetFunctionName) + }); + + dotnetFunction.addToRolePolicy(defaultDatadogSecretPolicy); + dotnetFunction.addLayers(getExtensionLayer(this)); + dotnetFunction.addLayers(getDotnet8Layer(this)); + } +} diff --git a/integration-tests/lib/stacks/base-java-stack.ts b/integration-tests/lib/stacks/base-java-stack.ts new file mode 100644 index 000000000..4207cb504 --- /dev/null +++ b/integration-tests/lib/stacks/base-java-stack.ts @@ -0,0 +1,38 @@ +import * as cdk from 'aws-cdk-lib'; +import * as lambda from 'aws-cdk-lib/aws-lambda'; +import { Construct } from 'constructs'; +import { + createLogGroup, + defaultDatadogEnvVariables, + defaultDatadogSecretPolicy, + getExtensionLayer, + getJava21Layer +} from '../util'; + +export class BaseJavaStack extends cdk.Stack { + constructor(scope: Construct, id: string, props: cdk.StackProps) { + super(scope, id, props); + + const javaFunctionName = `${id}-lambda`; + const javaFunction = new lambda.Function(this, javaFunctionName, { + runtime: lambda.Runtime.JAVA_21, + architecture: lambda.Architecture.ARM_64, + handler: 'example.Handler::handleRequest', + code: lambda.Code.fromAsset('./lambda/base-java/target/function.jar'), + functionName: javaFunctionName, + timeout: cdk.Duration.seconds(30), + memorySize: 256, + environment: { + ...defaultDatadogEnvVariables, + DD_SERVICE: javaFunctionName, + AWS_LAMBDA_EXEC_WRAPPER: '/opt/datadog_wrapper', + DD_TRACE_ENABLED: 'true', + }, + logGroup: createLogGroup(this, javaFunctionName) + }); + + javaFunction.addToRolePolicy(defaultDatadogSecretPolicy); + javaFunction.addLayers(getExtensionLayer(this)); + javaFunction.addLayers(getJava21Layer(this)); + } +} diff --git a/integration-tests/lib/util.ts b/integration-tests/lib/util.ts index 0cb3223b8..76e6ed4e7 100644 --- a/integration-tests/lib/util.ts +++ b/integration-tests/lib/util.ts @@ -9,6 +9,8 @@ export const extensionLayerArn = process.env.EXTENSION_LAYER_ARN!; export const node20LayerArn = 'arn:aws:lambda:us-east-1:464622532012:layer:Datadog-Node20-x:130'; export const python313LayerArn = 'arn:aws:lambda:us-east-1:464622532012:layer:Datadog-Python313-ARM:117'; +export const java21LayerArn = 'arn:aws:lambda:us-east-1:464622532012:layer:dd-trace-java:24'; +export const dotnet8LayerArn = 'arn:aws:lambda:us-east-1:464622532012:layer:dd-trace-dotnet-ARM:20'; export const defaultDatadogEnvVariables = { DD_API_KEY_SECRET_ARN: datadogSecretArn, @@ -60,3 +62,19 @@ export const getPython313Layer = (scope: Construct) => { python313LayerArn ); }; + +export const getJava21Layer = (scope: Construct) => { + return LayerVersion.fromLayerVersionArn( + scope, + 'DatadogJava21Layer', + java21LayerArn + ); +}; + +export const getDotnet8Layer = (scope: Construct) => { + return LayerVersion.fromLayerVersionArn( + scope, + 'DatadogDotnet8Layer', + dotnet8LayerArn + ); +}; diff --git a/integration-tests/tests/base-dotnet.test.ts b/integration-tests/tests/base-dotnet.test.ts new file mode 100644 index 000000000..9791c8d45 --- /dev/null +++ b/integration-tests/tests/base-dotnet.test.ts @@ -0,0 +1,52 @@ +import { invokeLambdaAndGetDatadogData, LambdaInvocationDatadogData } from './utils/util'; +import { getIdentifier } from './utils/config'; + +describe('Base .NET Lambda Integration Test', () => { + const DOTNET_FUNCTION_NAME = `integ-${getIdentifier()}-base-dotnet-lambda`; + let result: LambdaInvocationDatadogData; + + beforeAll(async () => { + console.log(`Invoking Lambda function: ${DOTNET_FUNCTION_NAME}`); + result = await invokeLambdaAndGetDatadogData(DOTNET_FUNCTION_NAME, {}, true); + }, 700000); // 11.6 minute timeout + + it('should invoke .NET Lambda successfully', () => { + expect(result.statusCode).toBe(200); + }); + + it('should have "Hello world!" log message', () => { + const helloWorldLog = result.logs?.find((log: any) => + log.message.includes('Hello world!') + ); + expect(helloWorldLog).toBeDefined(); + }); + + it('should send one trace to Datadog', () => { + expect(result.traces?.length).toEqual(1); + }); + + it('should have aws.lambda span with correct properties', () => { + const trace = result.traces![0]; + const awsLambdaSpan = trace.spans.find((span: any) => span.attributes.operation_name === 'aws.lambda'); + expect(awsLambdaSpan).toBeDefined(); + expect(awsLambdaSpan).toMatchObject({ + attributes: { + operation_name: 'aws.lambda', + custom: { + cold_start: 'true' + } + } + }); + }); + + it('should have aws.lambda.cold_start span', () => { + const trace = result.traces![0]; + const awsLambdaColdStartSpan = trace.spans.find((span: any) => span.attributes.operation_name === 'aws.lambda.cold_start'); + expect(awsLambdaColdStartSpan).toBeDefined(); + expect(awsLambdaColdStartSpan).toMatchObject({ + attributes: { + operation_name: 'aws.lambda.cold_start', + } + }); + }); +}); diff --git a/integration-tests/tests/base-java.test.ts b/integration-tests/tests/base-java.test.ts new file mode 100644 index 000000000..bf3bdbaa6 --- /dev/null +++ b/integration-tests/tests/base-java.test.ts @@ -0,0 +1,52 @@ +import { invokeLambdaAndGetDatadogData, LambdaInvocationDatadogData } from './utils/util'; +import { getIdentifier } from './utils/config'; + +describe('Base Java Lambda Integration Test', () => { + const JAVA_FUNCTION_NAME = `integ-${getIdentifier()}-base-java-lambda`; + let result: LambdaInvocationDatadogData; + + beforeAll(async () => { + console.log(`Invoking Lambda function: ${JAVA_FUNCTION_NAME}`); + result = await invokeLambdaAndGetDatadogData(JAVA_FUNCTION_NAME, {}, true); + }, 700000); // 11.6 minute timeout + + it('should invoke Java Lambda successfully', () => { + expect(result.statusCode).toBe(200); + }); + + it('should have "Hello world!" log message', () => { + const helloWorldLog = result.logs?.find((log: any) => + log.message.includes('Hello world!') + ); + expect(helloWorldLog).toBeDefined(); + }); + + it('should send one trace to Datadog', () => { + expect(result.traces?.length).toEqual(1); + }); + + it('should have aws.lambda span with correct properties', () => { + const trace = result.traces![0]; + const awsLambdaSpan = trace.spans.find((span: any) => span.attributes.operation_name === 'aws.lambda'); + expect(awsLambdaSpan).toBeDefined(); + expect(awsLambdaSpan).toMatchObject({ + attributes: { + operation_name: 'aws.lambda', + custom: { + cold_start: 'true' + } + } + }); + }); + + it('should have aws.lambda.cold_start span', () => { + const trace = result.traces![0]; + const awsLambdaColdStartSpan = trace.spans.find((span: any) => span.attributes.operation_name === 'aws.lambda.cold_start'); + expect(awsLambdaColdStartSpan).toBeDefined(); + expect(awsLambdaColdStartSpan).toMatchObject({ + attributes: { + operation_name: 'aws.lambda.cold_start', + } + }); + }); +}); diff --git a/integration-tests/tests/utils/datadog.ts b/integration-tests/tests/utils/datadog.ts index a0d551244..224431188 100644 --- a/integration-tests/tests/utils/datadog.ts +++ b/integration-tests/tests/utils/datadog.ts @@ -165,7 +165,7 @@ export async function getLogs( try { let query = `service:${serviceName}`; if (requestId) { - query += ` ${requestId}`; + query += ` @lambda.request_id:${requestId}`; } console.log(`Searching for logs: ${query}`);