diff --git a/.github/workflows/release-udp-exporter.yml b/.github/workflows/release-udp-exporter.yml new file mode 100644 index 00000000..7eb1509a --- /dev/null +++ b/.github/workflows/release-udp-exporter.yml @@ -0,0 +1,73 @@ +name: Release ADOT X-Ray UDP Exporter + +on: + workflow_dispatch: + inputs: + udp-exporter-version: + description: The version to tag the release with, e.g., 1.2.0 + required: true + type: string + +permissions: + id-token: write + contents: write + +jobs: + validate-udp-exporter-e2e-test: + name: "Validate X-Ray UDP Exporter E2E Test Succeeds" + uses: ./.github/workflows/udp-exporter-e2e-test.yml + secrets: inherit + permissions: + id-token: write + + build: + environment: Release + runs-on: ubuntu-latest + needs: validate-udp-exporter-e2e-test + steps: + - name: Checkout Contrib Repo @ SHA - ${{ github.sha }} + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 #v5.0.0 + + - name: Set up Node and run Unit Tests + uses: ./.github/actions/set_up + with: + node_version: "20" + package_name: "@aws/aws-distro-opentelemetry-exporter-xray-udp" + os: ubuntu-latest + run_unit_tests: true + + # Project dependencies and compilation are already done in the previous step + - name: Install Dependencies, Compile, and Build Tarball + id: staging_tarball_build + shell: bash + run: | + cd exporters/aws-distro-opentelemetry-exporter-xray-udp + npm pack + + - name: Validate project version matches workflow input + run: | + xrayUdpSpanExporterVersion=$(node -p "require('./exporters/aws-distro-opentelemetry-exporter-xray-udp/package.json').version") + if [[ ! "$xrayUdpSpanExporterVersion" == "${{ inputs.udp-exporter-version }}" ]]; then + echo "Input version '${{ inputs.udp-exporter-version }}' does not match with the UDP Exporter project version '$xrayUdpSpanExporterVersion'" + exit 1 + fi + + # Publish OTLP UDP Exporter to npm + - name: Publish to npm + working-directory: exporters/aws-distro-opentelemetry-exporter-xray-udp + env: + NODE_AUTH_TOKEN: ${{secrets.NPM_TOKEN}} + NPM_CONFIG_PROVENANCE: true + run: npx publish + + # Publish to GitHub releases + - name: Create GH release + id: create_release + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # This token is provided by Actions, you do not need to create your own token + run: | + gh release create --target "$GITHUB_REF_NAME" \ + --title "Release aws-distro-opentelemetry-exporter-xray-udp v${{ inputs.udp-exporter-version }}" \ + --notes "Please refer to the [Changelog](https://github.com/aws-observability/aws-otel-js-instrumentation/blob/main/exporters/aws-distro-opentelemetry-exporter-xray-udp/CHANGELOG.md) for more details" \ + --draft \ + "aws-distro-opentelemetry-exporter-xray-udp/v${{ inputs.udp-exporter-version }}" \ No newline at end of file diff --git a/.github/workflows/udp-exporter-e2e-test.yml b/.github/workflows/udp-exporter-e2e-test.yml new file mode 100644 index 00000000..4557d966 --- /dev/null +++ b/.github/workflows/udp-exporter-e2e-test.yml @@ -0,0 +1,74 @@ +name: Test ADOT X-Ray UDP Exporter +on: + workflow_call: + push: + branches: + - main + +permissions: + id-token: write + +jobs: + udp-exporter-e2e-test: + runs-on: ubuntu-latest + steps: + - name: Checkout Repo @ SHA - ${{ github.sha }} + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + + - name: Set up Node and run Unit Tests + uses: ./.github/actions/set_up + with: + node_version: "20" + package_name: "@aws/aws-distro-opentelemetry-exporter-xray-udp" + os: ubuntu-latest + run_unit_tests: true + + # Project dependencies and compilation are already done in the previous step + - name: Install Dependencies, Compile, and Build Tarball + id: staging_tarball_build + run: | + cd exporters/aws-distro-opentelemetry-exporter-xray-udp + npm pack + + - name: Configure AWS credentials for Testing Tracing + uses: aws-actions/configure-aws-credentials@a03048d87541d1d9fcf2ecf528a4a65ba9bd7838 # v5.0.0 + with: + role-to-assume: ${{ secrets.XRAY_UDP_EXPORTER_TEST_ROLE }} + aws-region: 'us-east-1' + + - name: Download and run X-Ray Daemon + run: | + mkdir xray-daemon + cd xray-daemon + wget https://s3.us-east-2.amazonaws.com/aws-xray-assets.us-east-2/xray-daemon/aws-xray-daemon-linux-3.x.zip + unzip aws-xray-daemon-linux-3.x.zip + ./xray -o -n us-east-2 -f ./daemon-logs.log --log-level debug & + + - name: Setup Sample App + working-directory: sample-applications/udp-exporter-test-app + run: | + npm install ../../exporters/aws-distro-opentelemetry-exporter-xray-udp/aws-aws-distro-opentelemetry-exporter-xray-udp-*.tgz + npm install + node udp-exporter-test-server.js & + # Wait for test server to initialize + sleep 5 + + - name: Call Sample App Endpoint + id: call-endpoint + run: | + echo "traceId=$(curl localhost:8080/test)" >> $GITHUB_OUTPUT + + - name: Check if traces are successfully sent to AWS X-Ray + run: | + sleep 20 + # # Print Daemon Logs for debugging + # cat xray-daemon/daemon-logs.log + + traceId=${{ steps.call-endpoint.outputs.traceId }} + numTracesFound=$(aws xray batch-get-traces --trace-ids $traceId --region us-east-2 | jq '.Traces' | jq length) + if [[ numTracesFound -ne "1" ]]; then + echo "TraceId $traceId not found in X-Ray." + exit 1 + else + echo "TraceId $traceId found in X-Ray." + fi \ No newline at end of file diff --git a/exporters/aws-distro-opentelemetry-exporter-xray-udp/.eslintignore b/exporters/aws-distro-opentelemetry-exporter-xray-udp/.eslintignore new file mode 100644 index 00000000..8cbd4317 --- /dev/null +++ b/exporters/aws-distro-opentelemetry-exporter-xray-udp/.eslintignore @@ -0,0 +1,5 @@ +build +node_modules +.eslintrc.js +version.ts +src/third-party \ No newline at end of file diff --git a/exporters/aws-distro-opentelemetry-exporter-xray-udp/.eslintrc.js b/exporters/aws-distro-opentelemetry-exporter-xray-udp/.eslintrc.js new file mode 100644 index 00000000..9389e718 --- /dev/null +++ b/exporters/aws-distro-opentelemetry-exporter-xray-udp/.eslintrc.js @@ -0,0 +1,8 @@ +module.exports = { + "env": { + "commonjs": true, + "node": true, + "mocha": true, + }, + ...require('../../eslint.config.js') +} \ No newline at end of file diff --git a/exporters/aws-distro-opentelemetry-exporter-xray-udp/CHANGELOG.md b/exporters/aws-distro-opentelemetry-exporter-xray-udp/CHANGELOG.md new file mode 100644 index 00000000..62225654 --- /dev/null +++ b/exporters/aws-distro-opentelemetry-exporter-xray-udp/CHANGELOG.md @@ -0,0 +1 @@ +# Release History: @aws/aws-distro-opentelemetry-exporter-xray-udp diff --git a/exporters/aws-distro-opentelemetry-exporter-xray-udp/LICENSE b/exporters/aws-distro-opentelemetry-exporter-xray-udp/LICENSE new file mode 100644 index 00000000..67db8588 --- /dev/null +++ b/exporters/aws-distro-opentelemetry-exporter-xray-udp/LICENSE @@ -0,0 +1,175 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. diff --git a/exporters/aws-distro-opentelemetry-exporter-xray-udp/README.md b/exporters/aws-distro-opentelemetry-exporter-xray-udp/README.md new file mode 100644 index 00000000..ce32de36 --- /dev/null +++ b/exporters/aws-distro-opentelemetry-exporter-xray-udp/README.md @@ -0,0 +1,27 @@ +# AWS Distro for OpenTelemetry (ADOT) X-Ray UDP Exporter + +The AWS X-Ray UDP Exporter allows you to send OpenTelemetry Spans to the AWS X-Ray Daemon endpoint. +Notably, this will work with the X-Ray Daemon that runs in an AWS Lambda Environment. + +## Installation + +Install this package into your NodeJS project with: + +```shell +npm install --save @aws/aws-distro-opentelemetry-exporter-xray-udp +``` + +## Usage + +```js +const { AwsXrayUdpSpanExporter } = require("@aws/aws-distro-opentelemetry-exporter-xray-udp") +// ... + +const _traceExporter = new AwsXrayUdpSpanExporter(); +const _spanProcessor = new SimpleSpanProcessor(_traceExporter); + +const sdk = new opentelemetry.NodeSDK({ + spanProcessor: _spanProcessor, + // ... +}); +``` diff --git a/exporters/aws-distro-opentelemetry-exporter-xray-udp/package.json b/exporters/aws-distro-opentelemetry-exporter-xray-udp/package.json new file mode 100644 index 00000000..7538264f --- /dev/null +++ b/exporters/aws-distro-opentelemetry-exporter-xray-udp/package.json @@ -0,0 +1,92 @@ +{ + "name": "@aws/aws-distro-opentelemetry-exporter-xray-udp", + "version": "0.0.1", + "description": "This package provides an AWS X-Ray UDP Exporter for OpenTelemetry.", + "author": { + "name": "Amazon Web Services", + "url": "http://aws.amazon.com" + }, + "homepage": "https://github.com/aws-observability/aws-otel-js-instrumentation/tree/main/exporters/aws-distro-opentelemetry-exporter-xray-udp#readme", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + }, + "publishConfig": { + "access": "public" + }, + "main": "build/src/index.js", + "types": "build/src/index.d.ts", + "repository": "aws-observability/aws-otel-js-instrumentation", + "scripts": { + "clean": "rimraf build/*", + "compile": "tsc -p .", + "lint": "eslint . --ext .ts", + "lint:fix": "eslint . --ext .ts --fix", + "precompile": "tsc --version && lerna run version:update --scope @aws/aws-distro-opentelemetry-exporter-xray-udp --include-dependencies", + "prewatch": "npm run precompile", + "prepublishOnly": "npm run compile", + "tdd": "yarn test -- --watch-extensions ts --watch", + "test": "nyc ts-mocha --timeout 10000 -p tsconfig.json --require '@opentelemetry/contrib-test-utils' 'test/**/*.ts'", + "test:coverage": "nyc --check-coverage --functions 95 --lines 95 ts-mocha --timeout 10000 -p tsconfig.json --require '@opentelemetry/contrib-test-utils' 'test/**/*.ts'", + "watch": "tsc -w" + }, + "nyc": { + "all": true, + "include": [ + "src/**/*.ts" + ], + "exclude": [ + "src/third-party/**/*.ts" + ] + }, + "bugs": { + "url": "https://github.com/aws-observability/aws-otel-js-instrumentation/issues" + }, + "keywords": [ + "aws", + "amazon", + "adot", + "adotjs", + "adot-js", + "adot js", + "xray", + "x-ray", + "x ray", + "awsxray", + "awsdistroopentelemetry", + "opentelemetry", + "otel", + "awslambda", + "nodejs", + "trace", + "tracing", + "profiling", + "instrumentation" + ], + "devDependencies": { + "@opentelemetry/resources": "1.30.1", + "@opentelemetry/contrib-test-utils": "^0.45.0", + "@types/mocha": "7.0.2", + "@types/node": "18.6.5", + "@types/sinon": "10.0.18", + "expect": "29.2.0", + "mocha": "7.2.0", + "nyc": "15.1.0", + "rimraf": "5.0.5", + "sinon": "15.2.0", + "ts-mocha": "10.0.0", + "typescript": "4.4.4" + }, + "dependencies": { + "@opentelemetry/api": "^1.9.0", + "@opentelemetry/core": "1.30.1", + "@opentelemetry/otlp-transformer": "0.57.1", + "@opentelemetry/sdk-trace-base": "1.30.1" + }, + "files": [ + "build/src/**/*.js", + "build/src/**/*.js.map", + "build/src/**/*.d.ts", + "build/src/**/*.json" + ] +} diff --git a/exporters/aws-distro-opentelemetry-exporter-xray-udp/src/aws-xray-udp-span-exporter.ts b/exporters/aws-distro-opentelemetry-exporter-xray-udp/src/aws-xray-udp-span-exporter.ts new file mode 100644 index 00000000..b5c69232 --- /dev/null +++ b/exporters/aws-distro-opentelemetry-exporter-xray-udp/src/aws-xray-udp-span-exporter.ts @@ -0,0 +1,115 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import * as dgram from 'dgram'; +import { diag } from '@opentelemetry/api'; +import { ExportResult, ExportResultCode } from '@opentelemetry/core'; +import { ProtobufTraceSerializer } from '@opentelemetry/otlp-transformer'; +import { SpanExporter, ReadableSpan } from '@opentelemetry/sdk-trace-base'; + +const DEFAULT_ENDPOINT = '127.0.0.1:2000'; +const PROTOCOL_HEADER = '{"format":"json","version":1}\n'; +const DEFAULT_FORMAT_OTEL_TRACES_BINARY_PREFIX = 'T1S'; + +export class UdpExporter { + private _endpoint: string; + private _host: string; + private _port: number; + private _socket: dgram.Socket; + + constructor(endpoint?: string) { + this._endpoint = endpoint || DEFAULT_ENDPOINT; + [this._host, this._port] = this._parseEndpoint(this._endpoint); + this._socket = dgram.createSocket('udp4'); + this._socket.unref(); + } + + sendData(data: Uint8Array, signalFormatPrefix: string): void { + const base64EncodedString = Buffer.from(data).toString('base64'); + const message = `${PROTOCOL_HEADER}${signalFormatPrefix}${base64EncodedString}`; + + try { + this._socket.send(Buffer.from(message, 'utf-8'), this._port, this._host, err => { + if (err) { + throw err; + } + }); + } catch (err) { + diag.error('Error sending UDP data: %s', err); + throw err; + } + } + + shutdown(): void { + this._socket.close(); + } + + private _parseEndpoint(endpoint: string): [string, number] { + try { + const [host, port] = endpoint.split(':'); + return [host, parseInt(port, 10)]; + } catch (err) { + throw new Error(`Invalid endpoint: ${endpoint}`); + } + } +} + +export class AwsXrayUdpSpanExporter implements SpanExporter { + private _udpExporter: UdpExporter; + private _signalPrefix: string; + private _endpoint: string; + + constructor(endpoint?: string, _signalPrefix?: string) { + if (endpoint == null) { + if (isLambdaEnvironment()) { + this._endpoint = getXrayDaemonEndpoint() || DEFAULT_ENDPOINT; + } else { + this._endpoint = DEFAULT_ENDPOINT; + } + } else { + this._endpoint = endpoint; + } + + this._udpExporter = new UdpExporter(this._endpoint); + this._signalPrefix = _signalPrefix || DEFAULT_FORMAT_OTEL_TRACES_BINARY_PREFIX; + } + + export(spans: ReadableSpan[], resultCallback: (result: ExportResult) => void): void { + const serializedData = ProtobufTraceSerializer.serializeRequest(spans); + if (serializedData == null) { + return; + } + try { + this._udpExporter.sendData(serializedData, this._signalPrefix); + return resultCallback({ code: ExportResultCode.SUCCESS }); + } catch (err) { + diag.error('Error exporting spans: %s', err); + return resultCallback({ code: ExportResultCode.FAILED }); + } + } + + forceFlush(): Promise { + return Promise.resolve(); + } + + /** Shutdown exporter. */ + shutdown(): Promise { + return new Promise((resolve, reject) => { + try { + this._udpExporter.shutdown(); + resolve(); + } catch (error) { + reject(error); + } + }); + } +} + +function isLambdaEnvironment() { + // detect if running in AWS Lambda environment + return process.env.AWS_LAMBDA_FUNCTION_NAME !== undefined; +} + +function getXrayDaemonEndpoint() { + return process.env.AWS_XRAY_DAEMON_ADDRESS; +} diff --git a/exporters/aws-distro-opentelemetry-exporter-xray-udp/src/index.ts b/exporters/aws-distro-opentelemetry-exporter-xray-udp/src/index.ts new file mode 100644 index 00000000..26d11fd5 --- /dev/null +++ b/exporters/aws-distro-opentelemetry-exporter-xray-udp/src/index.ts @@ -0,0 +1,4 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +export { AwsXrayUdpSpanExporter } from './aws-xray-udp-span-exporter'; diff --git a/exporters/aws-distro-opentelemetry-exporter-xray-udp/test/aws-xray-udp-span-exporter.test.ts b/exporters/aws-distro-opentelemetry-exporter-xray-udp/test/aws-xray-udp-span-exporter.test.ts new file mode 100644 index 00000000..7abd2bc2 --- /dev/null +++ b/exporters/aws-distro-opentelemetry-exporter-xray-udp/test/aws-xray-udp-span-exporter.test.ts @@ -0,0 +1,192 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { diag, SpanContext, SpanKind } from '@opentelemetry/api'; +import { ExportResultCode } from '@opentelemetry/core'; +import { Resource } from '@opentelemetry/resources'; +import { ProtobufTraceSerializer } from '@opentelemetry/otlp-transformer'; +import { AwsXrayUdpSpanExporter, UdpExporter } from '../src/aws-xray-udp-span-exporter'; +import { ReadableSpan } from '@opentelemetry/sdk-trace-base'; +import * as sinon from 'sinon'; +import expect from 'expect'; +import { Socket } from 'dgram'; + +describe('UdpExporterTest', () => { + const endpoint = '127.0.0.1:3000'; + const host = '127.0.0.1'; + const port = 3000; + let udpExporter: UdpExporter; + let socketSend: sinon.SinonStub; + let socketClose: sinon.SinonStub<[callback?: (() => void) | undefined], Socket>; + let diagErrorSpy: sinon.SinonSpy<[message: string, ...args: unknown[]], void>; + + beforeEach(() => { + udpExporter = new UdpExporter(endpoint); + + // Stub the _socket methods + socketSend = sinon.stub(udpExporter['_socket'], 'send'); + socketClose = sinon.stub(udpExporter['_socket'], 'close'); + + // Spy on diag.error using sinon + diagErrorSpy = sinon.spy(diag, 'error'); + }); + + afterEach(() => { + sinon.restore(); // Restore the original dgram behavior + }); + + it('should parse the endpoint correctly', () => { + expect(udpExporter['_host']).toBe(host); + expect(udpExporter['_port']).toBe(port); + }); + + it('should send UDP data correctly', () => { + const data = new Uint8Array([1, 2, 3]); + const prefix = 'T1'; + const encodedData = '{"format":"json","version":1}\nT1AQID'; + const protbufBinary = Buffer.from(encodedData, 'utf-8'); + udpExporter.sendData(data, prefix); + sinon.assert.calledOnce(socketSend); + expect(socketSend.getCall(0).args[0]).toEqual(protbufBinary); + }); + + it('should handle errors when sending UDP data', () => { + const errorMessage = 'UDP send error'; + socketSend.yields(new Error(errorMessage)); // Simulate an error + + const data = new Uint8Array([1, 2, 3]); + // Expect the sendData method to throw the error + expect(() => udpExporter.sendData(data, 'T1')).toThrow(errorMessage); + // Assert that diag.error was called with the correct error message + expect(diagErrorSpy.calledOnce).toBe(true); + expect(diagErrorSpy.calledWith('Error sending UDP data: %s', sinon.match.instanceOf(Error))).toBe(true); + }); + + it('should close the socket on shutdown', () => { + udpExporter.shutdown(); + expect(socketClose.calledOnce).toBe(true); + }); + + it('should throw when provided invalid endpoint', () => { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + expect(() => new UdpExporter(123)).toThrow(new Error('Invalid endpoint: 123')); + }); +}); + +describe('AwsXrayUdpSpanExporterTest', () => { + let awsXrayUdpSpanExporter: AwsXrayUdpSpanExporter; + let udpExporterMock: { sendData: any; shutdown: any }; + let diagErrorSpy: sinon.SinonSpy<[message: string, ...args: unknown[]], void>; + const endpoint = '127.0.0.1:3000'; + const prefix = 'T1'; + const serializedData = new Uint8Array([1, 2, 3]); // Mock serialized data + // Mock ReadableSpan object + const mockSpanData: ReadableSpan = { + name: 'spanName', + kind: SpanKind.SERVER, + spanContext: () => { + const spanContext: SpanContext = { + traceId: '00000000000000000000000000000008', + spanId: '0000000000000009', + traceFlags: 0, + }; + return spanContext; + }, + startTime: [0, 0], + endTime: [0, 1], + status: { code: 0 }, + attributes: {}, + links: [], + events: [], + duration: [0, 1], + ended: true, + resource: new Resource({}), + instrumentationLibrary: { name: 'mockedLibrary' }, + droppedAttributesCount: 0, + droppedEventsCount: 0, + droppedLinksCount: 0, + }; + const spans: ReadableSpan[] = [mockSpanData]; // Mock span data + + beforeEach(() => { + // Mock UdpExporter methods + udpExporterMock = { + sendData: sinon.stub(), + shutdown: sinon.stub().resolves(), + }; + + // Stub the UdpExporter constructor to return our mock + sinon.stub(UdpExporter.prototype, 'sendData').callsFake(udpExporterMock.sendData); + sinon.stub(UdpExporter.prototype, 'shutdown').callsFake(udpExporterMock.shutdown); + + // Stub the diag.error method + diagErrorSpy = sinon.spy(diag, 'error'); + + // Create an instance of AwsXrayUdpSpanExporter + awsXrayUdpSpanExporter = new AwsXrayUdpSpanExporter(endpoint, prefix); + }); + + afterEach(() => { + // Restore the original functionality after each test + sinon.restore(); + }); + + it('should export spans successfully', () => { + const callback = sinon.stub(); + // Stub ProtobufTraceSerializer.serializeRequest + sinon.stub(ProtobufTraceSerializer, 'serializeRequest').returns(serializedData); + + awsXrayUdpSpanExporter.export(spans, callback); + + expect(udpExporterMock.sendData.calledOnceWith(serializedData, 'T1')).toBe(true); + expect(callback.calledOnceWith({ code: ExportResultCode.SUCCESS })).toBe(true); + expect(diagErrorSpy.notCalled).toBe(true); // Ensure no error was logged + }); + + it('should handle serialization failure', () => { + // Make serializeRequest return null + sinon.stub(ProtobufTraceSerializer, 'serializeRequest').returns(undefined); + const callback = sinon.stub(); + + awsXrayUdpSpanExporter.export(spans, callback); + + expect(callback.notCalled).toBe(true); + expect(udpExporterMock.sendData.notCalled).toBe(true); + expect(diagErrorSpy.notCalled).toBe(true); + }); + + it('should handle errors during export', () => { + const error = new Error('Export error'); + udpExporterMock.sendData.throws(error); + + const callback = sinon.stub(); + + awsXrayUdpSpanExporter.export(spans, callback); + + expect(diagErrorSpy.calledOnceWith('Error exporting spans: %s', sinon.match.instanceOf(Error))).toBe(true); + expect(callback.calledOnceWith({ code: ExportResultCode.FAILED })).toBe(true); + }); + + it('should forceFlush without throwing', async () => { + expect(awsXrayUdpSpanExporter.forceFlush()).resolves.not.toThrow(); + }); + + it('should shutdown the UDP exporter successfully', async () => { + await awsXrayUdpSpanExporter.shutdown(); + expect(udpExporterMock.shutdown.calledOnce).toBe(true); + }); + + it('should use expected Environment Variables to configure endpoint', () => { + process.env.AWS_LAMBDA_FUNCTION_NAME = 'testFunctionName'; + process.env.AWS_XRAY_DAEMON_ADDRESS = 'someaddress:1234'; + + const exporter = new AwsXrayUdpSpanExporter(undefined); + expect(exporter['_endpoint']).toBe('someaddress:1234'); + expect(exporter['_udpExporter']['_host']).toBe('someaddress'); + expect(exporter['_udpExporter']['_port']).toBe(1234); + + delete process.env.AWS_XRAY_DAEMON_ADDRESS; + delete process.env.AWS_LAMBDA_FUNCTION_NAME; + }); +}); diff --git a/exporters/aws-distro-opentelemetry-exporter-xray-udp/tsconfig.json b/exporters/aws-distro-opentelemetry-exporter-xray-udp/tsconfig.json new file mode 100644 index 00000000..835319a4 --- /dev/null +++ b/exporters/aws-distro-opentelemetry-exporter-xray-udp/tsconfig.json @@ -0,0 +1,35 @@ +{ + "compilerOptions": { + "rootDir": ".", + "outDir": "build", + + "allowUnreachableCode": false, + "allowUnusedLabels": false, + "declaration": true, + "declarationMap": true, + "forceConsistentCasingInFileNames": true, + "inlineSources": true, + "module": "commonjs", + "noEmitOnError": true, + "noFallthroughCasesInSwitch": true, + "noImplicitOverride": true, + "noImplicitReturns": true, + "noUnusedLocals": true, + "pretty": true, + "sourceMap": true, + "strict": true, + "strictNullChecks": true, + "target": "es2017", + "incremental": true, + "newLine": "LF", + // This command allows TypeScript to import `sql_dialect_keywords.json` + "resolveJsonModule": true + }, + "include": [ + "src/**/*.ts", + "test/**/*.ts" + ], + "exclude": [ + "node_modules" + ] +} diff --git a/package-lock.json b/package-lock.json index 895eb246..679cc897 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,8 @@ "version": "0.7.0-dev0", "license": "Apache-2.0", "workspaces": [ - "aws-distro-opentelemetry-node-autoinstrumentation/" + "aws-distro-opentelemetry-node-autoinstrumentation/", + "exporters/aws-distro-opentelemetry-exporter-xray-udp" ], "devDependencies": { "@types/mocha": "7.0.2", @@ -610,6 +611,48 @@ "node": ">=4.2.0" } }, + "exporters/aws-distro-opentelemetry-exporter-xray-udp": { + "name": "@aws/aws-distro-opentelemetry-exporter-xray-udp", + "version": "0.0.1", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api": "^1.9.0", + "@opentelemetry/core": "1.30.1", + "@opentelemetry/otlp-transformer": "0.57.1", + "@opentelemetry/sdk-trace-base": "1.30.1" + }, + "devDependencies": { + "@opentelemetry/contrib-test-utils": "^0.45.0", + "@opentelemetry/resources": "1.30.1", + "@types/mocha": "7.0.2", + "@types/node": "18.6.5", + "@types/sinon": "10.0.18", + "expect": "29.2.0", + "mocha": "7.2.0", + "nyc": "15.1.0", + "rimraf": "5.0.5", + "sinon": "15.2.0", + "ts-mocha": "10.0.0", + "typescript": "4.4.4" + }, + "engines": { + "node": ">=14" + } + }, + "exporters/aws-distro-opentelemetry-exporter-xray-udp/node_modules/typescript": { + "version": "4.4.4", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.4.4.tgz", + "integrity": "sha512-DqGhF5IKoBl8WNf8C1gu8q0xZSInh9j1kJJMqT3a94w1JzVaBU4EXOSMrz9yDqMT0xt3selp83fuFMQ0uzv6qA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=4.2.0" + } + }, "node_modules/@ampproject/remapping": { "version": "2.3.0", "dev": true, @@ -2279,6 +2322,10 @@ "node": ">=16.0.0" } }, + "node_modules/@aws/aws-distro-opentelemetry-exporter-xray-udp": { + "resolved": "exporters/aws-distro-opentelemetry-exporter-xray-udp", + "link": true + }, "node_modules/@aws/aws-distro-opentelemetry-node-autoinstrumentation": { "resolved": "aws-distro-opentelemetry-node-autoinstrumentation", "link": true diff --git a/package.json b/package.json index 5e4cc9d2..5996684a 100644 --- a/package.json +++ b/package.json @@ -82,6 +82,7 @@ ] }, "workspaces": [ - "aws-distro-opentelemetry-node-autoinstrumentation/" + "aws-distro-opentelemetry-node-autoinstrumentation/", + "exporters/aws-distro-opentelemetry-exporter-xray-udp/" ] } diff --git a/sample-applications/udp-exporter-test-app/package.json b/sample-applications/udp-exporter-test-app/package.json new file mode 100644 index 00000000..351d95ec --- /dev/null +++ b/sample-applications/udp-exporter-test-app/package.json @@ -0,0 +1,26 @@ +{ + "name": "simple-express-server-for-local-testing", + "version": "1.0.0", + "description": "", + "private": true, + "main": "index.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "keywords": [], + "author": "", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api": "^1.9.0", + "@opentelemetry/id-generator-aws-xray": "^1.2.2", + "@opentelemetry/propagator-aws-xray": "^1.25.1", + "@opentelemetry/sdk-node": "^0.52.1", + "@opentelemetry/sdk-trace-base": "^1.25.1", + "@opentelemetry/sdk-trace-node": "^1.25.1", + "@types/express": "^4.17.21", + "express": "^4.19.2" + }, + "devDependencies": { + "@types/node": "^22.1.0" + } +} diff --git a/sample-applications/udp-exporter-test-app/udp-exporter-test-server.js b/sample-applications/udp-exporter-test-app/udp-exporter-test-server.js new file mode 100644 index 00000000..6f4678fc --- /dev/null +++ b/sample-applications/udp-exporter-test-app/udp-exporter-test-server.js @@ -0,0 +1,59 @@ +'use strict'; + +const { trace, SpanKind, context } = require('@opentelemetry/api'); +const { AlwaysOnSampler } = require('@opentelemetry/sdk-trace-node'); +const express = require('express'); +const process = require('process'); +const opentelemetry = require("@opentelemetry/sdk-node"); +const { SimpleSpanProcessor } = require('@opentelemetry/sdk-trace-base'); +const { AWSXRayPropagator } = require("@opentelemetry/propagator-aws-xray"); +const { AWSXRayIdGenerator } = require("@opentelemetry/id-generator-aws-xray"); +const { AwsXrayUdpSpanExporter } = require("@aws/aws-distro-opentelemetry-exporter-xray-udp") + +const _traceExporter = new AwsXrayUdpSpanExporter(); +const _spanProcessor = new SimpleSpanProcessor(_traceExporter); + +const PORT = parseInt(process.env.SAMPLE_APP_PORT || '8080', 10); +const app = express(); + +app.get('/', (req, res) => { + res.send(`healthcheck`) +}); + +app.get('/test', (req, res) => { + const tracer = trace.getTracer("testTracer"); + let ctx = context.active(); + let span = tracer.startSpan("testSpan", {kind: SpanKind.SERVER}, ctx); + let traceId = span.spanContext().traceId; + span.end(); + let xrayFormatTraceId = "1-" + traceId.substring(0,8) + "-" + traceId.substring(8); + console.log(`X-Ray Trace ID is: ${xrayFormatTraceId}`); + + res.send(`${xrayFormatTraceId}`); +}); + +app.listen(PORT, async () => { + await nodeSDKBuilder(); + console.log(`Listening for requests on http://localhost:${PORT}`); +}); + +async function nodeSDKBuilder() { + const sdk = new opentelemetry.NodeSDK({ + textMapPropagator: new AWSXRayPropagator(), + instrumentations: [], + spanProcessor: _spanProcessor, + sampler: new AlwaysOnSampler(), + idGenerator: new AWSXRayIdGenerator(), + }); + + // this enables the API to record telemetry + await sdk.start(); + + // gracefully shut down the SDK on process exit + process.on('SIGTERM', () => { + sdk.shutdown() + .then(() => console.log('Tracing terminated')) + .catch((error) => console.log('Error terminating tracing', error)) + .finally(() => process.exit(0)); + }); +}