diff --git a/.github/workflows/e2e-go.yml b/.github/workflows/e2e-go.yml new file mode 100644 index 0000000000..00529fe952 --- /dev/null +++ b/.github/workflows/e2e-go.yml @@ -0,0 +1,217 @@ +name: E2E - Go Layer + +on: + workflow_dispatch: + inputs: + logzio_api_url: + description: "Logz.io API base URL (default https://api.logz.io)" + required: false + default: "https://api.logz.io" + aws_region: + description: "AWS Region" + required: false + default: "us-east-1" + +permissions: + contents: read + +env: + AWS_REGION: ${{ inputs.aws_region || 'us-east-1' }} + AWS_DEFAULT_REGION: ${{ inputs.aws_region || 'us-east-1' }} + ARCHITECTURE: amd64 + FUNCTION_NAME: one-layer-e2e-test-go + LAYER_BASE_NAME: otel-go-extension-e2e + SERVICE_NAME: logzio-e2e-go-service + LOGZIO_REGION: us + +jobs: + build-layer: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v5 + + - name: Set up Go (for Collector) + uses: actions/setup-go@v5 + with: + go-version-file: collector/go.mod + + - name: Build combined Go layer (amd64) + run: | + cd go + ARCHITECTURE=${ARCHITECTURE} ./build-combined.sh + + - name: Upload layer artifact + uses: actions/upload-artifact@v4 + with: + name: otel-go-extension-layer.zip + path: go/build/otel-go-extension-layer.zip + + publish-update-invoke: + runs-on: ubuntu-latest + needs: build-layer + outputs: + layer_arn: ${{ steps.publish.outputs.layer_arn }} + e2e_label: ${{ steps.vars.outputs.e2e_label }} + steps: + - name: Checkout + uses: actions/checkout@v5 + + - name: Download layer artifact + uses: actions/download-artifact@v4 + with: + name: otel-go-extension-layer.zip + + - name: Configure AWS (User Credentials) + uses: aws-actions/configure-aws-credentials@v4 + with: + aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} + aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + aws-region: ${{ env.AWS_REGION }} + + - name: Publish layer version + id: publish + shell: bash + run: | + set -euo pipefail + LAYER_NAME="${LAYER_BASE_NAME}-amd64" + ARN=$(aws lambda publish-layer-version \ + --layer-name "$LAYER_NAME" \ + --license-info "Apache-2.0" \ + --compatible-architectures x86_64 \ + --compatible-runtimes provided provided.al2 \ + --zip-file fileb://otel-go-extension-layer.zip \ + --query 'LayerVersionArn' --output text) + echo "layer_arn=$ARN" >> "$GITHUB_OUTPUT" + + - name: Prepare variables + id: vars + run: | + echo "e2e_label=go-e2e-${GITHUB_RUN_ID}" >> "$GITHUB_OUTPUT" + + - name: Check function exists and get current config + run: | + echo "Checking if function exists and its current configuration..." + aws lambda get-function-configuration --function-name "${FUNCTION_NAME}" --query '{Role:Role,KMSKeyArn:KMSKeyArn,State:State,LastUpdateStatus:LastUpdateStatus}' --output table || { + echo "❌ Function ${FUNCTION_NAME} does not exist or is not accessible." + exit 1 + } + + echo "Current environment variables:" + aws lambda get-function-configuration --function-name "${FUNCTION_NAME}" --query 'Environment.Variables' --output json || echo "No environment variables set" + + - name: Update Lambda configuration with current run's layer and env vars + run: | + echo "Updating function configuration with this run's published layer and environment variables..." + aws lambda update-function-configuration \ + --function-name "${FUNCTION_NAME}" \ + --layers "${{ steps.publish.outputs.layer_arn }}" \ + --environment "Variables={OPENTELEMETRY_COLLECTOR_CONFIG_URI=/opt/collector-config/config.e2e.yaml,OTEL_SERVICE_NAME=${SERVICE_NAME},OTEL_TRACES_SAMPLER=always_on,OTEL_RESOURCE_ATTRIBUTES=deployment.environment=${{ steps.vars.outputs.e2e_label }},ENVIRONMENT=${{ steps.vars.outputs.e2e_label }},LOGZIO_REGION=${LOGZIO_REGION},LOGZIO_LOGS_TOKEN=${{ secrets.LOGZIO_LOGS_TOKEN }},LOGZIO_TRACES_TOKEN=${{ secrets.LOGZIO_TRACES_TOKEN }},LOGZIO_METRICS_TOKEN=${{ secrets.LOGZIO_METRICS_TOKEN }}}" + + echo "Waiting for function update to complete..." + aws lambda wait function-updated --function-name "${FUNCTION_NAME}" + + echo "Updated configuration (layers and environment variables):" + aws lambda get-function-configuration --function-name "${FUNCTION_NAME}" --query '{Layers:Layers[].Arn,Environment:Environment.Variables}' --output json + + - name: Invoke function multiple times + run: | + echo "Invoking function first time..." + aws lambda invoke --function-name "${FUNCTION_NAME}" --payload '{}' --cli-binary-format raw-in-base64-out response1.json + echo "First invocation response:" + cat response1.json + echo "" + + echo "Invoking function second time..." + aws lambda invoke --function-name "${FUNCTION_NAME}" --payload '{}' --cli-binary-format raw-in-base64-out response2.json + echo "Second invocation response:" + cat response2.json + echo "" + + echo "Sleeping for 5 seconds before additional invocations..." + sleep 5 + + echo "Invoking function third time..." + aws lambda invoke --function-name "${FUNCTION_NAME}" --payload '{}' --cli-binary-format raw-in-base64-out response3.json + echo "Third invocation response:" + cat response3.json + echo "" + + echo "Invoking function fourth time..." + aws lambda invoke --function-name "${FUNCTION_NAME}" --payload '{}' --cli-binary-format raw-in-base64-out response4.json + echo "Fourth invocation response:" + cat response4.json + echo "" + + echo "Invoking function fifth time..." + aws lambda invoke --function-name "${FUNCTION_NAME}" --payload '{}' --cli-binary-format raw-in-base64-out response5.json + echo "Fifth invocation response:" + cat response5.json + echo "" + + - name: Check CloudWatch logs + run: | + echo "Checking recent CloudWatch logs for the function..." + LOG_GROUP_NAME="/aws/lambda/${FUNCTION_NAME}" + + # Get recent log events (last 5 minutes) + aws logs filter-log-events \ + --log-group-name "$LOG_GROUP_NAME" \ + --start-time $(date -d '5 minutes ago' +%s)000 \ + --query 'events[].message' \ + --output text || { + echo "❌ Could not fetch CloudWatch logs. Log group might not exist or no recent logs." + echo "Checking if log group exists..." + aws logs describe-log-groups --log-group-name-prefix "$LOG_GROUP_NAME" --query 'logGroups[].logGroupName' --output text + } + + verify-e2e: + runs-on: ubuntu-latest + needs: publish-update-invoke + steps: + - name: Checkout + uses: actions/checkout@v5 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: '1.21' + + - name: Run E2E verification tests + working-directory: e2e_tests + env: + LOGZIO_API_KEY: ${{ secrets.LOGZIO_API_KEY }} + LOGZIO_API_URL: ${{ inputs.logzio_api_url || 'https://api.logz.io' }} + LOGZIO_API_METRICS_KEY: ${{ secrets.LOGZIO_API_METRICS_KEY }} + LOGZIO_METRICS_QUERY_URL: ${{ inputs.logzio_api_url || 'https://api.logz.io' }} + LOGZIO_API_TRACES_KEY: ${{ secrets.LOGZIO_API_TRACES_KEY }} + E2E_TEST_ENVIRONMENT_LABEL: ${{ needs.publish-update-invoke.outputs.e2e_label }} + EXPECTED_LAMBDA_FUNCTION_NAME: one-layer-e2e-test-go + EXPECTED_SERVICE_NAME: ${{ env.SERVICE_NAME }} + GITHUB_RUN_ID: ${{ github.run_id }} + AWS_REGION: ${{ env.AWS_REGION }} + run: | + go mod tidy + go test ./... -v -tags=e2e -run TestE2ERunner + + cleanup: + if: always() + runs-on: ubuntu-latest + needs: [publish-update-invoke, verify-e2e] + steps: + - name: Configure AWS (User Credentials) + uses: aws-actions/configure-aws-credentials@v4 + with: + aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} + aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + aws-region: ${{ inputs.aws_region || 'us-east-1' }} + - name: Delete published layer version + if: ${{ needs.publish-update-invoke.outputs.layer_arn != '' }} + shell: bash + run: | + ARN="${{ needs.publish-update-invoke.outputs.layer_arn }}" + LAYER_NAME=$(echo "$ARN" | cut -d: -f7) + LAYER_VERSION=$(echo "$ARN" | cut -d: -f8) + aws lambda delete-layer-version --layer-name "$LAYER_NAME" --version-number "$LAYER_VERSION" || echo "Failed to delete layer version." + + diff --git a/.github/workflows/e2e-java.yml b/.github/workflows/e2e-java.yml new file mode 100644 index 0000000000..a9f274ca7e --- /dev/null +++ b/.github/workflows/e2e-java.yml @@ -0,0 +1,226 @@ +name: E2E - Java Layer + +on: + workflow_dispatch: + inputs: + logzio_api_url: + description: "Logz.io API base URL (default https://api.logz.io)" + required: false + default: "https://api.logz.io" + aws_region: + description: "AWS Region" + required: false + default: "us-east-1" + + +permissions: + contents: read + +env: + AWS_REGION: ${{ inputs.aws_region || 'us-east-1' }} + AWS_DEFAULT_REGION: ${{ inputs.aws_region || 'us-east-1' }} + ARCHITECTURE: amd64 + FUNCTION_NAME: one-layer-e2e-test-java + LAYER_BASE_NAME: otel-java-extension-e2e + SERVICE_NAME: logzio-e2e-java-service + LOGZIO_REGION: us + +jobs: + build-layer: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v5 + + - name: Set up Go (for Collector) + uses: actions/setup-go@v5 + with: + go-version-file: collector/go.mod + + - name: Set up Java + uses: actions/setup-java@v4 + with: + distribution: 'temurin' + java-version: '21' + cache: 'gradle' + + - name: Build combined Java layer (amd64) + run: | + cd java + ARCHITECTURE=${ARCHITECTURE} ./build-combined.sh + + - name: Upload layer artifact + uses: actions/upload-artifact@v4 + with: + name: otel-java-extension-layer.zip + path: java/build/otel-java-extension-layer-${{ env.ARCHITECTURE }}.zip + + publish-update-invoke: + runs-on: ubuntu-latest + needs: build-layer + outputs: + layer_arn: ${{ steps.publish.outputs.layer_arn }} + e2e_label: ${{ steps.vars.outputs.e2e_label }} + steps: + - name: Checkout + uses: actions/checkout@v5 + + - name: Download layer artifact + uses: actions/download-artifact@v4 + with: + name: otel-java-extension-layer.zip + + - name: Configure AWS (User Credentials) + uses: aws-actions/configure-aws-credentials@v4 + with: + aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} + aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + aws-region: ${{ env.AWS_REGION }} + + - name: Publish layer version + id: publish + shell: bash + run: | + set -euo pipefail + LAYER_NAME="${LAYER_BASE_NAME}-amd64" + ZIP_FILE="otel-java-extension-layer-${ARCHITECTURE}.zip" + ARN=$(aws lambda publish-layer-version \ + --layer-name "$LAYER_NAME" \ + --license-info "Apache-2.0" \ + --compatible-architectures x86_64 \ + --compatible-runtimes java11 java17 java21 \ + --zip-file fileb://$ZIP_FILE \ + --query 'LayerVersionArn' --output text) + echo "layer_arn=$ARN" >> "$GITHUB_OUTPUT" + + - name: Prepare variables + id: vars + run: | + echo "e2e_label=java-e2e-${GITHUB_RUN_ID}" >> "$GITHUB_OUTPUT" + + - name: Check function exists and get current config + run: | + echo "Checking if function exists and its current configuration..." + aws lambda get-function-configuration --function-name "${FUNCTION_NAME}" --query '{Role:Role,KMSKeyArn:KMSKeyArn,State:State,LastUpdateStatus:LastUpdateStatus}' --output table || { + echo "❌ Function ${FUNCTION_NAME} does not exist or is not accessible." + exit 1 + } + + echo "Current environment variables:" + aws lambda get-function-configuration --function-name "${FUNCTION_NAME}" --query 'Environment.Variables' --output json || echo "No environment variables set" + + - name: Update Lambda configuration + run: | + echo "Updating function configuration..." + aws lambda update-function-configuration \ + --function-name "${FUNCTION_NAME}" \ + --layers "${{ steps.publish.outputs.layer_arn }}" \ + --environment "Variables={AWS_LAMBDA_EXEC_WRAPPER=/opt/otel-handler,OPENTELEMETRY_COLLECTOR_CONFIG_URI=/opt/collector-config/config.e2e.yaml,JAVA_TOOL_OPTIONS=-javaagent:/opt/opentelemetry-javaagent.jar,OTEL_SERVICE_NAME=${SERVICE_NAME},OTEL_TRACES_SAMPLER=always_on,OTEL_TRACES_EXPORTER=otlp,OTEL_METRICS_EXPORTER=otlp,OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf,OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318,OTEL_RESOURCE_ATTRIBUTES=deployment.environment=${{ steps.vars.outputs.e2e_label }},ENVIRONMENT=${{ steps.vars.outputs.e2e_label }},LOGZIO_REGION=${LOGZIO_REGION},LOGZIO_LOGS_TOKEN=${{ secrets.LOGZIO_LOGS_TOKEN }},LOGZIO_TRACES_TOKEN=${{ secrets.LOGZIO_TRACES_TOKEN }},LOGZIO_METRICS_TOKEN=${{ secrets.LOGZIO_METRICS_TOKEN }}}" + + echo "Waiting for function update to complete..." + aws lambda wait function-updated --function-name "${FUNCTION_NAME}" + + echo "Updated configuration:" + aws lambda get-function-configuration --function-name "${FUNCTION_NAME}" --query '{Layers:Layers[].Arn,Environment:Environment.Variables}' --output json + + - name: Invoke function multiple times + run: | + echo "Invoking function first time..." + aws lambda invoke --function-name "${FUNCTION_NAME}" --payload '{}' --cli-binary-format raw-in-base64-out response1.json + echo "First invocation response:" + cat response1.json + echo "" + + echo "Invoking function second time..." + aws lambda invoke --function-name "${FUNCTION_NAME}" --payload '{}' --cli-binary-format raw-in-base64-out response2.json + echo "Second invocation response:" + cat response2.json + echo "" + + echo "Sleeping for 5 seconds before additional invocations..." + sleep 5 + + echo "Invoking function third time..." + aws lambda invoke --function-name "${FUNCTION_NAME}" --payload '{}' --cli-binary-format raw-in-base64-out response3.json + echo "Third invocation response:" + cat response3.json + echo "" + + echo "Invoking function fourth time..." + aws lambda invoke --function-name "${FUNCTION_NAME}" --payload '{}' --cli-binary-format raw-in-base64-out response4.json + echo "Fourth invocation response:" + cat response4.json + echo "" + + echo "Invoking function fifth time..." + aws lambda invoke --function-name "${FUNCTION_NAME}" --payload '{}' --cli-binary-format raw-in-base64-out response5.json + echo "Fifth invocation response:" + cat response5.json + echo "" + + - name: Check CloudWatch logs + run: | + echo "Checking recent CloudWatch logs for the function..." + LOG_GROUP_NAME="/aws/lambda/${FUNCTION_NAME}" + + # Get recent log events (last 5 minutes) + aws logs filter-log-events \ + --log-group-name "$LOG_GROUP_NAME" \ + --start-time $(date -d '5 minutes ago' +%s)000 \ + --query 'events[].message' \ + --output text || { + echo "❌ Could not fetch CloudWatch logs. Log group might not exist or no recent logs." + echo "Checking if log group exists..." + aws logs describe-log-groups --log-group-name-prefix "$LOG_GROUP_NAME" --query 'logGroups[].logGroupName' --output text + } + + verify-e2e: + runs-on: ubuntu-latest + needs: publish-update-invoke + steps: + - name: Checkout + uses: actions/checkout@v5 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: '1.21' + + - name: Run E2E verification tests + working-directory: e2e_tests + env: + LOGZIO_API_KEY: ${{ secrets.LOGZIO_API_KEY }} + LOGZIO_API_URL: ${{ inputs.logzio_api_url || 'https://api.logz.io' }} + LOGZIO_API_METRICS_KEY: ${{ secrets.LOGZIO_API_METRICS_KEY }} + LOGZIO_METRICS_QUERY_URL: ${{ inputs.logzio_api_url || 'https://api.logz.io' }} + LOGZIO_API_TRACES_KEY: ${{ secrets.LOGZIO_API_TRACES_KEY }} + E2E_TEST_ENVIRONMENT_LABEL: ${{ needs.publish-update-invoke.outputs.e2e_label }} + EXPECTED_LAMBDA_FUNCTION_NAME: one-layer-e2e-test-java + EXPECTED_SERVICE_NAME: ${{ env.SERVICE_NAME }} + GITHUB_RUN_ID: ${{ github.run_id }} + AWS_REGION: ${{ env.AWS_REGION }} + run: | + go mod tidy + go test ./... -v -tags=e2e -run TestE2ERunner + + cleanup: + if: always() + runs-on: ubuntu-latest + needs: [publish-update-invoke, verify-e2e] + steps: + - name: Configure AWS (User Credentials) + uses: aws-actions/configure-aws-credentials@v4 + with: + aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} + aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + aws-region: ${{ inputs.aws_region || 'us-east-1' }} + - name: Delete published layer version + if: ${{ needs.publish-update-invoke.outputs.layer_arn != '' }} + shell: bash + run: | + ARN="${{ needs.publish-update-invoke.outputs.layer_arn }}" + LAYER_NAME=$(echo "$ARN" | cut -d: -f7) + LAYER_VERSION=$(echo "$ARN" | cut -d: -f8) + aws lambda delete-layer-version --layer-name "$LAYER_NAME" --version-number "$LAYER_VERSION" || echo "Failed to delete layer version." + + diff --git a/.github/workflows/e2e-nodejs.yml b/.github/workflows/e2e-nodejs.yml new file mode 100644 index 0000000000..39c8c0e50b --- /dev/null +++ b/.github/workflows/e2e-nodejs.yml @@ -0,0 +1,222 @@ +name: E2E - Node.js Layer + +on: + workflow_dispatch: + inputs: + logzio_api_url: + description: "Logz.io API base URL (default https://api.logz.io)" + required: false + default: "https://api.logz.io" + aws_region: + description: "AWS Region" + required: false + default: "us-east-1" + +permissions: + contents: read + +env: + AWS_REGION: ${{ inputs.aws_region || 'us-east-1' }} + AWS_DEFAULT_REGION: ${{ inputs.aws_region || 'us-east-1' }} + ARCHITECTURE: amd64 + FUNCTION_NAME: one-layer-e2e-test-nodejs + LAYER_BASE_NAME: otel-nodejs-extension-e2e + SERVICE_NAME: logzio-e2e-nodejs-service + LOGZIO_REGION: us + +jobs: + build-layer: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v5 + + - name: Set up Go (for Collector) + uses: actions/setup-go@v5 + with: + go-version-file: collector/go.mod + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: '22' + + - name: Build combined Node.js layer (amd64) + run: | + cd nodejs/packages/layer + ARCHITECTURE=${ARCHITECTURE} ./build-combined.sh + + - name: Upload layer artifact + uses: actions/upload-artifact@v4 + with: + name: otel-nodejs-extension-layer.zip + path: nodejs/packages/layer/build/otel-nodejs-extension-layer.zip + + publish-update-invoke: + runs-on: ubuntu-latest + needs: build-layer + outputs: + layer_arn: ${{ steps.publish.outputs.layer_arn }} + e2e_label: ${{ steps.vars.outputs.e2e_label }} + steps: + - name: Checkout + uses: actions/checkout@v5 + + - name: Download layer artifact + uses: actions/download-artifact@v4 + with: + name: otel-nodejs-extension-layer.zip + + - name: Configure AWS (User Credentials) + uses: aws-actions/configure-aws-credentials@v4 + with: + aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} + aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + aws-region: ${{ env.AWS_REGION }} + + - name: Publish layer version + id: publish + shell: bash + run: | + set -euo pipefail + LAYER_NAME="${LAYER_BASE_NAME}-amd64" + ARN=$(aws lambda publish-layer-version \ + --layer-name "$LAYER_NAME" \ + --license-info "Apache-2.0" \ + --compatible-architectures x86_64 \ + --compatible-runtimes nodejs18.x nodejs20.x nodejs22.x \ + --zip-file fileb://otel-nodejs-extension-layer.zip \ + --query 'LayerVersionArn' --output text) + echo "layer_arn=$ARN" >> "$GITHUB_OUTPUT" + + - name: Prepare variables + id: vars + run: | + echo "e2e_label=nodejs-e2e-${GITHUB_RUN_ID}" >> "$GITHUB_OUTPUT" + + - name: Check function exists and get current config + run: | + echo "Checking if function exists and its current configuration..." + aws lambda get-function-configuration --function-name "${FUNCTION_NAME}" --query '{Role:Role,KMSKeyArn:KMSKeyArn,State:State,LastUpdateStatus:LastUpdateStatus}' --output table || { + echo "❌ Function ${FUNCTION_NAME} does not exist or is not accessible." + exit 1 + } + + echo "Current environment variables:" + aws lambda get-function-configuration --function-name "${FUNCTION_NAME}" --query 'Environment.Variables' --output json || echo "No environment variables set" + + - name: Update Lambda configuration + run: | + echo "Updating function configuration..." + aws lambda update-function-configuration \ + --function-name "${FUNCTION_NAME}" \ + --layers "${{ steps.publish.outputs.layer_arn }}" \ + --environment "Variables={AWS_LAMBDA_EXEC_WRAPPER=/opt/otel-handler,OPENTELEMETRY_COLLECTOR_CONFIG_URI=/opt/collector-config/config.e2e.yaml,OTEL_NODE_ENABLED_INSTRUMENTATIONS='http,undici',OTEL_SERVICE_NAME=${SERVICE_NAME},OTEL_TRACES_SAMPLER=always_on,OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf,OTEL_RESOURCE_ATTRIBUTES=deployment.environment=${{ steps.vars.outputs.e2e_label }},ENVIRONMENT=${{ steps.vars.outputs.e2e_label }},LOGZIO_REGION=${LOGZIO_REGION},LOGZIO_LOGS_TOKEN=${{ secrets.LOGZIO_LOGS_TOKEN }},LOGZIO_TRACES_TOKEN=${{ secrets.LOGZIO_TRACES_TOKEN }},LOGZIO_METRICS_TOKEN=${{ secrets.LOGZIO_METRICS_TOKEN }}}" + + echo "Waiting for function update to complete..." + aws lambda wait function-updated --function-name "${FUNCTION_NAME}" + + echo "Updated configuration:" + aws lambda get-function-configuration --function-name "${FUNCTION_NAME}" --query '{Layers:Layers[].Arn,Environment:Environment.Variables}' --output json + + - name: Invoke function multiple times + run: | + echo "Invoking function first time..." + aws lambda invoke --function-name "${FUNCTION_NAME}" --payload '{}' --cli-binary-format raw-in-base64-out response1.json + echo "First invocation response:" + cat response1.json + echo "" + + echo "Invoking function second time..." + aws lambda invoke --function-name "${FUNCTION_NAME}" --payload '{}' --cli-binary-format raw-in-base64-out response2.json + echo "Second invocation response:" + cat response2.json + echo "" + + echo "Sleeping for 5 seconds before additional invocations..." + sleep 5 + + echo "Invoking function third time..." + aws lambda invoke --function-name "${FUNCTION_NAME}" --payload '{}' --cli-binary-format raw-in-base64-out response3.json + echo "Third invocation response:" + cat response3.json + echo "" + + echo "Invoking function fourth time..." + aws lambda invoke --function-name "${FUNCTION_NAME}" --payload '{}' --cli-binary-format raw-in-base64-out response4.json + echo "Fourth invocation response:" + cat response4.json + echo "" + + echo "Invoking function fifth time..." + aws lambda invoke --function-name "${FUNCTION_NAME}" --payload '{}' --cli-binary-format raw-in-base64-out response5.json + echo "Fifth invocation response:" + cat response5.json + echo "" + + - name: Check CloudWatch logs + run: | + echo "Checking recent CloudWatch logs for the function..." + LOG_GROUP_NAME="/aws/lambda/${FUNCTION_NAME}" + + # Get recent log events (last 5 minutes) + aws logs filter-log-events \ + --log-group-name "$LOG_GROUP_NAME" \ + --start-time $(date -d '5 minutes ago' +%s)000 \ + --query 'events[].message' \ + --output text || { + echo "❌ Could not fetch CloudWatch logs. Log group might not exist or no recent logs." + echo "Checking if log group exists..." + aws logs describe-log-groups --log-group-name-prefix "$LOG_GROUP_NAME" --query 'logGroups[].logGroupName' --output text + } + + verify-e2e: + runs-on: ubuntu-latest + needs: publish-update-invoke + steps: + - name: Checkout + uses: actions/checkout@v5 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: '1.21' + + - name: Run E2E verification tests + working-directory: e2e_tests + env: + LOGZIO_API_KEY: ${{ secrets.LOGZIO_API_KEY }} + LOGZIO_API_URL: ${{ inputs.logzio_api_url || 'https://api.logz.io' }} + LOGZIO_API_METRICS_KEY: ${{ secrets.LOGZIO_API_METRICS_KEY }} + LOGZIO_METRICS_QUERY_URL: ${{ inputs.logzio_api_url || 'https://api.logz.io' }} + LOGZIO_API_TRACES_KEY: ${{ secrets.LOGZIO_API_TRACES_KEY }} + E2E_TEST_ENVIRONMENT_LABEL: ${{ needs.publish-update-invoke.outputs.e2e_label }} + EXPECTED_LAMBDA_FUNCTION_NAME: one-layer-e2e-test-nodejs + EXPECTED_SERVICE_NAME: ${{ env.SERVICE_NAME }} + GITHUB_RUN_ID: ${{ github.run_id }} + AWS_REGION: ${{ env.AWS_REGION }} + run: | + go mod tidy + go test ./... -v -tags=e2e -run TestE2ERunner + + cleanup: + if: always() + runs-on: ubuntu-latest + needs: [publish-update-invoke, verify-e2e] + steps: + - name: Configure AWS (User Credentials) + uses: aws-actions/configure-aws-credentials@v4 + with: + aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} + aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + aws-region: ${{ inputs.aws_region || 'us-east-1' }} + - name: Delete published layer version + if: ${{ needs.publish-update-invoke.outputs.layer_arn != '' }} + shell: bash + run: | + ARN="${{ needs.publish-update-invoke.outputs.layer_arn }}" + LAYER_NAME=$(echo "$ARN" | cut -d: -f7) + LAYER_VERSION=$(echo "$ARN" | cut -d: -f8) + aws lambda delete-layer-version --layer-name "$LAYER_NAME" --version-number "$LAYER_VERSION" || echo "Failed to delete layer version." + + diff --git a/.github/workflows/e2e-python.yml b/.github/workflows/e2e-python.yml new file mode 100644 index 0000000000..418990983c --- /dev/null +++ b/.github/workflows/e2e-python.yml @@ -0,0 +1,222 @@ +name: E2E - Python Layer + +on: + workflow_dispatch: + inputs: + logzio_api_url: + description: "Logz.io API base URL (default https://api.logz.io)" + required: false + default: "https://api.logz.io" + aws_region: + description: "AWS Region" + required: false + default: "us-east-1" + + +permissions: + contents: read + +env: + AWS_REGION: ${{ inputs.aws_region || 'us-east-1' }} + AWS_DEFAULT_REGION: ${{ inputs.aws_region || 'us-east-1' }} + ARCHITECTURE: amd64 + FUNCTION_NAME: one-layer-e2e-test-python + LAYER_BASE_NAME: otel-python-extension-e2e + SERVICE_NAME: logzio-e2e-python-service + LOGZIO_REGION: us + +jobs: + build-layer: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v5 + + - name: Set up Go (for Collector) + uses: actions/setup-go@v5 + with: + go-version-file: collector/go.mod + + - name: Set up Docker + uses: crazy-max/ghaction-setup-docker@v3 + + - name: Build combined Python layer (amd64) + run: | + cd python/src + ARCHITECTURE=${ARCHITECTURE} ./build-combined.sh + + - name: Upload layer artifact + uses: actions/upload-artifact@v4 + with: + name: otel-python-extension-layer.zip + path: python/src/build/otel-python-extension-layer.zip + + publish-update-invoke: + runs-on: ubuntu-latest + needs: build-layer + outputs: + layer_arn: ${{ steps.publish.outputs.layer_arn }} + e2e_label: ${{ steps.vars.outputs.e2e_label }} + steps: + - name: Checkout + uses: actions/checkout@v5 + + - name: Download layer artifact + uses: actions/download-artifact@v4 + with: + name: otel-python-extension-layer.zip + + - name: Configure AWS (User Credentials) + uses: aws-actions/configure-aws-credentials@v4 + with: + aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} + aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + aws-region: ${{ env.AWS_REGION }} + + - name: Publish layer version + id: publish + shell: bash + run: | + set -euo pipefail + LAYER_NAME="${LAYER_BASE_NAME}-amd64" + ARN=$(aws lambda publish-layer-version \ + --layer-name "$LAYER_NAME" \ + --license-info "Apache-2.0" \ + --compatible-architectures x86_64 \ + --compatible-runtimes python3.9 python3.10 python3.11 python3.12 python3.13 \ + --zip-file fileb://otel-python-extension-layer.zip \ + --query 'LayerVersionArn' --output text) + echo "layer_arn=$ARN" >> "$GITHUB_OUTPUT" + + - name: Prepare variables + id: vars + run: | + echo "e2e_label=python-e2e-${GITHUB_RUN_ID}" >> "$GITHUB_OUTPUT" + + - name: Check function exists and get current config + run: | + echo "Checking if function exists and its current configuration..." + aws lambda get-function-configuration --function-name "${FUNCTION_NAME}" --query '{Role:Role,KMSKeyArn:KMSKeyArn,State:State,LastUpdateStatus:LastUpdateStatus}' --output table || { + echo "❌ Function ${FUNCTION_NAME} does not exist or is not accessible." + exit 1 + } + + + echo "Current environment variables:" + aws lambda get-function-configuration --function-name "${FUNCTION_NAME}" --query 'Environment.Variables' --output json || echo "No environment variables set" + + - name: Update Lambda configuration + run: | + echo "Updating function configuration..." + aws lambda update-function-configuration \ + --function-name "${FUNCTION_NAME}" \ + --layers "${{ steps.publish.outputs.layer_arn }}" \ + --environment "Variables={AWS_LAMBDA_EXEC_WRAPPER=/opt/otel-handler,OPENTELEMETRY_COLLECTOR_CONFIG_URI=/opt/collector-config/config.e2e.yaml,OTEL_SERVICE_NAME=${SERVICE_NAME},OTEL_TRACES_SAMPLER=always_on,OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf,OTEL_RESOURCE_ATTRIBUTES=deployment.environment=${{ steps.vars.outputs.e2e_label }},ENVIRONMENT=${{ steps.vars.outputs.e2e_label }},LOGZIO_REGION=${LOGZIO_REGION},LOGZIO_LOGS_TOKEN=${{ secrets.LOGZIO_LOGS_TOKEN }},LOGZIO_TRACES_TOKEN=${{ secrets.LOGZIO_TRACES_TOKEN }},LOGZIO_METRICS_TOKEN=${{ secrets.LOGZIO_METRICS_TOKEN }}}" + + echo "Waiting for function update to complete..." + aws lambda wait function-updated --function-name "${FUNCTION_NAME}" + + echo "Updated configuration:" + aws lambda get-function-configuration --function-name "${FUNCTION_NAME}" --query '{Layers:Layers[].Arn,Environment:Environment.Variables}' --output json + + - name: Invoke function multiple times + run: | + echo "Invoking function first time..." + aws lambda invoke --function-name "${FUNCTION_NAME}" --payload '{}' --cli-binary-format raw-in-base64-out response1.json + echo "First invocation response:" + cat response1.json + echo "" + + echo "Invoking function second time..." + aws lambda invoke --function-name "${FUNCTION_NAME}" --payload '{}' --cli-binary-format raw-in-base64-out response2.json + echo "Second invocation response:" + cat response2.json + echo "" + + echo "Sleeping for 5 seconds before additional invocations..." + sleep 5 + + echo "Invoking function third time..." + aws lambda invoke --function-name "${FUNCTION_NAME}" --payload '{}' --cli-binary-format raw-in-base64-out response3.json + echo "Third invocation response:" + cat response3.json + echo "" + + echo "Invoking function fourth time..." + aws lambda invoke --function-name "${FUNCTION_NAME}" --payload '{}' --cli-binary-format raw-in-base64-out response4.json + echo "Fourth invocation response:" + cat response4.json + echo "" + + echo "Invoking function fifth time..." + aws lambda invoke --function-name "${FUNCTION_NAME}" --payload '{}' --cli-binary-format raw-in-base64-out response5.json + echo "Fifth invocation response:" + cat response5.json + echo "" + + - name: Check CloudWatch logs + run: | + echo "Checking recent CloudWatch logs for the function..." + LOG_GROUP_NAME="/aws/lambda/${FUNCTION_NAME}" + + # Get recent log events (last 5 minutes) + aws logs filter-log-events \ + --log-group-name "$LOG_GROUP_NAME" \ + --start-time $(date -d '5 minutes ago' +%s)000 \ + --query 'events[].message' \ + --output text || { + echo "❌ Could not fetch CloudWatch logs. Log group might not exist or no recent logs." + echo "Checking if log group exists..." + aws logs describe-log-groups --log-group-name-prefix "$LOG_GROUP_NAME" --query 'logGroups[].logGroupName' --output text + } + + verify-e2e: + runs-on: ubuntu-latest + needs: publish-update-invoke + steps: + - name: Checkout + uses: actions/checkout@v5 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: '1.21' + + - name: Run E2E verification tests + working-directory: e2e_tests + env: + LOGZIO_API_KEY: ${{ secrets.LOGZIO_API_KEY }} + LOGZIO_API_URL: ${{ inputs.logzio_api_url || 'https://api.logz.io' }} + LOGZIO_API_METRICS_KEY: ${{ secrets.LOGZIO_API_METRICS_KEY }} + LOGZIO_METRICS_QUERY_URL: ${{ inputs.logzio_api_url || 'https://api.logz.io' }} + LOGZIO_API_TRACES_KEY: ${{ secrets.LOGZIO_API_TRACES_KEY }} + E2E_TEST_ENVIRONMENT_LABEL: ${{ needs.publish-update-invoke.outputs.e2e_label }} + EXPECTED_LAMBDA_FUNCTION_NAME: one-layer-e2e-test-python + EXPECTED_SERVICE_NAME: ${{ env.SERVICE_NAME }} + GITHUB_RUN_ID: ${{ github.run_id }} + AWS_REGION: ${{ env.AWS_REGION }} + run: | + go mod tidy + go test ./... -v -tags=e2e -run TestE2ERunner + + cleanup: + if: always() + runs-on: ubuntu-latest + needs: [publish-update-invoke, verify-e2e] + steps: + - name: Configure AWS (User Credentials) + uses: aws-actions/configure-aws-credentials@v4 + with: + aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} + aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + aws-region: ${{ inputs.aws_region || 'us-east-1' }} + - name: Delete published layer version + if: ${{ needs.publish-update-invoke.outputs.layer_arn != '' }} + shell: bash + run: | + ARN="${{ needs.publish-update-invoke.outputs.layer_arn }}" + LAYER_NAME=$(echo "$ARN" | cut -d: -f7) + LAYER_VERSION=$(echo "$ARN" | cut -d: -f8) + aws lambda delete-layer-version --layer-name "$LAYER_NAME" --version-number "$LAYER_VERSION" || echo "Failed to delete layer version." + + diff --git a/.github/workflows/e2e-ruby.yml b/.github/workflows/e2e-ruby.yml new file mode 100644 index 0000000000..c4fe99c462 --- /dev/null +++ b/.github/workflows/e2e-ruby.yml @@ -0,0 +1,224 @@ +name: E2E - Ruby Layer + +on: + workflow_dispatch: + inputs: + logzio_api_url: + description: "Logz.io API base URL (default https://api.logz.io)" + required: false + default: "https://api.logz.io" + aws_region: + description: "AWS Region" + required: false + default: "us-east-1" + +permissions: + contents: read + +env: + AWS_REGION: ${{ inputs.aws_region || 'us-east-1' }} + AWS_DEFAULT_REGION: ${{ inputs.aws_region || 'us-east-1' }} + ARCHITECTURE: amd64 + FUNCTION_NAME: one-layer-e2e-test-ruby + LAYER_BASE_NAME: otel-ruby-extension-e2e + SERVICE_NAME: logzio-e2e-ruby-service + LOGZIO_REGION: us + +jobs: + build-layer: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v5 + + - name: Set up Go (for Collector) + uses: actions/setup-go@v5 + with: + go-version-file: collector/go.mod + + - name: Set up Docker + uses: crazy-max/ghaction-setup-docker@v3 + + - name: Build combined Ruby layer (amd64) + run: | + cd ruby + KEEP_RUBY_GEM_VERSIONS=3.4.0 ARCHITECTURE=${ARCHITECTURE} ./build-combined.sh + + - name: Show layer artifact size + run: | + ls -lh ruby/build/otel-ruby-extension-layer.zip || true + + - name: Upload layer artifact + uses: actions/upload-artifact@v4 + with: + name: otel-ruby-extension-layer.zip + path: ruby/build/otel-ruby-extension-layer.zip + + publish-update-invoke: + runs-on: ubuntu-latest + needs: build-layer + outputs: + layer_arn: ${{ steps.publish.outputs.layer_arn }} + e2e_label: ${{ steps.vars.outputs.e2e_label }} + steps: + - name: Checkout + uses: actions/checkout@v5 + + - name: Download layer artifact + uses: actions/download-artifact@v4 + with: + name: otel-ruby-extension-layer.zip + + - name: Configure AWS (User Credentials) + uses: aws-actions/configure-aws-credentials@v4 + with: + aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} + aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + aws-region: ${{ env.AWS_REGION }} + + - name: Publish layer version + id: publish + shell: bash + run: | + set -euo pipefail + LAYER_NAME="${LAYER_BASE_NAME}-amd64" + ARN=$(aws lambda publish-layer-version \ + --layer-name "$LAYER_NAME" \ + --license-info "Apache-2.0" \ + --compatible-architectures x86_64 \ + --compatible-runtimes ruby3.4 \ + --zip-file fileb://otel-ruby-extension-layer.zip \ + --query 'LayerVersionArn' --output text) + echo "layer_arn=$ARN" >> "$GITHUB_OUTPUT" + + - name: Prepare variables + id: vars + run: | + echo "e2e_label=ruby-e2e-${GITHUB_RUN_ID}" >> "$GITHUB_OUTPUT" + + - name: Check function exists and get current config + run: | + echo "Checking if function exists and its current configuration..." + aws lambda get-function-configuration --function-name "${FUNCTION_NAME}" --query '{Role:Role,KMSKeyArn:KMSKeyArn,State:State,LastUpdateStatus:LastUpdateStatus}' --output table || { + echo "❌ Function ${FUNCTION_NAME} does not exist or is not accessible." + exit 1 + } + + echo "Current environment variables:" + aws lambda get-function-configuration --function-name "${FUNCTION_NAME}" --query 'Environment.Variables' --output json || echo "No environment variables set" + + - name: Update Lambda configuration + run: | + echo "Updating function configuration..." + aws lambda update-function-configuration \ + --function-name "${FUNCTION_NAME}" \ + --layers "${{ steps.publish.outputs.layer_arn }}" \ + --environment "Variables={AWS_LAMBDA_EXEC_WRAPPER=/opt/otel-handler,OPENTELEMETRY_COLLECTOR_CONFIG_URI=/opt/collector-config/config.e2e.yaml,OTEL_SERVICE_NAME=${SERVICE_NAME},OTEL_TRACES_SAMPLER=always_on,OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf,OTEL_RUBY_INSTRUMENTATION_NET_HTTP_ENABLED=true,OTEL_RESOURCE_ATTRIBUTES=deployment.environment=${{ steps.vars.outputs.e2e_label }},ENVIRONMENT=${{ steps.vars.outputs.e2e_label }},LOGZIO_REGION=${LOGZIO_REGION},LOGZIO_LOGS_TOKEN=${{ secrets.LOGZIO_LOGS_TOKEN }},LOGZIO_TRACES_TOKEN=${{ secrets.LOGZIO_TRACES_TOKEN }},LOGZIO_METRICS_TOKEN=${{ secrets.LOGZIO_METRICS_TOKEN }}}" + + echo "Waiting for function update to complete..." + aws lambda wait function-updated --function-name "${FUNCTION_NAME}" + + echo "Updated configuration:" + aws lambda get-function-configuration --function-name "${FUNCTION_NAME}" --query '{Layers:Layers[].Arn,Environment:Environment.Variables}' --output json + + - name: Invoke function multiple times + run: | + echo "Invoking function first time..." + aws lambda invoke --function-name "${FUNCTION_NAME}" --payload '{}' --cli-binary-format raw-in-base64-out response1.json + echo "First invocation response:" + cat response1.json + echo "" + + echo "Invoking function second time..." + aws lambda invoke --function-name "${FUNCTION_NAME}" --payload '{}' --cli-binary-format raw-in-base64-out response2.json + echo "Second invocation response:" + cat response2.json + echo "" + + echo "Sleeping for 5 seconds before additional invocations..." + sleep 5 + + echo "Invoking function third time..." + aws lambda invoke --function-name "${FUNCTION_NAME}" --payload '{}' --cli-binary-format raw-in-base64-out response3.json + echo "Third invocation response:" + cat response3.json + echo "" + + echo "Invoking function fourth time..." + aws lambda invoke --function-name "${FUNCTION_NAME}" --payload '{}' --cli-binary-format raw-in-base64-out response4.json + echo "Fourth invocation response:" + cat response4.json + echo "" + + echo "Invoking function fifth time..." + aws lambda invoke --function-name "${FUNCTION_NAME}" --payload '{}' --cli-binary-format raw-in-base64-out response5.json + echo "Fifth invocation response:" + cat response5.json + echo "" + + - name: Check CloudWatch logs + run: | + echo "Checking recent CloudWatch logs for the function..." + LOG_GROUP_NAME="/aws/lambda/${FUNCTION_NAME}" + + # Get recent log events (last 5 minutes) + aws logs filter-log-events \ + --log-group-name "$LOG_GROUP_NAME" \ + --start-time $(date -d '5 minutes ago' +%s)000 \ + --query 'events[].message' \ + --output text || { + echo "❌ Could not fetch CloudWatch logs. Log group might not exist or no recent logs." + echo "Checking if log group exists..." + aws logs describe-log-groups --log-group-name-prefix "$LOG_GROUP_NAME" --query 'logGroups[].logGroupName' --output text + } + + verify-e2e: + runs-on: ubuntu-latest + needs: publish-update-invoke + steps: + - name: Checkout + uses: actions/checkout@v5 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: '1.21' + + - name: Run E2E verification tests + working-directory: e2e_tests + env: + LOGZIO_API_KEY: ${{ secrets.LOGZIO_API_KEY }} + LOGZIO_API_URL: ${{ inputs.logzio_api_url || 'https://api.logz.io' }} + LOGZIO_API_METRICS_KEY: ${{ secrets.LOGZIO_API_METRICS_KEY }} + LOGZIO_METRICS_QUERY_URL: ${{ inputs.logzio_api_url || 'https://api.logz.io' }} + LOGZIO_API_TRACES_KEY: ${{ secrets.LOGZIO_API_TRACES_KEY }} + E2E_TEST_ENVIRONMENT_LABEL: ${{ needs.publish-update-invoke.outputs.e2e_label }} + EXPECTED_LAMBDA_FUNCTION_NAME: one-layer-e2e-test-ruby + EXPECTED_SERVICE_NAME: ${{ env.SERVICE_NAME }} + GITHUB_RUN_ID: ${{ github.run_id }} + AWS_REGION: ${{ env.AWS_REGION }} + run: | + go mod tidy + go test ./... -v -tags=e2e -run TestE2ERunner + + cleanup: + if: always() + runs-on: ubuntu-latest + needs: [publish-update-invoke, verify-e2e] + steps: + - name: Configure AWS (User Credentials) + uses: aws-actions/configure-aws-credentials@v4 + with: + aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} + aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + aws-region: ${{ inputs.aws_region || 'us-east-1' }} + - name: Delete published layer version + if: ${{ needs.publish-update-invoke.outputs.layer_arn != '' }} + shell: bash + run: | + ARN="${{ needs.publish-update-invoke.outputs.layer_arn }}" + LAYER_NAME=$(echo "$ARN" | cut -d: -f7) + LAYER_VERSION=$(echo "$ARN" | cut -d: -f8) + aws lambda delete-layer-version --layer-name "$LAYER_NAME" --version-number "$LAYER_VERSION" || echo "Failed to delete layer version." + + diff --git a/.github/workflows/release-combined-go-lambda-layer.yml b/.github/workflows/release-combined-go-lambda-layer.yml new file mode 100644 index 0000000000..6136b92b3c --- /dev/null +++ b/.github/workflows/release-combined-go-lambda-layer.yml @@ -0,0 +1,115 @@ +name: "Release Combined Go Lambda Layer" + +on: + push: + tags: + - combined-layer-go/** + +permissions: + contents: read + +jobs: + create-release: + permissions: + contents: write + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - name: Create Release + run: gh release create ${{ github.ref_name }} --draft --title ${{ github.ref_name }} + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + build-combined-layer: + permissions: + contents: write + runs-on: ubuntu-latest + needs: create-release + strategy: + matrix: + architecture: + - amd64 + - arm64 + steps: + - uses: actions/checkout@v5 + + - uses: actions/setup-go@v5 + with: + go-version-file: collector/go.mod + + - name: Build Combined Layer + run: | + cd go + ARCHITECTURE=${{ matrix.architecture }} ./build-combined.sh + env: + ARCHITECTURE: ${{ matrix.architecture }} + + - name: Rename zip file for architecture + run: | + mv build/otel-go-extension-layer.zip build/otel-go-extension-layer-${{ matrix.architecture }}.zip + working-directory: go + + - uses: actions/upload-artifact@v4 + name: Save assembled combined layer to build + with: + name: otel-go-extension-layer-${{ matrix.architecture }}.zip + path: go/build/otel-go-extension-layer-${{ matrix.architecture }}.zip + + - name: Add Binary to Release + run: | + gh release upload ${{github.ref_name}} go/build/otel-go-extension-layer-${{ matrix.architecture }}.zip + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + publish-combined-layer: + permissions: + contents: read + id-token: write + uses: ./.github/workflows/layer-publish.yml + needs: build-combined-layer + strategy: + matrix: + architecture: + - amd64 + - arm64 + aws_region: + - 'us-east-1' + - 'us-east-2' + - 'us-west-1' + - 'us-west-2' + - 'eu-central-1' + - 'eu-central-2' + - 'eu-north-1' + - 'eu-west-1' + - 'eu-west-2' + - 'eu-west-3' + - 'eu-south-1' + - 'eu-south-2' + - 'sa-east-1' + - 'ap-northeast-1' + - 'ap-northeast-2' + - 'ap-northeast-3' + - 'ap-south-1' + - 'ap-south-2' + - 'ap-southeast-1' + - 'ap-southeast-2' + - 'ap-southeast-3' + - 'ap-southeast-4' + - 'ap-east-1' + - 'ca-central-1' + - 'ca-west-1' + - 'af-south-1' + - 'me-south-1' + - 'me-central-1' + - 'il-central-1' + with: + artifact-name: otel-go-extension-layer-${{ matrix.architecture }}.zip + layer-name: otel-go-extension + component-version: "combined" + architecture: ${{ matrix.architecture }} + runtimes: provided.al2 provided.al2023 + release-group: prod + aws_region: ${{ matrix.aws_region }} + secrets: inherit + + diff --git a/.github/workflows/release-combined-layer-java.yml b/.github/workflows/release-combined-layer-java.yml new file mode 100644 index 0000000000..d87ec0af38 --- /dev/null +++ b/.github/workflows/release-combined-layer-java.yml @@ -0,0 +1,113 @@ +name: "Release Combined Java Lambda Layer" + +on: + push: + tags: + - combined-layer-java/** + +permissions: + contents: read + +jobs: + create-release: + permissions: + contents: write + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - name: Create Release + run: gh release create ${{ github.ref_name }} --draft --title ${{ github.ref_name }} + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + build-combined-layer: + permissions: + contents: write + runs-on: ubuntu-latest + needs: create-release + strategy: + matrix: + architecture: + - amd64 + - arm64 + steps: + - uses: actions/checkout@v5 + + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: 21 + + - uses: actions/setup-go@v5 + with: + go-version-file: collector/go.mod + + - name: Build Combined Layer + run: | + cd java + ARCHITECTURE=${{ matrix.architecture }} ./build-combined.sh + env: + ARCHITECTURE: ${{ matrix.architecture }} + + - uses: actions/upload-artifact@v4 + name: Save assembled combined layer to build + with: + name: otel-java-extension-layer-${{ matrix.architecture }}.zip + path: java/build/otel-java-extension-layer-${{ matrix.architecture }}.zip + + - name: Add Binary to Release + run: | + gh release upload ${{github.ref_name}} java/build/otel-java-extension-layer-${{ matrix.architecture }}.zip + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + publish-combined-layer: + permissions: + contents: read + id-token: write + uses: ./.github/workflows/layer-publish.yml + needs: build-combined-layer + strategy: + matrix: + architecture: + - amd64 + - arm64 + aws_region: + - 'us-east-1' + - 'us-east-2' + - 'us-west-1' + - 'us-west-2' + - 'eu-central-1' + - 'eu-central-2' + - 'eu-north-1' + - 'eu-west-1' + - 'eu-west-2' + - 'eu-west-3' + - 'eu-south-1' + - 'eu-south-2' + - 'sa-east-1' + - 'ap-northeast-1' + - 'ap-northeast-2' + - 'ap-northeast-3' + - 'ap-south-1' + - 'ap-south-2' + - 'ap-southeast-1' + - 'ap-southeast-2' + - 'ap-southeast-3' + - 'ap-southeast-4' + - 'ap-east-1' + - 'ca-central-1' + - 'ca-west-1' + - 'af-south-1' + - 'me-south-1' + - 'me-central-1' + - 'il-central-1' + with: + artifact-name: otel-java-extension-layer-${{ matrix.architecture }}.zip + layer-name: otel-java-extension + component-version: "combined" + architecture: ${{ matrix.architecture }} + runtimes: java11 java17 java21 + release-group: prod + aws_region: ${{ matrix.aws_region }} + secrets: inherit \ No newline at end of file diff --git a/.github/workflows/release-combined-layer-nodejs.yml b/.github/workflows/release-combined-layer-nodejs.yml new file mode 100644 index 0000000000..d13d9da6b6 --- /dev/null +++ b/.github/workflows/release-combined-layer-nodejs.yml @@ -0,0 +1,127 @@ +name: "Release Combined NodeJS Lambda Layer" + +on: + # (Using tag push instead of release to allow filtering by tag prefix.) + push: + tags: + - combined-layer-nodejs/** + +permissions: + contents: read + +jobs: + create-release: + permissions: + contents: write + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - name: Create Release + run: gh release create ${{ github.ref_name }} --draft --title ${{ github.ref_name }} + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + build-combined-layer: + permissions: + contents: write + runs-on: ubuntu-latest + needs: create-release + strategy: + matrix: + architecture: + - amd64 + - arm64 + outputs: + NODEJS_VERSION: ${{ steps.save-node-sdk-version.outputs.SDK_VERSION}} + steps: + - uses: actions/checkout@v5 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + + - uses: actions/setup-go@v5 + with: + go-version-file: collector/go.mod + + - name: Build Combined Layer + run: | + cd nodejs/packages/layer + ARCHITECTURE=${{ matrix.architecture }} npm run build-combined + env: + ARCHITECTURE: ${{ matrix.architecture }} + + - name: Save Node SDK Version + id: save-node-sdk-version + run: | + SDK_VERSION=$(npm list @opentelemetry/core --depth=0 | grep @opentelemetry/core | sed 's/^.*@//') + echo "SDK_VERSION=$SDK_VERSION" >> $GITHUB_OUTPUT + working-directory: nodejs/packages/layer/scripts + + - name: Rename zip file for architecture + run: | + mv build/otel-nodejs-extension-layer.zip build/otel-nodejs-extension-layer-${{ matrix.architecture }}.zip + working-directory: nodejs/packages/layer + + - uses: actions/upload-artifact@v4 + name: Save assembled combined layer to build + with: + name: otel-nodejs-extension-layer-${{ matrix.architecture }}.zip + path: nodejs/packages/layer/build/otel-nodejs-extension-layer-${{ matrix.architecture }}.zip + + - name: Add Binary to Release + run: | + gh release upload ${{github.ref_name}} nodejs/packages/layer/build/otel-nodejs-extension-layer-${{ matrix.architecture }}.zip + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + publish-combined-layer: + permissions: # required by the reusable workflow + contents: read + id-token: write + uses: ./.github/workflows/layer-publish.yml + needs: build-combined-layer + strategy: + matrix: + architecture: + - amd64 + - arm64 + aws_region: + - 'us-east-1' + - 'us-east-2' + - 'us-west-1' + - 'us-west-2' + - 'eu-central-1' + - 'eu-central-2' + - 'eu-north-1' + - 'eu-west-1' + - 'eu-west-2' + - 'eu-west-3' + - 'eu-south-1' + - 'eu-south-2' + - 'sa-east-1' + - 'ap-northeast-1' + - 'ap-northeast-2' + - 'ap-northeast-3' + - 'ap-south-1' + - 'ap-south-2' + - 'ap-southeast-1' + - 'ap-southeast-2' + - 'ap-southeast-3' + - 'ap-southeast-4' + - 'ap-east-1' + - 'ca-central-1' + - 'ca-west-1' + - 'af-south-1' + - 'me-south-1' + - 'me-central-1' + - 'il-central-1' + with: + artifact-name: otel-nodejs-extension-layer-${{ matrix.architecture }}.zip + layer-name: otel-nodejs-extension + component-version: ${{needs.build-combined-layer.outputs.NODEJS_VERSION}} + architecture: ${{ matrix.architecture }} + runtimes: nodejs18.x nodejs20.x nodejs22.x + release-group: prod + aws_region: ${{ matrix.aws_region }} + secrets: inherit \ No newline at end of file diff --git a/.github/workflows/release-combined-layer-python.yml b/.github/workflows/release-combined-layer-python.yml new file mode 100644 index 0000000000..f38c071326 --- /dev/null +++ b/.github/workflows/release-combined-layer-python.yml @@ -0,0 +1,113 @@ +name: "Release Combined Python Lambda Layer" + +on: + push: + tags: + - combined-layer-python/** + +permissions: + contents: read + +jobs: + create-release: + permissions: + contents: write + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - name: Create Release + run: gh release create ${{ github.ref_name }} --draft --title ${{ github.ref_name }} + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + build-combined-layer: + permissions: + contents: write + runs-on: ubuntu-latest + needs: create-release + strategy: + matrix: + architecture: + - amd64 + - arm64 + steps: + - uses: actions/checkout@v5 + + - uses: actions/setup-go@v5 + with: + go-version-file: collector/go.mod + + - name: Build Combined Layer + run: | + cd python/src + ARCHITECTURE=${{ matrix.architecture }} ./build-combined.sh + env: + ARCHITECTURE: ${{ matrix.architecture }} + + - name: Rename zip file for architecture + run: | + mv build/otel-python-extension-layer.zip build/otel-python-extension-layer-${{ matrix.architecture }}.zip + working-directory: python/src + + - uses: actions/upload-artifact@v4 + name: Save assembled combined layer to build + with: + name: otel-python-extension-layer-${{ matrix.architecture }}.zip + path: python/src/build/otel-python-extension-layer-${{ matrix.architecture }}.zip + + - name: Add Binary to Release + run: | + gh release upload ${{github.ref_name}} python/src/build/otel-python-extension-layer-${{ matrix.architecture }}.zip + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + publish-combined-layer: + permissions: + contents: read + id-token: write + uses: ./.github/workflows/layer-publish.yml + needs: build-combined-layer + strategy: + matrix: + architecture: + - amd64 + - arm64 + aws_region: + - 'us-east-1' + - 'us-east-2' + - 'us-west-1' + - 'us-west-2' + - 'eu-central-1' + - 'eu-central-2' + - 'eu-north-1' + - 'eu-west-1' + - 'eu-west-2' + - 'eu-west-3' + - 'eu-south-1' + - 'eu-south-2' + - 'sa-east-1' + - 'ap-northeast-1' + - 'ap-northeast-2' + - 'ap-northeast-3' + - 'ap-south-1' + - 'ap-south-2' + - 'ap-southeast-1' + - 'ap-southeast-2' + - 'ap-southeast-3' + - 'ap-southeast-4' + - 'ap-east-1' + - 'ca-central-1' + - 'ca-west-1' + - 'af-south-1' + - 'me-south-1' + - 'me-central-1' + - 'il-central-1' + with: + artifact-name: otel-python-extension-layer-${{ matrix.architecture }}.zip + layer-name: otel-python-extension + component-version: "combined" + architecture: ${{ matrix.architecture }} + runtimes: python3.9 python3.10 python3.11 python3.12 python3.13 + release-group: prod + aws_region: ${{ matrix.aws_region }} + secrets: inherit \ No newline at end of file diff --git a/.github/workflows/release-combined-ruby-lambda-layer.yml b/.github/workflows/release-combined-ruby-lambda-layer.yml new file mode 100644 index 0000000000..0f965e0f54 --- /dev/null +++ b/.github/workflows/release-combined-ruby-lambda-layer.yml @@ -0,0 +1,115 @@ +name: "Release Combined Ruby Lambda Layer" + +on: + push: + tags: + - combined-layer-ruby/** + +permissions: + contents: read + +jobs: + create-release: + permissions: + contents: write + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - name: Create Release + run: gh release create ${{ github.ref_name }} --draft --title ${{ github.ref_name }} + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + build-combined-layer: + permissions: + contents: write + runs-on: ubuntu-latest + needs: create-release + strategy: + matrix: + architecture: + - amd64 + - arm64 + steps: + - uses: actions/checkout@v5 + + - uses: actions/setup-go@v5 + with: + go-version-file: collector/go.mod + + - name: Build Combined Layer + run: | + cd ruby + ARCHITECTURE=${{ matrix.architecture }} ./build-combined.sh + env: + ARCHITECTURE: ${{ matrix.architecture }} + + - name: Rename zip file for architecture + run: | + mv build/otel-ruby-extension-layer.zip build/otel-ruby-extension-layer-${{ matrix.architecture }}.zip + working-directory: ruby + + - uses: actions/upload-artifact@v4 + name: Save assembled combined layer to build + with: + name: otel-ruby-extension-layer-${{ matrix.architecture }}.zip + path: ruby/build/otel-ruby-extension-layer-${{ matrix.architecture }}.zip + + - name: Add Binary to Release + run: | + gh release upload ${{github.ref_name}} ruby/build/otel-ruby-extension-layer-${{ matrix.architecture }}.zip + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + publish-combined-layer: + permissions: + contents: read + id-token: write + uses: ./.github/workflows/layer-publish.yml + needs: build-combined-layer + strategy: + matrix: + architecture: + - amd64 + - arm64 + aws_region: + - 'us-east-1' + - 'us-east-2' + - 'us-west-1' + - 'us-west-2' + - 'eu-central-1' + - 'eu-central-2' + - 'eu-north-1' + - 'eu-west-1' + - 'eu-west-2' + - 'eu-west-3' + - 'eu-south-1' + - 'eu-south-2' + - 'sa-east-1' + - 'ap-northeast-1' + - 'ap-northeast-2' + - 'ap-northeast-3' + - 'ap-south-1' + - 'ap-south-2' + - 'ap-southeast-1' + - 'ap-southeast-2' + - 'ap-southeast-3' + - 'ap-southeast-4' + - 'ap-east-1' + - 'ca-central-1' + - 'ca-west-1' + - 'af-south-1' + - 'me-south-1' + - 'me-central-1' + - 'il-central-1' + with: + artifact-name: otel-ruby-extension-layer-${{ matrix.architecture }}.zip + layer-name: otel-ruby-extension + component-version: "combined" + architecture: ${{ matrix.architecture }} + runtimes: ruby3.2 ruby3.4 + release-group: prod + aws_region: ${{ matrix.aws_region }} + secrets: inherit + + diff --git a/README.combined-layers.md b/README.combined-layers.md new file mode 100644 index 0000000000..b8420be52d --- /dev/null +++ b/README.combined-layers.md @@ -0,0 +1,80 @@ +## Combined Layers (New) + +**Simplified Deployment**: We now offer combined layers that bundle both the language-specific instrumentation and the collector into a single layer. This approach: +- Reduces the number of layers from 2 to 1 +- Simplifies configuration and deployment +- Maintains all the functionality of the separate layers +- Is available for Python, Node.js, Java, Ruby, and Go + +### What's included in combined layers: +- **Language-specific OpenTelemetry instrumentation** - Automatically instruments your Lambda function and popular libraries +- **OpenTelemetry Collector** - Built-in collector that exports telemetry data to your configured backend +- **Auto-instrumentation** - Automatic instrumentation for AWS SDK and popular libraries in each language +- **Optimized packaging** - Reduced cold start impact with optimized layer packaging + +### Benefits: +- **Single layer deployment** - No need to manage separate collector and instrumentation layers +- **Simplified configuration** - Fewer environment variables and layer configurations +- **Reduced cold start impact** - Optimized packaging reduces overhead +- **Production-ready** - Includes all necessary components for complete observability + +### Common Environment Variables + +Most combined layers support these common environment variables: + +**Required:** +- `AWS_LAMBDA_EXEC_WRAPPER` – set to `/opt/otel-handler` (or language-specific handler) +- `LOGZIO_TRACES_TOKEN` – account token for traces +- `LOGZIO_METRICS_TOKEN` – account token for metrics +- `LOGZIO_LOGS_TOKEN` – account token for logs +- `LOGZIO_REGION` – Logz.io region code (for example, `us`, `eu`) + +**Optional:** +- `OTEL_SERVICE_NAME` – explicit service name +- `OTEL_RESOURCE_ATTRIBUTES` – comma-separated resource attributes (for example, `service.name=my-func,env_id=${LOGZIO_ENV_ID},deployment.environment=${ENVIRONMENT}`) +- `LOGZIO_ENV_ID` – environment identifier you can include in `OTEL_RESOURCE_ATTRIBUTES` (for example, `env_id=prod`) +- `ENVIRONMENT` – logical environment name you can include in `OTEL_RESOURCE_ATTRIBUTES` (for example, `deployment.environment=prod`) +- `OPENTELEMETRY_COLLECTOR_CONFIG_URI` – custom collector config URI/file path; defaults to `/opt/collector-config/config.yaml` +- `OPENTELEMETRY_EXTENSION_LOG_LEVEL` – extension log level (`debug`, `info`, `warn`, `error`) + +### Language-Specific Details + +#### Java Combined Layer +- **Multiple handler types available:** + - `/opt/otel-handler` - for regular handlers (implementing RequestHandler) + - `/opt/otel-sqs-handler` - for SQS-triggered functions + - `/opt/otel-proxy-handler` - for API Gateway proxied handlers + - `/opt/otel-stream-handler` - for streaming handlers +- **Fast startup mode:** Set `OTEL_JAVA_AGENT_FAST_STARTUP_ENABLED=true` to enable optimized startup (disables JIT tiered compilation level 2) +- **Agent and wrapper variants:** Both Java agent and wrapper approaches are available in the combined layer + +#### Node.js Combined Layer +- **ESM and CommonJS support:** Works with both module systems +- **Instrumentation control:** + - `OTEL_NODE_ENABLED_INSTRUMENTATIONS` - comma-separated list to enable only specific instrumentations + - `OTEL_NODE_DISABLED_INSTRUMENTATIONS` - comma-separated list to disable specific instrumentations +- **Popular libraries included:** AWS SDK v3, Express, HTTP, MongoDB, Redis, and many more + +#### Ruby Combined Layer +- **Ruby version support:** Compatible with Ruby 3.2.0, 3.3.0, and 3.4.0 +- **Popular gems included:** AWS SDK, Rails, Sinatra, and other popular Ruby libraries +- **Additional configuration:** `OTEL_RUBY_INSTRUMENTATION_NET_HTTP_ENABLED` - toggle net/http instrumentation (true/false) + +#### Go Combined Layer +- **Collector-only layer:** Since Go uses manual instrumentation, this provides only the collector component +- **Manual instrumentation required:** You must instrument your Go code using the [OpenTelemetry Go SDK](https://github.com/open-telemetry/opentelemetry-go-contrib/tree/main/instrumentation/github.com/aws/aws-lambda-go/otellambda) +- **No AWS_LAMBDA_EXEC_WRAPPER needed:** Go layer doesn't require the wrapper environment variable + +#### Python Combined Layer +- **Auto-instrumentation:** Automatically instruments Lambda functions and popular Python libraries like boto3, requests, urllib3 +- **Trace context propagation:** Automatically propagates trace context through AWS services + +### Build Scripts +Each language provides a `build-combined.sh` script for creating combined layers: +- `python/src/build-combined.sh` +- `java/build-combined.sh` +- `nodejs/packages/layer/build-combined.sh` +- `ruby/build-combined.sh` +- `go/build-combined.sh` + +For detailed build instructions and sample applications, see the individual language README files below. diff --git a/RELEASE.combined-layers.md b/RELEASE.combined-layers.md new file mode 100644 index 0000000000..49c02d4216 --- /dev/null +++ b/RELEASE.combined-layers.md @@ -0,0 +1,155 @@ +# OpenTelemetry Lambda + +![GitHub Java Workflow Status](https://img.shields.io/github/actions/workflow/status/open-telemetry/opentelemetry-lambda/ci-java.yml?branch=main&label=CI%20%28Java%29&style=for-the-badge) +![GitHub Collector Workflow Status](https://img.shields.io/github/actions/workflow/status/open-telemetry/opentelemetry-lambda/ci-collector.yml?branch=main&label=CI%20%28Collector%29&style=for-the-badge) +![GitHub NodeJS Workflow Status](https://img.shields.io/github/actions/workflow/status/open-telemetry/opentelemetry-lambda/ci-nodejs.yml?branch=main&label=CI%20%28NodeJS%29&style=for-the-badge) +![GitHub Terraform Lint Workflow Status](https://img.shields.io/github/actions/workflow/status/open-telemetry/opentelemetry-lambda/ci-terraform.yml?branch=main&label=CI%20%28Terraform%20Lint%29&style=for-the-badge) +![GitHub Python Pull Request Workflow Status](https://img.shields.io/github/actions/workflow/status/open-telemetry/opentelemetry-lambda/ci-python.yml?branch=main&label=Pull%20Request%20%28Python%29&style=for-the-badge) +[![OpenSSF Scorecard](https://api.scorecard.dev/projects/github.com/open-telemetry/opentelemetry-lambda/badge?style=for-the-badge)](https://scorecard.dev/viewer/?uri=github.com/open-telemetry/opentelemetry-lambda) + +## OpenTelemetry Lambda Layers + +The OpenTelemetry Lambda Layers provide the OpenTelemetry (OTel) code to export telemetry asynchronously from AWS Lambda functions. It does this by embedding a stripped-down version of [OpenTelemetry Collector Contrib](https://github.com/open-telemetry/opentelemetry-collector-contrib) inside an [AWS Lambda Extension Layer](https://aws.amazon.com/blogs/compute/introducing-aws-lambda-extensions-in-preview/). This allows Lambda functions to use OpenTelemetry to send traces and metrics to any configured backend. + +There are 2 types of lambda layers +1. Collector Layer - Embeds a stripped down version of the OpenTelemetry Collector +2. Language Specific Layer - Includes language specific nuances to allow lambda functions to automatically consume context from upstream callers, create spans, and automatically instrument the AWS SDK + +These 2 layers are meant to be used in conjunction to instrument your lambda functions. The reason that the collector is not embedded in specific language layers is to give users flexibility + +## Collector Layer +* ### [Collector Lambda Layer](collector/README.md) + +## Extension Layer Language Support +* ### [Python Lambda Layer](python/README.md) +* ### [Java Lambda Layer](java/README.md) +* ### [NodeJS Lambda Layer](nodejs/README.md) +* ### [Ruby Lambda Layer](ruby/README.md) + +## Additional language tooling not currently supported +* ### [Go Lambda Library](go/README.md) +* ### [.NET Lambda Layer](dotnet/README.md) + +## Latest Layer Versions +| Name | ARN | Version | +|--------------|:-----------------------------------------------------------------------------------------------------------------------|:--------| +| collector | `arn:aws:lambda::184161586896:layer:opentelemetry-collector--:1` | ![Collector](https://api.globadge.com/v1/badgen/http/jq/e3309d56-dfd6-4dae-ac00-4498070d84f0) | +| nodejs | `arn:aws:lambda::184161586896:layer:opentelemetry-nodejs-:1` | ![NodeJS](https://api.globadge.com/v1/badgen/http/jq/91b0f102-25fc-425f-8de9-f05491b9f757) | +| python | `arn:aws:lambda::184161586896:layer:opentelemetry-python-:1` | ![Python](https://api.globadge.com/v1/badgen/http/jq/ab030ce1-ee7d-4c14-b643-eb20ec050e0b) | +| java-agent | `arn:aws:lambda::184161586896:layer:opentelemetry-javaagent-:1` | ![Java Agent](https://api.globadge.com/v1/badgen/http/jq/301ad852-ccb4-4bb4-997e-60282ad11f71) | +| java-wrapper | `arn:aws:lambda::184161586896:layer:opentelemetry-javawrapper-:1` | ![Java Wrapper](https://api.globadge.com/v1/badgen/http/jq/e10281c6-3d0e-42e4-990b-7a725301bef4) | +| ruby | `arn:aws:lambda::184161586896:layer:opentelemetry-ruby-dev-:1` | ![Ruby](https://api.globadge.com/v1/badgen/http/jq/4d9b9e93-7d6b-4dcf-836e-1878de566fdb) | + +## FAQ + +* **What exporters/receivers/processors are included from the OpenTelemetry Collector?** + > You can check out [the stripped-down collector's imports](https://github.com/open-telemetry/opentelemetry-lambda/blob/main/collector/lambdacomponents/default.go#L18) in this repository for a full list of currently included components. + + > Self-built binaries of the collector have **experimental** support for a custom set of connectors/exporters/receivers/processors. For more information, see [(Experimental) Customized collector build](./collector/README.md#experimental-customized-collector-build) +* **Is the Lambda layer provided or do I need to build it and distribute it myself?** + > This repository provides pre-built Lambda layers, their ARNs are available in the [Releases](https://github.com/open-telemetry/opentelemetry-lambda/releases). You can also build the layers manually and publish them in your AWS account. This repo has files to facilitate doing that. More information is provided in [the Collector folder's README](collector/README.md). + +## Design Proposal + +To get a better understanding of the proposed design for the OpenTelemetry Lambda extension, you can see the [Design Proposal here.](docs/design_proposal.md) + +## Features + +The following is a list of features provided by the OpenTelemetry layers. + +### OpenTelemetry collector + +The layer includes the OpenTelemetry Collector as a Lambda extension. + +### Custom context propagation carrier extraction + +Context can be propagated through various mechanisms (e.g. http headers (APIGW), message attributes (SQS), ...). In some cases, it may be required to pass a custom context propagation extractor in Lambda through configuration, this feature allows this through Lambda instrumentation configuration. + +### X-Ray Env Var Span Link + +This links a context extracted from the Lambda runtime environment to the instrumentation-generated span rather than disabling that context extraction entirely. + +### Semantic conventions + +The Lambda language implementation follows the semantic conventions specified in the OpenTelemetry Specification. + +### Auto instrumentation + +The Lambda layer includes support for automatically instrumentation code via the use of instrumentation libraries. + +### Flush TracerProvider + +The Lambda instrumentation will flush the `TracerProvider` at the end of an invocation. + +### Flush MeterProvider + +The Lambda instrumentation will flush the `MeterProvider` at the end of an invocation. + +### Support matrix + +The table below captures the state of various features and their levels of support different runtimes. + +| Feature | Node | Python | Java | .NET | Go | Ruby | +| -------------------------- | :--: | :----: | :--: | :--: | :--: | :--: | +| OpenTelemetry collector | + | + | + | + | + | + | +| Custom context propagation | + | - | - | - | N/A | + | +| X-Ray Env Var Span Link | - | - | - | - | N/A | - | +| Semantic Conventions^ | | + | + | + | N/A | + | +| - Trace General^[1] | + | | + | + | N/A | + | +| - Trace Incoming^[2] | - | | - | + | N/A | - | +| - Trace Outgoing^[3] | + | | - | + | N/A | + | +| - Metrics^[4] | - | | - | - | N/A | - | +| Auto instrumentation | + | + | + | - | N/A | + | +| Flush TracerProvider | + | + | | + | + | + | +| Flush MeterProvider | + | + | | | | - | + +#### Legend + +* `+` is supported +* `-` not supported +* `^` subject to change depending on spec updates +* `N/A` not applicable to the particular language +* blank cell means the status of the feature is not known. + +The following are runtimes which are no longer or not yet supported by this repository: + +* Node.js 12, Node.js 16 - not [officially supported](https://github.com/open-telemetry/opentelemetry-js#supported-runtimes) by OpenTelemetry JS + +[1]: https://github.com/open-telemetry/semantic-conventions/blob/main/docs/faas/faas-spans.md#general-attributes +[2]: https://github.com/open-telemetry/semantic-conventions/blob/main/docs/faas/faas-spans.md#incoming-invocations +[3]: https://github.com/open-telemetry/semantic-conventions/blob/main/docs/faas/faas-spans.md#outgoing-invocations +[4]: https://github.com/open-telemetry/semantic-conventions/blob/main/docs/faas/faas-metrics.md + +## Contributing + +See the [Contributing Guide](CONTRIBUTING.md) for details. + +### Maintainers + +- [Serkan Özal](https://github.com/serkan-ozal), Catchpoint +- [Tyler Benson](https://github.com/tylerbenson), ServiceNow +- [Warre Pessers](https://github.com/wpessers) + +For more information about the maintainer role, see the [community repository](https://github.com/open-telemetry/community/blob/main/guides/contributor/membership.md#maintainer). + +### Approvers + +- [Ivan Santos](https://github.com/pragmaticivan) + +For more information about the approver role, see the [community repository](https://github.com/open-telemetry/community/blob/main/guides/contributor/membership.md#approver). + +### Emeritus Maintainers + +- [Alex Boten](https://github.com/codeboten) +- [Anthony Mirabella](https://github.com/Aneurysm9) +- [Raphael Philipe Mendes da Silva](https://github.com/rapphil) + +For more information about the emeritus role, see the [community repository](https://github.com/open-telemetry/community/blob/main/guides/contributor/membership.md#emeritus-maintainerapprovertriager). + +### Emeritus Approvers + +- [Lei Wang](https://github.com/wangzlei) +- [Nathaniel Ruiz Nowell](https://github.com/NathanielRN) +- [Tristan Sloughter](https://github.com/tsloughter) + +For more information about the emeritus role, see the [community repository](https://github.com/open-telemetry/community/blob/main/guides/contributor/membership.md#emeritus-maintainerapprovertriager). \ No newline at end of file diff --git a/RELEASE.md b/RELEASE.md index a39a5c52df..8036a787ad 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -11,4 +11,4 @@ The release process is almost entirely managed by [GitHub actions](https://githu * Select a the newly pushed tag * Select the corresponding previous release * Click "Generate Release Notes" - * Adjust the release notes. Include the ARN, list of changes and diff with previous label. + * Adjust the release notes. Include the ARN, list of changes and diff with previous label. \ No newline at end of file diff --git a/collector/Makefile b/collector/Makefile index fbdb929f00..f38e8de1d8 100644 --- a/collector/Makefile +++ b/collector/Makefile @@ -45,6 +45,42 @@ package: build cp config* $(BUILD_SPACE)/collector-config cd $(BUILD_SPACE) && zip -r opentelemetry-collector-layer-$(GOARCH).zip collector-config extensions +# Variables for combined layer build +LANGUAGE ?= +INSTRUMENTATION_LAYER_DIR ?= $(BUILD_SPACE)/instrumentation +INSTRUMENTATION_MANAGER = ../utils/instrumentation-layer-manager.sh + +.PHONY: package-combined +package-combined: build + @if [ -z "$(LANGUAGE)" ]; then \ + echo "Error: LANGUAGE parameter is required for combined layer build"; \ + echo "Usage: make package-combined LANGUAGE= [GOARCH=]"; \ + echo "Supported languages: nodejs, python, java, dotnet, ruby, go"; \ + exit 1; \ + fi + @echo Building combined extension layer for $(LANGUAGE) + mkdir -p $(BUILD_SPACE)/collector-config + cp config* $(BUILD_SPACE)/collector-config + @# Check if instrumentation layer is available for this language + @if $(INSTRUMENTATION_MANAGER) check $(LANGUAGE); then \ + echo "Downloading instrumentation layer for $(LANGUAGE)..."; \ + RESULT=$$($(INSTRUMENTATION_MANAGER) download $(LANGUAGE) $(BUILD_SPACE)/temp $(GOARCH) 2>&1) || { \ + echo "Warning: Could not download instrumentation layer for $(LANGUAGE): $$RESULT"; \ + echo "Building collector-only layer..."; \ + }; \ + if [ -d "$(BUILD_SPACE)/temp/instrumentation" ]; then \ + echo "Including instrumentation layer in combined build..."; \ + cp -r $(BUILD_SPACE)/temp/instrumentation/* $(BUILD_SPACE)/; \ + echo "$$RESULT" | grep "Release tag:" > $(BUILD_SPACE)/instrumentation-version.txt || echo "unknown" > $(BUILD_SPACE)/instrumentation-version.txt; \ + rm -rf $(BUILD_SPACE)/temp; \ + fi; \ + else \ + echo "No instrumentation layer available for $(LANGUAGE), building collector-only layer"; \ + fi + @# Create combined layer zip + cd $(BUILD_SPACE) && zip -r otel-$(LANGUAGE)-extension-$(GOARCH).zip collector-config extensions $(shell [ -f "$(BUILD_SPACE)/instrumentation-version.txt" ] && find . -mindepth 1 -not -path "./collector-config*" -not -path "./extensions*" -not -name "*-$(GOARCH).zip" || echo "") + @echo Combined layer created: otel-$(LANGUAGE)-extension-$(GOARCH).zip + .PHONY: publish publish: aws lambda publish-layer-version --layer-name $(LAYER_NAME) --zip-file fileb://$(BUILD_SPACE)/opentelemetry-collector-layer-$(GOARCH).zip --compatible-runtimes nodejs16.x nodejs18.x nodejs20.x nodejs22.x java11 java17 java21 python3.9 python3.10 python3.11 python3.12 python3.13 --query 'LayerVersionArn' --output text @@ -55,6 +91,31 @@ publish-layer: package aws lambda publish-layer-version --layer-name $(LAYER_NAME) --zip-file fileb://$(BUILD_SPACE)/opentelemetry-collector-layer-$(GOARCH).zip --compatible-runtimes nodejs16.x nodejs18.x nodejs20.x nodejs22.x java11 java17 java21 python3.9 python3.10 python3.11 python3.12 python3.13 --query 'LayerVersionArn' --output text @echo OpenTelemetry Collector layer published. +.PHONY: publish-combined +publish-combined: package-combined + @if [ -z "$(LANGUAGE)" ]; then \ + echo "Error: LANGUAGE parameter is required for combined layer publish"; \ + echo "Usage: make publish-combined LANGUAGE= [GOARCH=]"; \ + exit 1; \ + fi + @echo Publishing combined extension layer for $(LANGUAGE)... + @# Determine compatible runtimes based on language + @case "$(LANGUAGE)" in \ + nodejs) RUNTIMES="nodejs18.x nodejs20.x nodejs22.x" ;; \ + python) RUNTIMES="python3.9 python3.10 python3.11 python3.12 python3.13" ;; \ + java) RUNTIMES="java11 java17 java21" ;; \ + dotnet) RUNTIMES="dotnet6 dotnet8" ;; \ + ruby) RUNTIMES="ruby3.2 ruby3.3" ;; \ + go) RUNTIMES="provided provided.al2" ;; \ + *) echo "Unknown language: $(LANGUAGE)"; exit 1 ;; \ + esac; \ + aws lambda publish-layer-version \ + --layer-name otel-$(LANGUAGE)-extension$(if $(findstring arm64,$(GOARCH)),-arm64,$(if $(findstring amd64,$(GOARCH)),-amd64,)) \ + --zip-file fileb://$(BUILD_SPACE)/otel-$(LANGUAGE)-extension-$(GOARCH).zip \ + --compatible-runtimes $$RUNTIMES \ + --query 'LayerVersionArn' --output text + @echo Combined extension layer for $(LANGUAGE) published. + .PHONY: set-otelcol-version set-otelcol-version: @OTELCOL_VERSION=$$(grep "go.opentelemetry.io/collector/otelcol v" go.mod | awk '{print $$2; exit}'); \ diff --git a/collector/config.e2e.yaml b/collector/config.e2e.yaml new file mode 100644 index 0000000000..f7a66b3279 --- /dev/null +++ b/collector/config.e2e.yaml @@ -0,0 +1,72 @@ +receivers: + otlp: + protocols: + grpc: + endpoint: "localhost:4317" + http: + endpoint: "localhost:4318" + telemetryapireceiver: + types: ["platform", "function", "extension"] + +processors: + batch: + # Jaeger (classic) rejects non-scalar tags (arrays/maps). Drop array attributes + # (process.command_args, aws.log.group.names, process.tags) to prevent "invalid tag type" 500s. + # If you need these values, stringify arrays with a transform processor instead of dropping. + attributes/drop_array_tags: + actions: + - key: process.command_args + action: delete + - key: aws.log.group.names + action: delete + - key: process.tags + action: delete + resource/drop_array_tags: + attributes: + - key: process.command_args + action: delete + - key: aws.log.group.names + action: delete + - key: process.tags + action: delete + +exporters: + debug: + verbosity: detailed + logzio/logs: + account_token: "${env:LOGZIO_LOGS_TOKEN}" + region: "${env:LOGZIO_REGION}" + headers: + user-agent: logzio-opentelemetry-layer-logs + logzio/traces: + account_token: "${env:LOGZIO_TRACES_TOKEN}" + region: "${env:LOGZIO_REGION}" + headers: + user-agent: logzio-opentelemetry-layer-traces + prometheusremotewrite: + endpoint: "https://listener.logz.io:8053" + headers: + Authorization: "Bearer ${env:LOGZIO_METRICS_TOKEN}" + user-agent: logzio-opentelemetry-layer-metrics + target_info: + enabled: false + +service: + pipelines: + traces: + receivers: [otlp, telemetryapireceiver] + processors: [resource/drop_array_tags, attributes/drop_array_tags, batch] + exporters: [logzio/traces] + metrics: + receivers: [otlp, telemetryapireceiver] + processors: [batch] + exporters: [prometheusremotewrite] + logs: + receivers: [telemetryapireceiver] + processors: [batch] + exporters: [logzio/logs] + telemetry: + logs: + level: "info" + + diff --git a/collector/receiver/telemetryapireceiver/config.go b/collector/receiver/telemetryapireceiver/config.go index 7a16fb59c9..c664fb9b9c 100644 --- a/collector/receiver/telemetryapireceiver/config.go +++ b/collector/receiver/telemetryapireceiver/config.go @@ -30,6 +30,9 @@ type Config struct { // Validate validates the configuration by checking for missing or invalid fields func (cfg *Config) Validate() error { + if cfg.extensionID == "" { + return fmt.Errorf("extensionID is a required configuration field") + } for _, t := range cfg.Types { if t != platform && t != function && t != extension { return fmt.Errorf("unknown extension type: %s", t) diff --git a/collector/receiver/telemetryapireceiver/config_test.go b/collector/receiver/telemetryapireceiver/config_test.go index 7eff02fbb5..3c67f12fef 100644 --- a/collector/receiver/telemetryapireceiver/config_test.go +++ b/collector/receiver/telemetryapireceiver/config_test.go @@ -127,16 +127,22 @@ func TestValidate(t *testing.T) { }{ { desc: "valid config", - cfg: &Config{}, + cfg: &Config{extensionID: "extensionID"}, expectedErr: nil, }, { desc: "invalid config", cfg: &Config{ - Types: []string{"invalid"}, + extensionID: "extensionID", + Types: []string{"invalid"}, }, expectedErr: fmt.Errorf("unknown extension type: invalid"), }, + { + desc: "missing extensionID", + cfg: &Config{}, + expectedErr: fmt.Errorf("extensionID is a required configuration field"), + }, } for _, tc := range testCases { diff --git a/collector/receiver/telemetryapireceiver/internal/telemetryapi/client.go b/collector/receiver/telemetryapireceiver/internal/telemetryapi/client.go index a0bd8d9742..635371637d 100644 --- a/collector/receiver/telemetryapireceiver/internal/telemetryapi/client.go +++ b/collector/receiver/telemetryapireceiver/internal/telemetryapi/client.go @@ -14,8 +14,8 @@ import ( const ( awsLambdaRuntimeAPIEnvVar = "AWS_LAMBDA_RUNTIME_API" - lambdaExtensionNameHeader = "Lambda-Extension-Name" lambdaExtensionIdentifierHeader = "Lambda-Extension-Identifier" + lambdaExtensionNameHeader = "Lambda-Extension-Name" ) // Client is a client for the AWS Lambda Telemetry API. @@ -41,36 +41,6 @@ func NewClient(logger *zap.Logger) (*Client, error) { }, nil } -// Register registers the extension with the Lambda Extensions API. -func (c *Client) Register(ctx context.Context, extensionName string) (string, error) { - url := c.baseURL + "/extension/register" - reqBody, _ := json.Marshal(RegisterRequest{Events: []string{"INVOKE", "SHUTDOWN"}}) - - req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewBuffer(reqBody)) - if err != nil { - return "", err - } - req.Header.Set(lambdaExtensionNameHeader, extensionName) - - resp, err := c.httpClient.Do(req) - if err != nil { - return "", err - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - body, _ := io.ReadAll(resp.Body) - return "", fmt.Errorf("failed to register extension, status: %s, body: %s", resp.Status, string(body)) - } - - extensionID := resp.Header.Get(lambdaExtensionNameHeader) - if extensionID == "" { - return "", fmt.Errorf("did not receive extension identifier") - } - - return extensionID, nil -} - // Subscribe subscribes the extension to the Telemetry API. func (c *Client) Subscribe(ctx context.Context, extensionID string, types []EventType, buffering BufferingCfg, destination Destination) error { url := c.telemetryAPIURL @@ -89,6 +59,7 @@ func (c *Client) Subscribe(ctx context.Context, extensionID string, types []Even if err != nil { return err } + req.Header.Set(lambdaExtensionIdentifierHeader, extensionID) req.Header.Set(lambdaExtensionNameHeader, extensionID) resp, err := c.httpClient.Do(req) diff --git a/collector/receiver/telemetryapireceiver/internal/telemetryapi/types.go b/collector/receiver/telemetryapireceiver/internal/telemetryapi/types.go index 197c696cab..9cf9cec9e7 100644 --- a/collector/receiver/telemetryapireceiver/internal/telemetryapi/types.go +++ b/collector/receiver/telemetryapireceiver/internal/telemetryapi/types.go @@ -29,17 +29,17 @@ type BufferingCfg struct { TimeoutMS uint `json:"timeoutMs"` } +// RegisterRequest is the request body for the /extension/register endpoint. +type RegisterRequest struct { + Events []string `json:"events"` +} + // Destination is where the Telemetry API will send telemetry. type Destination struct { Protocol Protocol `json:"protocol"` URI string `json:"URI"` } -// RegisterRequest is the request body for the /extension/register endpoint. -type RegisterRequest struct { - Events []string `json:"events"` -} - // SubscribeRequest is the request body for the /telemetry endpoint. type SubscribeRequest struct { SchemaVersion string `json:"schemaVersion"` diff --git a/collector/receiver/telemetryapireceiver/receiver.go b/collector/receiver/telemetryapireceiver/receiver.go index 31cb2a1197..66170259c8 100644 --- a/collector/receiver/telemetryapireceiver/receiver.go +++ b/collector/receiver/telemetryapireceiver/receiver.go @@ -122,13 +122,7 @@ func (r *telemetryAPIReceiver) Start(ctx context.Context, host component.Host) e return fmt.Errorf("failed to create telemetry api client: %w", err) } - extensionID, err := apiClient.Register(ctx, typeStr) - if err != nil { - return fmt.Errorf("failed to register extension: %w", err) - } - r.config.extensionID = extensionID - - // If the user has configured any types, subscribe to them. + // Subscribe to telemetry API for the configured event types if len(r.config.Types) > 0 { eventTypes := make([]telemetryapi.EventType, len(r.config.Types)) for i, s := range r.config.Types { @@ -144,7 +138,7 @@ func (r *telemetryAPIReceiver) Start(ctx context.Context, host component.Host) e URI: fmt.Sprintf("http://%s/", address), } - err = apiClient.Subscribe(ctx, extensionID, eventTypes, bufferingCfg, destinationCfg) + err = apiClient.Subscribe(ctx, r.config.extensionID, eventTypes, bufferingCfg, destinationCfg) if err != nil { return fmt.Errorf("failed to subscribe to Telemetry API: %w", err) } diff --git a/dotnet/sample-apps/aws-sdk/wrapper/SampleApps/AwsSdkSample/obj/Debug/net6.0/AwsSdkSample.AssemblyInfoInputs.cache b/dotnet/sample-apps/aws-sdk/wrapper/SampleApps/AwsSdkSample/obj/Debug/net6.0/AwsSdkSample.AssemblyInfoInputs.cache new file mode 100644 index 0000000000..7fecb29fa6 --- /dev/null +++ b/dotnet/sample-apps/aws-sdk/wrapper/SampleApps/AwsSdkSample/obj/Debug/net6.0/AwsSdkSample.AssemblyInfoInputs.cache @@ -0,0 +1 @@ +766949263e8a8f9c07f9f90c7d9f45f957c0dea974afedf43932e4de958a7f52 diff --git a/e2e_tests/e2e_helpers_test.go b/e2e_tests/e2e_helpers_test.go new file mode 100644 index 0000000000..108b9ad589 --- /dev/null +++ b/e2e_tests/e2e_helpers_test.go @@ -0,0 +1,460 @@ +//go:build e2e + +package e2e + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "os" + "strings" + "testing" + "time" + + "github.com/sirupsen/logrus" + "github.com/stretchr/testify/require" +) + +var e2eLogger = logrus.WithField("test_type", "e2e") + +var ( + logzioLogsQueryAPIKey = os.Getenv("LOGZIO_API_KEY") + logzioAPIURL = os.Getenv("LOGZIO_API_URL") + e2eTestEnvironmentLabel = os.Getenv("E2E_TEST_ENVIRONMENT_LABEL") + logzioMetricsQueryAPIKey = os.Getenv("LOGZIO_API_METRICS_KEY") + logzioMetricsQueryBaseURL = os.Getenv("LOGZIO_METRICS_QUERY_URL") + logzioTracesQueryAPIKey = os.Getenv("LOGZIO_API_TRACES_KEY") +) + +var ( + totalBudgetSeconds = 400 + testStartTime time.Time + timeSpentMetrics time.Duration + timeSpentLogs time.Duration + timeSpentTraces time.Duration +) + +func initTimeTracking() { + testStartTime = time.Now() + timeSpentMetrics = 0 + timeSpentLogs = 0 + timeSpentTraces = 0 +} + +func getRemainingBudgetSeconds() int { + elapsed := time.Since(testStartTime) + remaining := time.Duration(totalBudgetSeconds)*time.Second - elapsed + return max(0, int(remaining.Seconds())) +} + +func getDynamicRetryConfig(testType string) (maxRetries int, retryDelay time.Duration) { + defaultMaxRetries := 30 + defaultRetryDelay := 10 * time.Second + + remainingBudget := getRemainingBudgetSeconds() + retryDelay = defaultRetryDelay + + var allocatedBudgetPortion float64 + switch testType { + case "metrics": + allocatedBudgetPortion = 0.1 + case "logs": + allocatedBudgetPortion = 0.6 + case "traces": + allocatedBudgetPortion = 0.3 + default: + allocatedBudgetPortion = 0.2 + } + + var effectiveBudget int + if timeSpentMetrics == 0 && timeSpentLogs == 0 && timeSpentTraces == 0 { + effectiveBudget = int(float64(totalBudgetSeconds) * allocatedBudgetPortion) + } else { + effectiveBudget = int(float64(remainingBudget) * allocatedBudgetPortion) + } + + effectiveBudget = max(effectiveBudget, int(defaultRetryDelay.Seconds())*2+1) + + maxRetries = effectiveBudget / int(defaultRetryDelay.Seconds()) + maxRetries = max(2, min(maxRetries, defaultMaxRetries)) + + e2eLogger.Infof("Time budget for %s: %d attempts (delay %s). Total remaining: %ds. Effective budget for this test: %ds", testType, maxRetries, retryDelay, remainingBudget, effectiveBudget) + return maxRetries, retryDelay +} + +func recordTimeSpent(testType string, duration time.Duration) { + switch testType { + case "metrics": + timeSpentMetrics += duration + case "logs": + timeSpentLogs += duration + case "traces": + timeSpentTraces += duration + } + total := timeSpentMetrics + timeSpentLogs + timeSpentTraces + e2eLogger.Infof("Time spent - Metrics: %.1fs, Logs: %.1fs, Traces: %.1fs, Total: %.1fs/%ds", timeSpentMetrics.Seconds(), timeSpentLogs.Seconds(), timeSpentTraces.Seconds(), total.Seconds(), totalBudgetSeconds) +} + +const ( + apiTimeout = 45 * time.Second + searchLookback = "30m" +) + +var ErrNoDataFoundAfterRetries = errors.New("no data found after all retries") + +func skipIfEnvVarsMissing(t *testing.T, testName string) { + baseRequired := []string{"E2E_TEST_ENVIRONMENT_LABEL"} + specificRequiredMissing := false + + if strings.Contains(testName, "Logs") || strings.Contains(testName, "E2ELogsTest") { + if logzioLogsQueryAPIKey == "" { + e2eLogger.Errorf("Skipping E2E Log test %s: Missing LOGZIO_API_KEY.", testName) + t.Skipf("Skipping E2E Log test %s: Missing LOGZIO_API_KEY.", testName) + specificRequiredMissing = true + } + if logzioAPIURL == "" { + e2eLogger.Errorf("Skipping E2E Log test %s: Missing LOGZIO_API_URL.", testName) + t.Skipf("Skipping E2E Log test %s: Missing LOGZIO_API_URL.", testName) + specificRequiredMissing = true + } + } + if strings.Contains(testName, "Metrics") || strings.Contains(testName, "E2EMetricsTest") { + if logzioMetricsQueryAPIKey == "" { + e2eLogger.Errorf("Skipping E2E Metrics test %s: Missing LOGZIO_API_METRICS_KEY.", testName) + t.Skipf("Skipping E2E Metrics test %s: Missing LOGZIO_API_METRICS_KEY.", testName) + specificRequiredMissing = true + } + if logzioMetricsQueryBaseURL == "" { + e2eLogger.Errorf("Skipping E2E Metrics test %s: Missing LOGZIO_METRICS_QUERY_URL.", testName) + t.Skipf("Skipping E2E Metrics test %s: Missing LOGZIO_METRICS_QUERY_URL.", testName) + specificRequiredMissing = true + } + } + if strings.Contains(testName, "Traces") || strings.Contains(testName, "E2ETracesTest") { + if logzioTracesQueryAPIKey == "" { + e2eLogger.Errorf("Skipping E2E Traces test %s: Missing required environment variable LOGZIO_API_TRACES_KEY.", testName) + t.Skipf("Skipping E2E Traces test %s: Missing required environment variable LOGZIO_API_TRACES_KEY.", testName) + specificRequiredMissing = true + } + } + + if specificRequiredMissing { + return + } + + for _, v := range baseRequired { + if os.Getenv(v) == "" { + e2eLogger.Errorf("Skipping E2E test %s: Missing base required environment variable %s.", testName, v) + t.Skipf("Skipping E2E test %s: Missing base required environment variable %s.", testName, v) + return + } + } +} + +type logzioSearchQueryBody struct { + Query map[string]interface{} `json:"query"` + From int `json:"from"` + Size int `json:"size"` + Sort []interface{} `json:"sort"` + Source interface{} `json:"_source"` + PostFilter interface{} `json:"post_filter,omitempty"` + DocvalueFields []string `json:"docvalue_fields"` + Version bool `json:"version"` + StoredFields []string `json:"stored_fields"` + Highlight map[string]interface{} `json:"highlight"` + Aggregations map[string]interface{} `json:"aggregations,omitempty"` +} + +type logzioSearchResponse struct { + Hits struct { + Total json.RawMessage `json:"total"` + Hits []struct { + Source map[string]interface{} `json:"_source"` + Sort []interface{} `json:"sort"` + } `json:"hits"` + } `json:"hits"` + Error *struct { + Reason string `json:"reason"` + } `json:"error,omitempty"` +} + +func (r *logzioSearchResponse) getTotalHits() int { + if len(r.Hits.Total) == 0 { + return 0 + } + var totalInt int + if err := json.Unmarshal(r.Hits.Total, &totalInt); err == nil { + return totalInt + } + var totalObj struct { + Value int `json:"value"` + } + if err := json.Unmarshal(r.Hits.Total, &totalObj); err == nil { + return totalObj.Value + } + e2eLogger.Warnf("Could not determine total hits from raw message: %s", string(r.Hits.Total)) + return 0 +} + +func fetchLogzSearchAPI(t *testing.T, apiKey, queryBaseAPIURL, luceneQuery string, testType string) (*logzioSearchResponse, error) { + maxRetries, retryDelay := getDynamicRetryConfig(testType) + return fetchLogzSearchAPIWithRetries(t, apiKey, queryBaseAPIURL, luceneQuery, maxRetries, retryDelay) +} + +func fetchLogzSearchAPIWithRetries(t *testing.T, apiKey, queryBaseAPIURL, luceneQuery string, maxRetries int, retryDelay time.Duration) (*logzioSearchResponse, error) { + searchAPIEndpoint := fmt.Sprintf("%s/v1/search", strings.TrimSuffix(queryBaseAPIURL, "/")) + + // Build request body per Logz.io Search API example + queryBodyMap := logzioSearchQueryBody{ + Query: map[string]interface{}{ + "bool": map[string]interface{}{ + "must": []interface{}{ + map[string]interface{}{ + "query_string": map[string]interface{}{ + "query": luceneQuery, + "allow_leading_wildcard": false, + }, + }, + map[string]interface{}{ + "range": map[string]interface{}{ + "@timestamp": map[string]interface{}{ + "gte": "now-30m", + "lte": "now", + }, + }, + }, + }, + }, + }, + From: 0, + Size: 100, + Sort: []interface{}{map[string]interface{}{}}, + Source: true, + PostFilter: nil, + DocvalueFields: []string{"@timestamp"}, + Version: true, + StoredFields: []string{"*"}, + Highlight: map[string]interface{}{}, + Aggregations: map[string]interface{}{ + "byType": map[string]interface{}{ + "terms": map[string]interface{}{ + "field": "type", + "size": 5, + }, + }, + }, + } + queryBytes, err := json.Marshal(queryBodyMap) + require.NoError(t, err) + + // Debug: Log the actual JSON query being sent + e2eLogger.Debugf("Logz.io search query JSON: %s", string(queryBytes)) + + var lastErr error + + for i := 0; i < maxRetries; i++ { + e2eLogger.Infof("Attempt %d/%d to fetch Logz.io search results (Query: %s)...", i+1, maxRetries, luceneQuery) + req, err := http.NewRequest("POST", searchAPIEndpoint, bytes.NewBuffer(queryBytes)) + require.NoError(t, err) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + req.Header.Set("X-API-TOKEN", apiKey) + client := &http.Client{Timeout: apiTimeout} + resp, err := client.Do(req) + if err != nil { + lastErr = fmt.Errorf("API request failed on attempt %d: %w", i+1, err) + e2eLogger.Warnf("%v. Retrying in %s...", lastErr, retryDelay) + if i < maxRetries-1 { + time.Sleep(retryDelay) + } + continue + } + respBodyBytes, readErr := io.ReadAll(resp.Body) + resp.Body.Close() + if readErr != nil { + lastErr = fmt.Errorf("failed to read API response body on attempt %d: %w", i+1, readErr) + e2eLogger.Warnf("%v. Retrying in %s...", lastErr, retryDelay) + if i < maxRetries-1 { + time.Sleep(retryDelay) + } + continue + } + if resp.StatusCode != http.StatusOK { + lastErr = fmt.Errorf("API returned status %d on attempt %d: %s", resp.StatusCode, i+1, string(respBodyBytes)) + e2eLogger.Warnf("%v. Retrying in %s...", lastErr, retryDelay) + e2eLogger.Debugf("Failed request body was: %s", string(queryBytes)) + if i < maxRetries-1 { + time.Sleep(retryDelay) + } + continue + } + var logResponse logzioSearchResponse + unmarshalErr := json.Unmarshal(respBodyBytes, &logResponse) + if unmarshalErr != nil { + lastErr = fmt.Errorf("failed to unmarshal API response on attempt %d: %w. Body: %s", i+1, unmarshalErr, string(respBodyBytes)) + e2eLogger.Warnf("%v. Retrying in %s...", lastErr, retryDelay) + if i < maxRetries-1 { + time.Sleep(retryDelay) + } + continue + } + if logResponse.Error != nil { + lastErr = fmt.Errorf("Logz.io API error in response on attempt %d: %s", i+1, logResponse.Error.Reason) + if strings.Contains(logResponse.Error.Reason, "parse_exception") || strings.Contains(logResponse.Error.Reason, "query_shard_exception") { + e2eLogger.Errorf("Non-retryable API error encountered: %v", lastErr) + return nil, lastErr + } + e2eLogger.Warnf("%v. Retrying in %s...", lastErr, retryDelay) + if i < maxRetries-1 { + time.Sleep(retryDelay) + } + continue + } + if logResponse.getTotalHits() > 0 { + e2eLogger.Infof("Attempt %d successful. Found %d total hits.", i+1, logResponse.getTotalHits()) + return &logResponse, nil + } + lastErr = fmt.Errorf("attempt %d/%d: no data found for query '%s'", i+1, maxRetries, luceneQuery) + e2eLogger.Infof("%s. Retrying in %s...", lastErr.Error(), retryDelay) + if i < maxRetries-1 { + time.Sleep(retryDelay) + } + } + e2eLogger.Warnf("No data found for query '%s' after %d retries.", luceneQuery, maxRetries) + return nil, ErrNoDataFoundAfterRetries +} + +type logzioPrometheusResponse struct { + Status string `json:"status"` + Data struct { + ResultType string `json:"resultType"` + Result []struct { + Metric map[string]string `json:"metric"` + Value []interface{} `json:"value"` + } `json:"result"` + } `json:"data"` + ErrorType string `json:"errorType,omitempty"` + Error string `json:"error,omitempty"` +} + +func fetchLogzMetricsAPI(t *testing.T, apiKey, metricsAPIBaseURL, promqlQuery string) (*logzioPrometheusResponse, error) { + maxRetries, retryDelay := getDynamicRetryConfig("metrics") + return fetchLogzMetricsAPIWithRetries(t, apiKey, metricsAPIBaseURL, promqlQuery, maxRetries, retryDelay) +} + +func fetchLogzMetricsAPIWithRetries(t *testing.T, apiKey, metricsAPIBaseURL, promqlQuery string, maxRetries int, retryDelay time.Duration) (*logzioPrometheusResponse, error) { + queryAPIEndpoint := buildMetricsQueryEndpoint(metricsAPIBaseURL, "query", promqlQuery) + var lastErr error + + for i := 0; i < maxRetries; i++ { + e2eLogger.Infof("Attempt %d/%d to fetch Logz.io metrics (Query: %s)...", i+1, maxRetries, promqlQuery) + req, err := http.NewRequest("GET", queryAPIEndpoint, nil) + if err != nil { + return nil, fmt.Errorf("metrics API request creation failed: %w", err) + } + req.Header.Set("Accept", "application/json") + req.Header.Set("X-API-TOKEN", apiKey) + + client := &http.Client{Timeout: apiTimeout} + resp, err := client.Do(req) + if err != nil { + lastErr = fmt.Errorf("metrics API request failed on attempt %d: %w", i+1, err) + e2eLogger.Warnf("%v. Retrying in %s...", lastErr, retryDelay) + if i < maxRetries-1 { + time.Sleep(retryDelay) + } + continue + } + respBodyBytes, readErr := io.ReadAll(resp.Body) + resp.Body.Close() + if readErr != nil { + lastErr = fmt.Errorf("failed to read metrics API response body on attempt %d: %w", i+1, readErr) + e2eLogger.Warnf("%v. Retrying in %s...", lastErr, retryDelay) + if i < maxRetries-1 { + time.Sleep(retryDelay) + } + continue + } + if resp.StatusCode != http.StatusOK { + lastErr = fmt.Errorf("metrics API returned status %d on attempt %d: %s", resp.StatusCode, i+1, string(respBodyBytes)) + e2eLogger.Warnf("%v. Retrying in %s...", lastErr, retryDelay) + if i < maxRetries-1 { + time.Sleep(retryDelay) + } + continue + } + var metricResponse logzioPrometheusResponse + unmarshalErr := json.Unmarshal(respBodyBytes, &metricResponse) + if unmarshalErr != nil { + lastErr = fmt.Errorf("failed to unmarshal metrics API response on attempt %d: %w. Body: %s", i+1, unmarshalErr, string(respBodyBytes)) + e2eLogger.Warnf("%v. Retrying in %s...", lastErr, retryDelay) + if i < maxRetries-1 { + time.Sleep(retryDelay) + } + continue + } + if metricResponse.Status != "success" { + lastErr = fmt.Errorf("Logz.io Metrics API returned status '%s' on attempt %d, ErrorType: '%s', Error: '%s'", metricResponse.Status, i+1, metricResponse.ErrorType, metricResponse.Error) + e2eLogger.Warnf("%v. Retrying in %s...", lastErr, retryDelay) + if i < maxRetries-1 { + time.Sleep(retryDelay) + } + continue + } + if len(metricResponse.Data.Result) > 0 { + e2eLogger.Infof("Attempt %d successful. Found %d metric series.", i+1, len(metricResponse.Data.Result)) + return &metricResponse, nil + } + lastErr = fmt.Errorf("attempt %d/%d: no data found for query '%s'", i+1, maxRetries, promqlQuery) + e2eLogger.Infof("%s. Retrying in %s...", lastErr.Error(), retryDelay) + if i < maxRetries-1 { + time.Sleep(retryDelay) + } + } + e2eLogger.Warnf("No data found for query '%s' after %d retries.", promqlQuery, maxRetries) + return nil, ErrNoDataFoundAfterRetries +} + +// buildMetricsQueryEndpoint normalizes base URL to Logz.io public Prometheus API format. +// It accepts either a root API URL (e.g., https://api.logz.io) or a URL that already +// contains the "/v1/metrics/prometheus" prefix, and constructs: +// {root}/v1/metrics/prometheus/api/v1/{apiPath}?query={promql} +func buildMetricsQueryEndpoint(baseURL string, apiPath string, promqlQuery string) string { + trimmedBase := strings.TrimSuffix(baseURL, "/") + if !strings.Contains(trimmedBase, "/metrics/prometheus") { + trimmedBase = trimmedBase + "/v1/metrics/prometheus" + } + return fmt.Sprintf("%s/api/v1/%s?query=%s", trimmedBase, apiPath, url.QueryEscape(promqlQuery)) +} + +func getNestedValue(data map[string]interface{}, path ...string) interface{} { + var current interface{} = data + for _, key := range path { + m, ok := current.(map[string]interface{}) + if !ok { + return nil + } + current, ok = m[key] + if !ok { + return nil + } + } + return current +} + +func min(a, b int) int { + if a < b { + return a + } + return b +} +func max(a, b int) int { + if a > b { + return a + } + return b +} diff --git a/e2e_tests/e2e_log_test.go b/e2e_tests/e2e_log_test.go new file mode 100644 index 0000000000..fb0aed397d --- /dev/null +++ b/e2e_tests/e2e_log_test.go @@ -0,0 +1,98 @@ +//go:build e2e + +package e2e + +import ( + "encoding/json" + "fmt" + "os" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestE2ELogs(t *testing.T) { + skipIfEnvVarsMissing(t, t.Name()) + e2eLogger.Infof("Starting E2E Log Test for environment label: %s", e2eTestEnvironmentLabel) + + expectedServiceName := os.Getenv("EXPECTED_SERVICE_NAME") + require.NotEmpty(t, expectedServiceName, "EXPECTED_SERVICE_NAME environment variable must be set for log tests") + expectedFaasName := os.Getenv("EXPECTED_LAMBDA_FUNCTION_NAME") + require.NotEmpty(t, expectedFaasName, "EXPECTED_LAMBDA_FUNCTION_NAME must be set for log tests") + + // Query for logs from our function - start with basic search + baseQuery := fmt.Sprintf(`faas.name:"%s"`, expectedFaasName) + + logChecks := []struct { + name string + mustContain string + assertion func(t *testing.T, hits []map[string]interface{}) + }{ + { + name: "telemetry_api_subscription", + mustContain: `"Successfully subscribed to Telemetry API"`, + assertion: func(t *testing.T, hits []map[string]interface{}) { + assert.GreaterOrEqual(t, len(hits), 1, "Should find telemetry API subscription log") + hit := hits[0] + var got any + if v, ok := hit["faas.name"]; ok { + got = v + } else { + got = getNestedValue(hit, "faas", "name") + } + assert.Equal(t, expectedFaasName, got) + }, + }, + { + name: "function_invocation_log", + mustContain: `"Lambda invocation started"`, + assertion: func(t *testing.T, hits []map[string]interface{}) { + assert.GreaterOrEqual(t, len(hits), 1, "Should find function invocation start log") + hit := hits[0] + var got any + if v, ok := hit["faas.name"]; ok { + got = v + } else { + got = getNestedValue(hit, "faas", "name") + } + assert.Equal(t, expectedFaasName, got) + }, + }, + } + + allChecksPassed := true + + for _, check := range logChecks { + t.Run(check.name, func(t *testing.T) { + query := fmt.Sprintf(`%s AND %s`, baseQuery, check.mustContain) + e2eLogger.Infof("Querying for logs: %s", query) + + logResponse, err := fetchLogzSearchAPI(t, logzioLogsQueryAPIKey, logzioAPIURL, query, "logs") + if err != nil { + e2eLogger.Errorf("Failed to fetch logs for check '%s' after all retries: %v", check.name, err) + allChecksPassed = false + t.Fail() + return + } + + require.NotNil(t, logResponse, "Log response should not be nil if error is nil for check '%s'", check.name) + + var sources []map[string]interface{} + for _, hit := range logResponse.Hits.Hits { + sources = append(sources, hit.Source) + if len(sources) <= 2 { + logSample, _ := json.Marshal(hit.Source) + e2eLogger.Debugf("Sample log for check '%s': %s", check.name, string(logSample)) + } + } + + if check.assertion != nil { + check.assertion(t, sources) + } + }) + } + + require.True(t, allChecksPassed, "One or more E2E log checks failed.") + e2eLogger.Info("E2E Log Test Completed Successfully.") +} diff --git a/e2e_tests/e2e_metric_test.go b/e2e_tests/e2e_metric_test.go new file mode 100644 index 0000000000..04bd955a71 --- /dev/null +++ b/e2e_tests/e2e_metric_test.go @@ -0,0 +1,72 @@ +//go:build e2e + +package e2e + +import ( + "errors" + "fmt" + "os" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestE2EMetrics(t *testing.T) { + skipIfEnvVarsMissing(t, t.Name()) + e2eLogger.Infof("Starting E2E Metrics Test for environment: %s", e2eTestEnvironmentLabel) + + expectedServiceName := os.Getenv("EXPECTED_SERVICE_NAME") + require.NotEmpty(t, expectedServiceName, "EXPECTED_SERVICE_NAME environment variable must be set") + + // We'll validate two representative metrics visible in Logz.io Grafana + metricsToCheck := []string{"aws_lambda_billedDurationMs_milliseconds"} + + // Java agent metric names/units differ (seconds vs milliseconds) and HTTP client metrics + // may be disabled by default. Make the HTTP client metric optional for Java runtime. + isJava := os.Getenv("EXPECTED_LAMBDA_FUNCTION_NAME") == "one-layer-e2e-test-java" || + os.Getenv("EXPECTED_SERVICE_NAME") == "logzio-e2e-java-service" + // Ruby's E2E may not emit http_client metrics consistently; keep it optional like Java. + isRuby := os.Getenv("EXPECTED_LAMBDA_FUNCTION_NAME") == "one-layer-e2e-test-ruby" || + os.Getenv("EXPECTED_SERVICE_NAME") == "logzio-e2e-ruby-service" + if !isJava && !isRuby { + metricsToCheck = append(metricsToCheck, "http_client_duration_milliseconds_count") + } + + for _, metricName := range metricsToCheck { + promql := fmt.Sprintf(`%s{job="%s"}`, metricName, expectedServiceName) + e2eLogger.Infof("Querying metrics: %s", promql) + + metricResponse, err := fetchLogzMetricsAPI(t, logzioMetricsQueryAPIKey, logzioMetricsQueryBaseURL, promql) + if err != nil { + if errors.Is(err, ErrNoDataFoundAfterRetries) { + t.Fatalf("Failed to find metrics after all retries for query '%s': %v", promql, err) + } else { + t.Fatalf("Error fetching metrics for query '%s': %v", promql, err) + } + } + require.NotNil(t, metricResponse, "Metric response should not be nil if error is nil") + require.Equal(t, "success", metricResponse.Status, "Metric API status should be success") + require.GreaterOrEqual(t, len(metricResponse.Data.Result), 1, "Should find at least one series for %s with job=%s", metricName, expectedServiceName) + + first := metricResponse.Data.Result[0] + labels := first.Metric + assert.Equal(t, metricName, labels["__name__"], "expected __name__ label to match metric name %s", metricName) + assert.Equal(t, expectedServiceName, labels["job"], "metric %s should have job=%s", metricName, expectedServiceName) + + if metricName == "http_client_duration_milliseconds_count" { + // Optional helpful context if present + if v := labels["http_host"]; v != "" { + e2eLogger.Infof("http_host=%s", v) + } + if v := labels["http_method"]; v != "" { + e2eLogger.Infof("http_method=%s", v) + } + if v := labels["http_status_code"]; v != "" { + e2eLogger.Infof("http_status_code=%s", v) + } + } + } + + e2eLogger.Info("E2E Metrics Test: Specific metric validation successful.") +} diff --git a/e2e_tests/e2e_runner_test.go b/e2e_tests/e2e_runner_test.go new file mode 100644 index 0000000000..55143ad64a --- /dev/null +++ b/e2e_tests/e2e_runner_test.go @@ -0,0 +1,61 @@ +//go:build e2e + +package e2e + +import ( + "testing" + "time" +) + +func TestE2ERunner(t *testing.T) { + e2eLogger.Info("E2E Test Runner: Waiting 60 seconds for initial Lambda execution and data ingestion before starting tests...") + time.Sleep(60 * time.Second) + + initTimeTracking() + e2eLogger.Infof("E2E Test Runner starting with a total budget of %d seconds.", totalBudgetSeconds) + e2eLogger.Info("Tests will run in order: Metrics -> Logs -> Traces.") + + t.Run("E2EMetricsTest", func(t *testing.T) { + e2eLogger.Info("=== Starting E2E Metrics Test ===") + startTime := time.Now() + TestE2EMetrics(t) + duration := time.Since(startTime) + recordTimeSpent("metrics", duration) + e2eLogger.Infof("=== E2E Metrics Test completed in %.1f seconds ===", duration.Seconds()) + }) + + if t.Failed() { + e2eLogger.Error("Metrics test or previous setup failed. Subsequent tests might be affected or also fail.") + } + + t.Run("E2ELogsTest", func(t *testing.T) { + e2eLogger.Info("=== Starting E2E Logs Test ===") + startTime := time.Now() + TestE2ELogs(t) + duration := time.Since(startTime) + recordTimeSpent("logs", duration) + e2eLogger.Infof("=== E2E Logs Test completed in %.1f seconds ===", duration.Seconds()) + }) + + if t.Failed() { + e2eLogger.Error("Logs test or previous setup/tests failed. Subsequent tests might be affected or also fail.") + } + + t.Run("E2ETracesTest", func(t *testing.T) { + e2eLogger.Info("=== Starting E2E Traces Test ===") + startTime := time.Now() + TestE2ETraces(t) + duration := time.Since(startTime) + recordTimeSpent("traces", duration) + e2eLogger.Infof("=== E2E Traces Test completed in %.1f seconds ===", duration.Seconds()) + }) + + totalElapsed := time.Since(testStartTime) + e2eLogger.Infof("E2E Test Runner finished all tests in %.1f seconds. Remaining budget: %ds", totalElapsed.Seconds(), getRemainingBudgetSeconds()) + + if t.Failed() { + e2eLogger.Error("One or more E2E tests failed.") + } else { + e2eLogger.Info("All E2E tests passed successfully!") + } +} diff --git a/e2e_tests/e2e_trace_test.go b/e2e_tests/e2e_trace_test.go new file mode 100644 index 0000000000..ed95fd0ac8 --- /dev/null +++ b/e2e_tests/e2e_trace_test.go @@ -0,0 +1,85 @@ +//go:build e2e + +package e2e + +import ( + "fmt" + "os" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestE2ETraces(t *testing.T) { + skipIfEnvVarsMissing(t, t.Name()) + e2eLogger.Infof("Starting E2E Trace Test for environment: %s", e2eTestEnvironmentLabel) + + tracesQueryKey := logzioTracesQueryAPIKey + expectedFaasName := os.Getenv("EXPECTED_LAMBDA_FUNCTION_NAME") + require.NotEmpty(t, expectedFaasName, "EXPECTED_LAMBDA_FUNCTION_NAME must be set") + expectedServiceName := os.Getenv("EXPECTED_SERVICE_NAME") + require.NotEmpty(t, expectedServiceName, "EXPECTED_SERVICE_NAME must be set") + + e2eLogger.Infof("Expecting traces for service: %s, function: %s, environment: %s", expectedServiceName, expectedFaasName, e2eTestEnvironmentLabel) + + // Some Java client spans may miss faas.name in processed documents. Keep server strict, relax client for Java. + isJava := os.Getenv("EXPECTED_LAMBDA_FUNCTION_NAME") == "one-layer-e2e-test-java" || + os.Getenv("EXPECTED_SERVICE_NAME") == "logzio-e2e-java-service" + + // Go handler emits an internal span from scope "logzio-go-lambda-example" for the HTTP call. + // Accept internal spans from that scope as valid client activity and relax faas.name like Java. + isGo := os.Getenv("EXPECTED_LAMBDA_FUNCTION_NAME") == "one-layer-e2e-test-go" || + os.Getenv("EXPECTED_SERVICE_NAME") == "logzio-e2e-go-service" + + // Ruby Net::HTTP instrumentation may emit internal spans (e.g., "connect"). + // Accept either client spans or internal spans specifically from Net::HTTP. + isRuby := os.Getenv("EXPECTED_LAMBDA_FUNCTION_NAME") == "one-layer-e2e-test-ruby" || + os.Getenv("EXPECTED_SERVICE_NAME") == "logzio-e2e-ruby-service" + + baseQueryWithFaas := fmt.Sprintf(`type:jaegerSpan AND process.serviceName:"%s" AND process.tag.faas@name:"%s"`, expectedServiceName, expectedFaasName) + baseQueryServiceOnly := fmt.Sprintf(`type:jaegerSpan AND process.serviceName:"%s"`, expectedServiceName) + + // Verify at least one platform/server span exists (must include faas name) + serverQuery := baseQueryWithFaas + " AND JaegerTag.span@kind:server" + e2eLogger.Infof("Querying for server span: %s", serverQuery) + serverResp, err := fetchLogzSearchAPI(t, tracesQueryKey, logzioAPIURL, serverQuery, "traces") + require.NoError(t, err, "Failed to find server span after all retries.") + require.NotNil(t, serverResp) + require.GreaterOrEqual(t, serverResp.getTotalHits(), 1, "Should find at least one server span.") + serverHit := serverResp.Hits.Hits[0].Source + assert.Equal(t, expectedServiceName, getNestedValue(serverHit, "process", "serviceName")) + assert.Equal(t, expectedFaasName, getNestedValue(serverHit, "process", "tag", "faas@name")) + + // Verify at least one custom/client span exists + // Verify at least one client span exists + clientBase := baseQueryWithFaas + if isJava || isGo { + // Relax for Java: some client spans may not carry faas.name + // Also relax for Go: internal spans from the custom scope may not include faas.name + clientBase = baseQueryServiceOnly + } + var clientQuery string + if isRuby { + clientQuery = clientBase + " AND (JaegerTag.span@kind:client OR (JaegerTag.span@kind:internal AND JaegerTag.otel@scope@name:\"OpenTelemetry::Instrumentation::Net::HTTP\"))" + } else if isGo { + clientQuery = clientBase + " AND (JaegerTag.span@kind:client OR (JaegerTag.span@kind:internal AND JaegerTag.otel@scope@name:\"logzio-go-lambda-example\"))" + } else { + clientQuery = clientBase + " AND JaegerTag.span@kind:client" + } + e2eLogger.Infof("Querying for client spans: %s", clientQuery) + clientResp, err := fetchLogzSearchAPI(t, tracesQueryKey, logzioAPIURL, clientQuery, "traces") + require.NoError(t, err, "Failed to find client spans after all retries.") + require.NotNil(t, clientResp) + require.GreaterOrEqual(t, clientResp.getTotalHits(), 1, "Should find at least one client span.") + + clientHit := clientResp.Hits.Hits[0].Source + if m := getNestedValue(clientHit, "JaegerTag.http@method"); m != nil { + e2eLogger.Infof("Client span HTTP method: %v", m) + } + if sc := getNestedValue(clientHit, "JaegerTag.http@status_code"); sc != nil { + e2eLogger.Infof("Client span HTTP status: %v", sc) + } + + e2eLogger.Info("E2E Trace Test Completed Successfully.") +} diff --git a/e2e_tests/go.mod b/e2e_tests/go.mod new file mode 100644 index 0000000000..a79d1b1ffd --- /dev/null +++ b/e2e_tests/go.mod @@ -0,0 +1,10 @@ +module e2e-python + +go 1.21 + +require ( + github.com/sirupsen/logrus v1.9.3 + github.com/stretchr/testify v1.9.0 +) + + diff --git a/go/README.md b/go/README.md index a44b2ec989..4d7a5b667e 100644 --- a/go/README.md +++ b/go/README.md @@ -13,4 +13,4 @@ For other instrumentations, such as http, you'll need to include the correspondi ## Sample application -The [sample application](https://github.com/open-telemetry/opentelemetry-lambda/tree/main/go/sample-apps/function/function.go) shows the manual instrumentations of OpenTelemetry Lambda Go SDK on a Lambda handler that triggers downstream requests to AWS S3 and HTTP. +The [sample application](https://github.com/open-telemetry/opentelemetry-lambda/tree/main/go/sample-apps/function/function.go) shows the manual instrumentations of OpenTelemetry Lambda Go SDK on a Lambda handler that triggers downstream requests to AWS S3 and HTTP. \ No newline at end of file diff --git a/go/build-combined.sh b/go/build-combined.sh new file mode 100755 index 0000000000..648a980234 --- /dev/null +++ b/go/build-combined.sh @@ -0,0 +1,59 @@ +#!/bin/bash + +# Build Go extension layer (collector-only) +# Go uses manual instrumentation. This script builds only the custom collector +# and packages it into a Lambda layer zip. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +BUILD_DIR="$SCRIPT_DIR/build" +COLLECTOR_DIR="$SCRIPT_DIR/../collector" +ARCHITECTURE="${ARCHITECTURE:-amd64}" + +# Pre-flight checks +require_cmd() { command -v "$1" >/dev/null 2>&1 || { echo "Error: '$1' is required but not installed." >&2; exit 1; }; } +require_cmd unzip +require_cmd zip + +echo "Building combined Go extension layer..." + +# Clean and create directories +rm -rf "$BUILD_DIR" +mkdir -p "$BUILD_DIR/combined-layer" + +echo "Step 1: Building collector..." +# Build the collector +cd "$COLLECTOR_DIR" +make build GOARCH="$ARCHITECTURE" +cd "$SCRIPT_DIR" + +# Copy collector files to combined layer +echo "Copying collector to combined layer..." +mkdir -p "$BUILD_DIR/combined-layer/extensions" +mkdir -p "$BUILD_DIR/combined-layer/collector-config" +cp "$COLLECTOR_DIR/build/extensions"/* "$BUILD_DIR/combined-layer/extensions/" +cp "$COLLECTOR_DIR/config"* "$BUILD_DIR/combined-layer/collector-config/" + + +echo "Step 2: Creating combined layer package..." +# Package so that zip root maps directly to /opt (do NOT include an extra top-level opt/) +cd "$BUILD_DIR/combined-layer" + +# Create version info file at the layer root (becomes /opt/build-info.txt) +{ +echo "Combined layer built on $(date)" +echo "Architecture: $ARCHITECTURE" +echo "Collector version: $(cat "$COLLECTOR_DIR/VERSION" 2>/dev/null || echo 'unknown')" +echo "Note: Go uses manual instrumentation - this layer provides the collector for Go applications" +} > build-info.txt + +# Zip the contents of combined-layer so that extensions/ -> /opt/extensions and collector-config/ -> /opt/collector-config +zip -qr ../otel-go-extension-layer.zip . +cd "$SCRIPT_DIR" + +echo "Combined Go extension layer created: $BUILD_DIR/otel-go-extension-layer.zip" +echo "Layer contents:" +unzip -l "$BUILD_DIR/otel-go-extension-layer.zip" | head -20 || true + +echo "Build completed successfully!" \ No newline at end of file diff --git a/java/README.md b/java/README.md index 402d306d9f..a046299352 100644 --- a/java/README.md +++ b/java/README.md @@ -99,4 +99,4 @@ Sample applications are provided to show usage the above layers. - [Application using OkHttp](./sample-apps/okhttp) - shows the manual initialization of OkHttp library instrumentation for use with the wrapper. The agent would be usable without such a code change - at the expense of the cold start overhead it introduces. + at the expense of the cold start overhead it introduces. \ No newline at end of file diff --git a/java/build-combined.sh b/java/build-combined.sh new file mode 100755 index 0000000000..39c4bcb355 --- /dev/null +++ b/java/build-combined.sh @@ -0,0 +1,96 @@ +#!/bin/bash + +# Production-ready script to build a combined Java extension layer. +# This script combines our custom collector with the Java instrumentation +# built directly from the source code in this repository. + +set -euo pipefail + +# --- Script Setup --- +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +BUILD_DIR="$SCRIPT_DIR/build" +WORKSPACE_DIR="$BUILD_DIR/workspace" +# Collector is a sibling directory of `java/` +COLLECTOR_DIR="$SCRIPT_DIR/../collector" +# Navigate to the Java source directory (where gradlew lives) +JAVA_SRC_DIR="$SCRIPT_DIR" +ARCHITECTURE="${ARCHITECTURE:-amd64}" + +# Pre-flight checks +require_cmd() { command -v "$1" >/dev/null 2>&1 || { echo "Error: '$1' is required but not installed." >&2; exit 1; }; } +require_cmd unzip +require_cmd zip + +if [[ ! -e "$JAVA_SRC_DIR/gradlew" ]]; then + echo "Error: gradlew not found at $JAVA_SRC_DIR/gradlew" >&2 + exit 1 +fi +chmod +x "$JAVA_SRC_DIR/gradlew" || true + +echo "Building combined Java extension layer (Arch: $ARCHITECTURE)..." + +# 1. Clean and prepare the build environment +echo "--> Cleaning up previous build artifacts..." +rm -rf "$BUILD_DIR" +mkdir -p "$WORKSPACE_DIR" + +# 2. Build the Java instrumentation layers from source +echo "--> Building Java instrumentation layers from source..." +# The parentheses run this in a subshell, so we don't have to cd back. +( + cd "$JAVA_SRC_DIR" + # Use gradle to build the agent and wrapper layers + ./gradlew :layer-javaagent:build :layer-wrapper:build +) +echo "Java instrumentation build successful." + +# 3. Extract the newly built layers into the workspace +echo "--> Extracting instrumentation layers..." +AGENT_ZIP="$JAVA_SRC_DIR/layer-javaagent/build/distributions/opentelemetry-javaagent-layer.zip" +WRAPPER_ZIP="$JAVA_SRC_DIR/layer-wrapper/build/distributions/opentelemetry-javawrapper-layer.zip" + +if [[ ! -f "$AGENT_ZIP" ]]; then + echo "Error: Expected artifact not found: $AGENT_ZIP" >&2 + exit 1 +fi +if [[ ! -f "$WRAPPER_ZIP" ]]; then + echo "Error: Expected artifact not found: $WRAPPER_ZIP" >&2 + exit 1 +fi + +unzip -oq -d "$WORKSPACE_DIR" "$AGENT_ZIP" +unzip -oq -d "$WORKSPACE_DIR" "$WRAPPER_ZIP" + + +# 4. Build the custom Go OTel Collector +echo "--> Building custom Go OTel Collector..." +( + cd "$COLLECTOR_DIR" + make build GOARCH="$ARCHITECTURE" +) +echo "Collector build successful." + +# 5. Add the collector to the combined layer +echo "--> Adding collector to the combined layer..." +mkdir -p "$WORKSPACE_DIR/extensions" +mkdir -p "$WORKSPACE_DIR/collector-config" +cp "$COLLECTOR_DIR/build/extensions"/* "$WORKSPACE_DIR/extensions/" +cp "$COLLECTOR_DIR/config.yaml" "$WORKSPACE_DIR/collector-config/" + +# Include E2E-specific collector config for testing workflows +if [[ -f "$COLLECTOR_DIR/config.e2e.yaml" ]]; then + cp "$COLLECTOR_DIR/config.e2e.yaml" "$WORKSPACE_DIR/collector-config/" +fi + +# 6. Create the final layer package +echo "--> Creating final layer .zip package..." +( + cd "$WORKSPACE_DIR" + zip -qr "$BUILD_DIR/otel-java-extension-layer-${ARCHITECTURE}.zip" . +) + +echo "" +echo "✅ Combined Java extension layer created successfully!" +echo " Location: $BUILD_DIR/otel-java-extension-layer-${ARCHITECTURE}.zip" + +exit 0 \ No newline at end of file diff --git a/nodejs/README.md b/nodejs/README.md index 6430c88c11..8505b49fa4 100644 --- a/nodejs/README.md +++ b/nodejs/README.md @@ -84,4 +84,4 @@ You'll find the generated layer zip file at `./packages/layer/build/layer.zip`. Sample applications are provided to show usage of the above layer. - Application using AWS SDK - shows using the wrapper with an application using AWS SDK without code change. - - [WIP] [Using OTel Public Layer](./sample-apps/aws-sdk) + - [WIP] [Using OTel Public Layer](./sample-apps/aws-sdk) \ No newline at end of file diff --git a/nodejs/packages/layer/build-combined.sh b/nodejs/packages/layer/build-combined.sh new file mode 100755 index 0000000000..4402a8ad59 --- /dev/null +++ b/nodejs/packages/layer/build-combined.sh @@ -0,0 +1,89 @@ +#!/bin/bash + +# Build combined Node.js extension layer +# This script builds a production-ready combined layer that includes: +# 1. The official OpenTelemetry Node.js instrumentation layer (pinned version) +# 2. The custom Go OpenTelemetry Collector + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +BUILD_DIR="$SCRIPT_DIR/build" +WORKSPACE_DIR="$BUILD_DIR/workspace" +COLLECTOR_DIR="$SCRIPT_DIR/../../../collector" +ARCHITECTURE="${ARCHITECTURE:-amd64}" + +# Pre-flight checks +require_cmd() { command -v "$1" >/dev/null 2>&1 || { echo "Error: '$1' is required but not installed." >&2; exit 1; }; } +require_cmd unzip +require_cmd zip +require_cmd npm + +echo "Building combined Node.js extension layer from local sources..." + +# Clean and create directories +rm -rf "$BUILD_DIR" +mkdir -p "$WORKSPACE_DIR" + +echo "Step 1: Building OpenTelemetry Node.js instrumentation layer from local source..." +# Build via workspace so root devDependencies (rimraf, bestzip, etc.) are available +( + cd "$SCRIPT_DIR/../.." && \ + npm ci --workspaces && \ + npm run build -w @opentelemetry-lambda/sdk-layer +) +LOCAL_LAYER_ZIP="$SCRIPT_DIR/build/layer.zip" +if [ ! -f "$LOCAL_LAYER_ZIP" ]; then + echo "ERROR: Local Node.js layer artifact not found: $LOCAL_LAYER_ZIP" >&2 + exit 1 +fi +echo "Extracting locally built Node.js layer to workspace..." +mkdir -p "$WORKSPACE_DIR" +unzip -oq -d "$WORKSPACE_DIR" "$LOCAL_LAYER_ZIP" + +echo "Step 2: Building custom OpenTelemetry Collector..." +# Build the collector +cd "$COLLECTOR_DIR" +if ! make build GOARCH="$ARCHITECTURE"; then + echo "ERROR: Failed to build collector" + exit 1 +fi +cd "$SCRIPT_DIR" + +echo "Step 3: Adding collector to combined layer..." +# Copy collector files to workspace +mkdir -p "$WORKSPACE_DIR/extensions" +mkdir -p "$WORKSPACE_DIR/collector-config" +cp "$COLLECTOR_DIR/build/extensions"/* "$WORKSPACE_DIR/extensions/" +cp "$COLLECTOR_DIR/config.yaml" "$WORKSPACE_DIR/collector-config/" + +# Include E2E-specific collector config for testing workflows +if [ -f "$COLLECTOR_DIR/config.e2e.yaml" ]; then + cp "$COLLECTOR_DIR/config.e2e.yaml" "$WORKSPACE_DIR/collector-config/" +fi + +echo "Step 4: Creating build metadata..." +cat > "$WORKSPACE_DIR/build-info.txt" << EOF +Combined Node.js extension layer (built from local source) +Built on: $(date -u +"%Y-%m-%d %H:%M:%S UTC") +Architecture: $ARCHITECTURE +Layer package hash: $(shasum "$LOCAL_LAYER_ZIP" 2>/dev/null | awk '{print $1}') +Collector version: $(cat "$COLLECTOR_DIR/VERSION" 2>/dev/null || echo 'unknown') +Git commit: $(git -C "$SCRIPT_DIR/../../.." rev-parse --short HEAD 2>/dev/null || echo 'unknown') +EOF + +echo "Step 5: Creating final layer package..." +# Package the combined layer (workspace becomes /opt at runtime) +cd "$WORKSPACE_DIR" +zip -qr ../otel-nodejs-extension-layer.zip . +cd "$SCRIPT_DIR" + +# Clean up temporary files +: + +echo "✅ Combined Node.js extension layer created: $BUILD_DIR/otel-nodejs-extension-layer.zip" +echo "" +echo "Layer contents preview:" +unzip -l "$BUILD_DIR/otel-nodejs-extension-layer.zip" | head -20 || true +echo "" +echo "Build completed successfully!" \ No newline at end of file diff --git a/nodejs/packages/layer/package.json b/nodejs/packages/layer/package.json index 22971032e2..d405976e3d 100644 --- a/nodejs/packages/layer/package.json +++ b/nodejs/packages/layer/package.json @@ -6,6 +6,7 @@ "repository": "open-telemetry/opentelemetry-lambda", "scripts": { "build": "npm run clean && npm run compile && npm run install-externals && npm run package", + "build-combined": "./build-combined.sh", "clean": "rimraf build/*", "compile:tsc": "tsc --build tsconfig.json", "compile:webpack": "webpack", diff --git a/python/src/build-combined.sh b/python/src/build-combined.sh new file mode 100755 index 0000000000..d57739d2fc --- /dev/null +++ b/python/src/build-combined.sh @@ -0,0 +1,87 @@ +#!/bin/bash + +# Build combined Python extension layer +# This script builds a production-ready combined layer that includes: +# 1. The official OpenTelemetry Python instrumentation layer (pinned version) +# 2. The custom Go OpenTelemetry Collector + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +BUILD_DIR="$SCRIPT_DIR/build" +WORKSPACE_DIR="$BUILD_DIR/workspace" +COLLECTOR_DIR="$SCRIPT_DIR/../../collector" +ARCHITECTURE="${ARCHITECTURE:-amd64}" + +# Pre-flight checks +require_cmd() { command -v "$1" >/dev/null 2>&1 || { echo "Error: '$1' is required but not installed." >&2; exit 1; }; } +require_cmd unzip +require_cmd zip +require_cmd docker + +echo "Building combined Python extension layer from local sources..." + +# Clean and create directories +rm -rf "$BUILD_DIR" +mkdir -p "$WORKSPACE_DIR" + +echo "Step 1: Building OpenTelemetry Python instrumentation layer from local source..." +# Build local instrumentation layer using provided Docker-based builder +( + cd "$SCRIPT_DIR" + ./build.sh +) + +LOCAL_LAYER_ZIP="$SCRIPT_DIR/build/opentelemetry-python-layer.zip" +if [ ! -f "$LOCAL_LAYER_ZIP" ]; then + echo "ERROR: Local Python layer artifact not found: $LOCAL_LAYER_ZIP" + exit 1 +fi +echo "Extracting locally built Python layer to workspace..." +unzip -oq -d "$WORKSPACE_DIR" "$LOCAL_LAYER_ZIP" + +echo "Step 2: Building custom OpenTelemetry Collector..." +# Build the collector +cd "$COLLECTOR_DIR" +if ! make build GOARCH="$ARCHITECTURE"; then + echo "ERROR: Failed to build collector" + exit 1 +fi +cd "$SCRIPT_DIR" + +echo "Step 3: Adding collector to combined layer..." +# Copy collector files to workspace +mkdir -p "$WORKSPACE_DIR/extensions" +mkdir -p "$WORKSPACE_DIR/collector-config" +cp "$COLLECTOR_DIR/build/extensions"/* "$WORKSPACE_DIR/extensions/" +cp "$COLLECTOR_DIR/config.yaml" "$WORKSPACE_DIR/collector-config/" +# Include E2E-specific collector config for testing workflows +if [ -f "$COLLECTOR_DIR/config.e2e.yaml" ]; then + cp "$COLLECTOR_DIR/config.e2e.yaml" "$WORKSPACE_DIR/collector-config/" +fi + +echo "Step 4: Creating build metadata..." +cat > "$WORKSPACE_DIR/build-info.txt" << EOF +Combined Python extension layer (built from local source) +Built on: $(date -u +"%Y-%m-%d %H:%M:%S UTC") +Architecture: $ARCHITECTURE +Python requirements hash: $(shasum "$SCRIPT_DIR/otel/otel_sdk/requirements.txt" 2>/dev/null | awk '{print $1}') +Collector version: $(cat "$COLLECTOR_DIR/VERSION" 2>/dev/null || echo 'unknown') +Git commit: $(git -C "$SCRIPT_DIR/../.." rev-parse --short HEAD 2>/dev/null || echo 'unknown') +EOF + +echo "Step 5: Creating final layer package..." +# Package the combined layer (workspace becomes /opt at runtime) +cd "$WORKSPACE_DIR" +zip -qr ../otel-python-extension-layer.zip . +cd "$SCRIPT_DIR" + +# Clean up temporary files +: + +echo "✅ Combined Python extension layer created: $BUILD_DIR/otel-python-extension-layer.zip" +echo "" +echo "Layer contents preview:" +unzip -l "$BUILD_DIR/otel-python-extension-layer.zip" | head -20 || true +echo "" +echo "Build completed successfully!" \ No newline at end of file diff --git a/ruby/build-combined.sh b/ruby/build-combined.sh new file mode 100755 index 0000000000..65c27c3719 --- /dev/null +++ b/ruby/build-combined.sh @@ -0,0 +1,158 @@ +#!/bin/bash + +# Build combined Ruby extension layer +# This script builds a combined layer that includes: +# 1. The Ruby instrumentation layer built from local sources in this repo +# 2. The custom Go OpenTelemetry Collector + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +BUILD_DIR="$SCRIPT_DIR/build" +COLLECTOR_DIR="$SCRIPT_DIR/../collector" +ARCHITECTURE="${ARCHITECTURE:-amd64}" + +# Pre-flight checks +require_cmd() { command -v "$1" >/dev/null 2>&1 || { echo "Error: '$1' is required but not installed." >&2; exit 1; }; } +require_cmd unzip +require_cmd zip +require_cmd docker + +echo "Building combined Ruby extension layer..." + +# Clean and create directories +rm -rf "$BUILD_DIR" +mkdir -p "$BUILD_DIR/combined-layer" + +echo "Step 1: Building Ruby instrumentation layer from local source..." +# Build the local Ruby layer +cd "$SCRIPT_DIR/src" +# Ensure a fresh docker build to pick up Gemfile changes (e.g., google-protobuf) +docker rmi -f aws-otel-lambda-ruby-layer >/dev/null 2>&1 || true +./build.sh +cd "$SCRIPT_DIR" + +# Extract the current layer +cd "$BUILD_DIR/combined-layer" +unzip -oq "$SCRIPT_DIR/src/build/opentelemetry-ruby-layer.zip" 2>/dev/null || { + echo "Warning: Could not extract Ruby layer, checking for alternate name..." + unzip -oq "$SCRIPT_DIR/src/build"/*.zip 2>/dev/null || { + echo "Error: No Ruby layer zip file found" + exit 1 + } +} +cd "$SCRIPT_DIR" + +echo "Step 2: Building collector..." +# Build the collector +cd "$COLLECTOR_DIR" +make build GOARCH="$ARCHITECTURE" +cd "$SCRIPT_DIR" + +# Copy collector files to combined layer +echo "Copying collector to combined layer..." +mkdir -p "$BUILD_DIR/combined-layer/extensions" +mkdir -p "$BUILD_DIR/combined-layer/collector-config" +cp "$COLLECTOR_DIR/build/extensions"/* "$BUILD_DIR/combined-layer/extensions/" +cp "$COLLECTOR_DIR/config.yaml" "$BUILD_DIR/combined-layer/collector-config/" +if [ -f "$COLLECTOR_DIR/config.e2e.yaml" ]; then + cp "$COLLECTOR_DIR/config.e2e.yaml" "$BUILD_DIR/combined-layer/collector-config/" +fi + +# Strip collector binaries to reduce size (best-effort) +echo "Stripping collector binaries (if possible) to reduce size..." +if command -v strip >/dev/null 2>&1; then + for bin in "$BUILD_DIR/combined-layer/extensions"/*; do + if [ -f "$bin" ] && command -v file >/dev/null 2>&1 && file "$bin" | grep -q "ELF"; then + strip "$bin" || true + fi + done +else + echo "strip not available; skipping binary stripping" +fi + +echo "Step 3: Optional: slimming Ruby gems (set KEEP_RUBY_GEM_VERSIONS=3.4.0,3.3.0 to keep specific versions)..." +if [ -n "${KEEP_RUBY_GEM_VERSIONS:-}" ]; then + IFS=',' read -r -a keep_list <<< "$KEEP_RUBY_GEM_VERSIONS" + find "$BUILD_DIR/combined-layer/ruby/gems" -maxdepth 1 -type d -name '3.*' | while read -r dir; do + base=$(basename "$dir") + base_mm=$(echo "$base" | cut -d. -f1-2) + keep=false + for v in "${keep_list[@]}"; do + v_mm=$(echo "$v" | cut -d. -f1-2) + if [ "$base" = "$v" ] || [ "$base_mm" = "$v_mm" ]; then keep=true; break; fi + done + if [ "$keep" = false ]; then + echo "Pruning Ruby gems version $base" + rm -rf "$dir" + fi + done +fi + +echo "Step 4: Creating combined layer package..." +cd "$BUILD_DIR/combined-layer" + +# Create build metadata at layer root (root of zip maps to /opt) +echo "Combined layer built on $(date)" > build-info.txt +echo "Architecture: $ARCHITECTURE" >> build-info.txt +echo "Collector version: $(cat "$COLLECTOR_DIR/VERSION" 2>/dev/null || echo 'unknown')" >> build-info.txt + +# Additional slimming: remove non-essential Ruby gem folders (docs/tests/examples) +echo "Pruning non-essential Ruby gem directories (docs/tests/examples)..." +if [ -d "ruby/gems" ]; then + find ruby/gems -type d \ + \( -name doc -o -name docs -o -name rdoc -o -name test -o -name tests -o -name spec -o -name examples -o -name example -o -name benchmark -o -name benchmarks \) \ + -prune -exec rm -rf {} + || true +fi + +# Prune common development/build artifacts to reduce size further +echo "Removing development artifacts (*.a, *.o, headers, pkgconfig, cache)..." +find . -type f \( -name "*.a" -o -name "*.la" -o -name "*.o" -o -name "*.h" -o -name "*.c" -o -name "*.cc" -o -name "*.cpp" \) -delete 2>/dev/null || true +find . -type d \( -name include -o -name pkgconfig -o -name cache -o -name Cache -o -name tmp \) -prune -exec rm -rf {} + 2>/dev/null || true + +# Strip Ruby native extension .so files (ELF) to reduce size +if command -v strip >/dev/null 2>&1 && command -v file >/dev/null 2>&1; then + echo "Stripping Ruby native extension .so files..." + find . -type f -name "*.so" -print0 | while IFS= read -r -d '' sofile; do + if file "$sofile" | grep -q "ELF"; then + strip "$sofile" || true + fi + done +fi + +# Ensure handler is executable +chmod +x otel-handler || true + +# Package with maximum compression so that zip root maps directly to /opt +zip -qr -9 -X ../otel-ruby-extension-layer.zip . +cd "$SCRIPT_DIR" + +echo "Combined Ruby extension layer created: $BUILD_DIR/otel-ruby-extension-layer.zip" +echo "Layer contents:" +unzip -l "$BUILD_DIR/otel-ruby-extension-layer.zip" | head -20 || true + +echo "Build completed successfully!" + +# Optional: Build function code package with bundled gems if Bundler is available +if command -v bundle >/dev/null 2>&1; then + echo "Building Ruby function package with bundled gems..." + FUNC_SRC_DIR="$SCRIPT_DIR/function" + FUNC_BUILD_DIR="$BUILD_DIR/function" + rm -rf "$FUNC_BUILD_DIR" + mkdir -p "$FUNC_BUILD_DIR" + cp "$FUNC_SRC_DIR/lambda_function.rb" "$FUNC_BUILD_DIR/" 2>/dev/null || true + cp "$FUNC_SRC_DIR/Gemfile" "$FUNC_BUILD_DIR/" 2>/dev/null || true + ( + cd "$FUNC_BUILD_DIR" + if [ -f Gemfile ]; then + bundle config set --local path 'vendor/bundle' + bundle install --without development test + zip -qr -9 -X ../otel-ruby-function.zip lambda_function.rb Gemfile Gemfile.lock vendor || true + echo "Function package created: $BUILD_DIR/otel-ruby-function.zip" + else + echo "No Gemfile found in $FUNC_SRC_DIR; skipping function package build." + fi + ) +else + echo "Bundler not available on host; skipping function package build." +fi \ No newline at end of file diff --git a/ruby/src/build.sh b/ruby/src/build.sh index 01503ee944..cdf235980d 100755 --- a/ruby/src/build.sh +++ b/ruby/src/build.sh @@ -3,5 +3,10 @@ set -e mkdir -p build -docker build --progress plain -t aws-otel-lambda-ruby-layer otel +# Honor NO_CACHE and optional platform for Apple Silicon cross-builds +BUILD_FLAGS="--progress plain --build-arg RUBY_VERSIONS=\"${KEEP_RUBY_GEM_VERSIONS:-3.2.0,3.3.0,3.4.0}\"" +if [ -n "${NO_CACHE:-}" ]; then BUILD_FLAGS="$BUILD_FLAGS --no-cache"; fi +if [ -n "${DOCKER_DEFAULT_PLATFORM:-}" ]; then BUILD_FLAGS="$BUILD_FLAGS --platform ${DOCKER_DEFAULT_PLATFORM}"; fi + +eval docker build "$BUILD_FLAGS" -t aws-otel-lambda-ruby-layer otel docker run --rm -v "$(pwd)/build:/out" aws-otel-lambda-ruby-layer diff --git a/ruby/src/otel/Dockerfile b/ruby/src/otel/Dockerfile index c6d0b38f1f..22c7afa4ea 100644 --- a/ruby/src/otel/Dockerfile +++ b/ruby/src/otel/Dockerfile @@ -1,5 +1,8 @@ FROM ubuntu:latest +# Comma-separated list of Ruby versions to build, e.g. "3.4.0" or "3.4.0,3.3.0". +ARG RUBY_VERSIONS="3.2.0,3.3.0,3.4.0" + RUN mkdir /build COPY . /build @@ -18,46 +21,50 @@ RUN echo 'alias be="bundle exec"' >> ~/.bashrc RUN echo 'alias be="bundle exec"' >> ~/.profile # install rubies to build our gem against Gemfile -RUN . ~/.profile \ - && cd /root/.rbenv/plugins/ruby-build && git pull && cd - \ - && rbenv install 3.2.0 \ - && rbenv install 3.3.0 \ - && rbenv install 3.4.0 +RUN set -e; . ~/.profile; \ + cd /root/.rbenv/plugins/ruby-build && git pull && cd -; \ + for v in $(echo "$RUBY_VERSIONS" | tr ',' ' '); do \ + echo "Installing Ruby $v"; \ + rbenv install -s "$v"; \ + done WORKDIR /build/layer -RUN . ~/.profile && rbenv local 3.2.0 && bundle install -RUN . ~/.profile && rbenv local 3.3.0 && bundle install -RUN . ~/.profile && rbenv local 3.4.0 && bundle install - -WORKDIR /root/.rbenv/versions/3.2.0/lib/ruby/gems/ -RUN zip -r gems-3.2.0.zip 3.2.0/ - -WORKDIR /root/.rbenv/versions/3.3.0/lib/ruby/gems/ -RUN zip -r gems-3.3.0.zip 3.3.0/ - -WORKDIR /root/.rbenv/versions/3.4.0/lib/ruby/gems/ - -# rbenv install 3.4.0 get 3.4.0+1/, so need to change back to 3.4.0+1 -RUN mv 3.4.0+1/ 3.4.0/ -RUN set -e && \ - dir=$(find /root/.rbenv/versions/3.4.0/lib/ruby/gems/ -type d -name '3.4.0+1' | head -n 1) && \ - target=$(echo "$dir" | sed 's/3\.4\.0+1/3.4.0/') && \ - mv "$dir" "$target" -RUN zip -r gems-3.4.0.zip 3.4.0/ - -# copy gems to /build/ruby/gems for zipping -RUN mkdir /build/ruby && mkdir /build/ruby/gems +RUN set -e; . ~/.profile; \ + for v in $(echo "$RUBY_VERSIONS" | tr ',' ' '); do \ + echo "Bundler install for Ruby $v"; \ + rbenv local "$v"; \ + bundle install; \ + done + +RUN set -e; . ~/.profile; \ + for v in $(echo "$RUBY_VERSIONS" | tr ',' ' '); do \ + cd "/root/.rbenv/versions/$v/lib/ruby/gems/"; \ + # Determine the RubyGems ABI dir (e.g., 3.4.0) for this Ruby $v + abi_dir=$(RBENV_VERSION="$v" ruby -e 'print RbConfig::CONFIG["ruby_version"]' 2>/dev/null || true); \ + # Fallback: find a directory matching major.minor.* when patch-level differs (e.g., 3.4.4 -> 3.4.0) + if [ -z "$abi_dir" ] || [ ! -d "$abi_dir" ]; then \ + major_minor=$(echo "$v" | cut -d. -f1-2); \ + abi_dir=$(find . -maxdepth 1 -type d -name "${major_minor}.*" -printf "%f" -quit) || true; \ + fi; \ + if [ -z "$abi_dir" ] || [ ! -d "$abi_dir" ]; then \ + echo "Could not locate RubyGems dir for Ruby $v under $(pwd)" >&2; exit 1; \ + fi; \ + zip -r "gems-$v.zip" "$abi_dir"/; \ + done + +RUN mkdir -p /build/ruby/gems WORKDIR /build/ruby/gems -RUN cp /root/.rbenv/versions/3.2.0/lib/ruby/gems/gems-3.2.0.zip . && unzip gems-3.2.0.zip && rm gems-3.2.0.zip -RUN cp /root/.rbenv/versions/3.3.0/lib/ruby/gems/gems-3.3.0.zip . && unzip gems-3.3.0.zip && rm gems-3.3.0.zip -RUN cp /root/.rbenv/versions/3.4.0/lib/ruby/gems/gems-3.4.0.zip . && unzip gems-3.4.0.zip && rm gems-3.4.0.zip -RUN ls -al /build/ruby/gems +RUN set -e; for v in $(echo "$RUBY_VERSIONS" | tr ',' ' '); do \ + cp "/root/.rbenv/versions/$v/lib/ruby/gems/gems-$v.zip" .; \ + unzip -q "gems-$v.zip" && rm "gems-$v.zip"; \ + done \ + && ls -al /build/ruby/gems # rm gem cache -RUN rm /root/.rbenv/versions/3.2.0/lib/ruby/gems/3.2.0/cache/* \ - && rm /root/.rbenv/versions/3.3.0/lib/ruby/gems/3.3.0/cache/* \ - && rm /root/.rbenv/versions/3.4.0/lib/ruby/gems/3.4.0/cache/* +RUN set -e; for v in $(echo "$RUBY_VERSIONS" | tr ',' ' '); do \ + rm -rf "/root/.rbenv/versions/$v/lib/ruby/gems/$v/cache"/* || true; \ + done # zip all the gems WORKDIR /build diff --git a/ruby/src/otel/layer/Gemfile b/ruby/src/otel/layer/Gemfile index 183319ffda..4b34020e82 100644 --- a/ruby/src/otel/layer/Gemfile +++ b/ruby/src/otel/layer/Gemfile @@ -3,3 +3,4 @@ source 'https://rubygems.org' gem 'opentelemetry-sdk', '~> 1.8.0' gem 'opentelemetry-exporter-otlp', '~> 0.30.0' gem 'opentelemetry-instrumentation-all', '~> 0.78.0' +gem 'google-protobuf', '4.30.0' diff --git a/ruby/src/otel/layer/wrapper.rb b/ruby/src/otel/layer/wrapper.rb index 59f9a19384..84e7a10bea 100644 --- a/ruby/src/otel/layer/wrapper.rb +++ b/ruby/src/otel/layer/wrapper.rb @@ -1,6 +1,8 @@ -require 'opentelemetry-sdk' -require 'opentelemetry-exporter-otlp' -require 'opentelemetry-instrumentation-all' +# Ensure gem libs are on the load path in case environment hooks are ignored +require 'bundler/setup' +require 'opentelemetry/sdk' +require 'opentelemetry/exporter/otlp' +require 'opentelemetry/instrumentation/all' # We need to load the function code's dependencies, and _before_ any dependencies might # be initialized outside of the function handler, bootstrap instrumentation. @@ -14,7 +16,11 @@ def preload_function_dependencies return nil end - libraries = File.read("#{default_task_location}/#{handler_file}.rb") + # Read as UTF-8 and scrub invalid bytes to avoid US-ASCII encoding errors + source = File.read("#{default_task_location}/#{handler_file}.rb", mode: 'rb').force_encoding('UTF-8') + source = source.sub(/^\uFEFF/, '') # strip UTF-8 BOM if present + source = source.scrub + libraries = source .scan(/^\s*require\s+['"]([^'"]+)['"]/) .flatten diff --git a/utils/instrumentation-layer-manager.sh b/utils/instrumentation-layer-manager.sh new file mode 100755 index 0000000000..9f1943adf6 --- /dev/null +++ b/utils/instrumentation-layer-manager.sh @@ -0,0 +1,194 @@ +#!/bin/bash + +# OpenTelemetry Lambda Instrumentation Layer Manager +# This script detects and downloads available instrumentation layers from the official OpenTelemetry Lambda releases + +set -euo pipefail + +OTEL_LAMBDA_REPO="open-telemetry/opentelemetry-lambda" +RELEASES_API="https://api.github.com/repos/${OTEL_LAMBDA_REPO}/releases" + +# Language to instrumentation layer mapping +# Based on OpenTelemetry Lambda releases structure +# Using a simple function-based mapping for better portability +get_layer_prefix_for_language() { + local language="$1" + case "$language" in + "nodejs") echo "layer-nodejs" ;; + "python") echo "layer-python" ;; + "javaagent") echo "layer-javaagent" ;; + "javawrapper") echo "layer-javawrapper" ;; + "dotnet") echo "layer-dotnet" ;; + *) return 1 ;; + esac +} + +# Function to get the latest release tag for a specific layer +get_latest_layer_release() { + local layer_prefix="$1" + + # Get all releases and filter by the layer prefix + curl -s "${RELEASES_API}" | \ + jq -r --arg prefix "$layer_prefix" \ + '.[] | select(.tag_name | startswith($prefix + "/")) | .tag_name' | \ + head -n 1 +} + +# Function to get download URL for a specific layer asset +get_layer_download_url() { + local tag_name="$1" + local asset_pattern="$2" + + curl -s "${RELEASES_API}/tags/${tag_name}" | \ + jq -r --arg pattern "$asset_pattern" \ + '.assets[] | select(.name | test($pattern)) | .browser_download_url' +} + +# Function to download instrumentation layer for a language +download_instrumentation_layer() { + local language="$1" + local output_dir="$2" + + # Check if language has instrumentation layer + local layer_prefix + if ! layer_prefix=$(get_layer_prefix_for_language "$language"); then + echo "No instrumentation layer available for $language" + return 1 + fi + echo "Looking for instrumentation layer for $language (prefix: $layer_prefix)" + + # Get latest release tag + local latest_tag + latest_tag=$(get_latest_layer_release "$layer_prefix") + if [[ -z "$latest_tag" ]]; then + echo "No releases found for $layer_prefix" + return 1 + fi + + echo "Found latest release: $latest_tag" + + # Determine asset pattern based on language and architecture + local asset_pattern + case "$language" in + "nodejs") + asset_pattern="opentelemetry-nodejs.*\.zip" + ;; + "python") + asset_pattern="opentelemetry-python.*\.zip" + ;; + "javaagent") + asset_pattern="opentelemetry-javaagent.*\.zip" + ;; + "javawrapper") + asset_pattern="opentelemetry-javawrapper.*\.zip" + ;; + "dotnet") + asset_pattern="opentelemetry-dotnet.*\.zip" + ;; + *) + echo "Unknown asset pattern for language: $language" + return 1 + ;; + esac + + # Get download URL + local download_url + download_url=$(get_layer_download_url "$latest_tag" "$asset_pattern") + if [[ -z "$download_url" ]]; then + echo "No downloadable asset found for $latest_tag with pattern $asset_pattern" + return 1 + fi + + echo "Downloading instrumentation layer from: $download_url" + + # Create output directory + mkdir -p "$output_dir" + + # Download and extract + local filename="${latest_tag//\//-}-instrumentation.zip" + local filepath="$output_dir/$filename" + + curl -L -o "$filepath" "$download_url" + + # Extract to instrumentation directory + local extract_dir="$output_dir/instrumentation" + mkdir -p "$extract_dir" + unzip -q "$filepath" -d "$extract_dir" + + echo "Instrumentation layer extracted to: $extract_dir" + echo "Release tag: $latest_tag" + + # Return the extract directory path and release tag + echo "$extract_dir|$latest_tag" +} + +# Function to check if instrumentation layer is available for a language +has_instrumentation_layer() { + local language="$1" + get_layer_prefix_for_language "$language" > /dev/null 2>&1 +} + +# Function to list all available instrumentation layers +list_available_layers() { + echo "Available instrumentation layers:" + for language in nodejs python javaagent javawrapper dotnet; do + if layer_prefix=$(get_layer_prefix_for_language "$language"); then + local latest_tag + latest_tag=$(get_latest_layer_release "$layer_prefix") + if [[ -n "$latest_tag" ]]; then + echo " $language: $latest_tag" + else + echo " $language: No releases found" + fi + fi + done +} + +# Main function +main() { + local command="${1:-help}" + + case "$command" in + "download") + if [[ $# -lt 3 ]]; then + echo "Usage: $0 download [architecture]" + exit 1 + fi + download_instrumentation_layer "$2" "$3" "${4:-amd64}" + ;; + "check") + if [[ $# -lt 2 ]]; then + echo "Usage: $0 check " + exit 1 + fi + if has_instrumentation_layer "$2"; then + echo "Instrumentation layer available for $2" + exit 0 + else + echo "No instrumentation layer available for $2" + exit 1 + fi + ;; + "list") + list_available_layers + ;; + "help"|*) + echo "OpenTelemetry Lambda Instrumentation Layer Manager" + echo "" + echo "Usage: $0 [options]" + echo "" + echo "Commands:" + echo " download [architecture] Download instrumentation layer" + echo " check Check if instrumentation layer is available" + echo " list List all available instrumentation layers" + echo " help Show this help message" + echo "" + echo "Supported languages: nodejs python javaagent javawrapper dotnet" + ;; + esac +} + +# Run main function if script is executed directly +if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then + main "$@" +fi \ No newline at end of file