From 485d635f5bf1070f54a81467540d2b1d454d298c Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Thu, 4 Apr 2024 09:27:38 -0700 Subject: [PATCH] feat: Add OpenTelemetry support for node-server-sdk. (#401) --- ...stores-node-server-sdk-otel--bug_report.md | 36 ++++ ...s-node-server-sdk-otel--feature_request.md | 19 ++ .github/workflows/manual-publish-docs.yml | 1 + .github/workflows/manual-publish.yml | 1 + .github/workflows/node-otel.yml | 27 +++ .github/workflows/release-please.yml | 28 ++- .release-please-manifest.json | 3 +- README.md | 12 ++ package.json | 5 +- .../telemetry/node-server-sdk-otel/LICENSE | 13 ++ .../telemetry/node-server-sdk-otel/README.md | 69 +++++++ .../node-server-sdk-otel/jest.config.js | 8 + .../node-server-sdk-otel/package.json | 63 +++++++ .../src/TracingHook.test.ts | 138 ++++++++++++++ .../node-server-sdk-otel/src/TracingHook.ts | 174 ++++++++++++++++++ .../node-server-sdk-otel/src/index.ts | 2 + .../node-server-sdk-otel/tsconfig.eslint.json | 5 + .../node-server-sdk-otel/tsconfig.json | 19 ++ .../node-server-sdk-otel/tsconfig.ref.json | 7 + .../node-server-sdk-otel/typedoc.json | 5 + release-please-config.json | 5 +- tsconfig.json | 3 + 22 files changed, 636 insertions(+), 7 deletions(-) create mode 100644 .github/ISSUE_TEMPLATE/package-stores-node-server-sdk-otel--bug_report.md create mode 100644 .github/ISSUE_TEMPLATE/package-stores-node-server-sdk-otel--feature_request.md create mode 100644 .github/workflows/node-otel.yml create mode 100644 packages/telemetry/node-server-sdk-otel/LICENSE create mode 100644 packages/telemetry/node-server-sdk-otel/README.md create mode 100644 packages/telemetry/node-server-sdk-otel/jest.config.js create mode 100644 packages/telemetry/node-server-sdk-otel/package.json create mode 100644 packages/telemetry/node-server-sdk-otel/src/TracingHook.test.ts create mode 100644 packages/telemetry/node-server-sdk-otel/src/TracingHook.ts create mode 100644 packages/telemetry/node-server-sdk-otel/src/index.ts create mode 100644 packages/telemetry/node-server-sdk-otel/tsconfig.eslint.json create mode 100644 packages/telemetry/node-server-sdk-otel/tsconfig.json create mode 100644 packages/telemetry/node-server-sdk-otel/tsconfig.ref.json create mode 100644 packages/telemetry/node-server-sdk-otel/typedoc.json diff --git a/.github/ISSUE_TEMPLATE/package-stores-node-server-sdk-otel--bug_report.md b/.github/ISSUE_TEMPLATE/package-stores-node-server-sdk-otel--bug_report.md new file mode 100644 index 000000000..8887a360c --- /dev/null +++ b/.github/ISSUE_TEMPLATE/package-stores-node-server-sdk-otel--bug_report.md @@ -0,0 +1,36 @@ +--- +name: '@launchdarkly/node-server-sdk-otel Bug Report' +about: Create a report to help us improve +title: '' +labels: 'package: telemetry/node-server-sdk-otel, bug' +assignees: '' +--- + +**Is this a support request?** +This issue tracker is maintained by LaunchDarkly SDK developers and is intended for feedback on the code in this library. If you're not sure whether the problem you are having is specifically related to this library, or to the LaunchDarkly service overall, it may be more appropriate to contact the LaunchDarkly support team; they can help to investigate the problem and will consult the SDK team if necessary. You can submit a support request by going [here](https://support.launchdarkly.com/) and clicking "submit a request", or by emailing support@launchdarkly.com. + +Note that issues filed on this issue tracker are publicly accessible. Do not provide any private account information on your issues. If your problem is specific to your account, you should submit a support request as described above. + +**Describe the bug** +A clear and concise description of what the bug is. + +**To reproduce** +Steps to reproduce the behavior. + +**Expected behavior** +A clear and concise description of what you expected to happen. + +**Logs** +If applicable, add any log output related to your problem. + +**SDK version** +The version of this SDK that you are using. + +**Language version, developer tools** +For instance, Go 1.11 or Ruby 2.5.3. If you are using a language that requires a separate compiler, such as C, please include the name and version of the compiler too. + +**OS/platform** +For instance, Ubuntu 16.04, Windows 10, or Android 4.0.3. If your code is running in a browser, please also include the browser type and version. + +**Additional context** +Add any other context about the problem here. diff --git a/.github/ISSUE_TEMPLATE/package-stores-node-server-sdk-otel--feature_request.md b/.github/ISSUE_TEMPLATE/package-stores-node-server-sdk-otel--feature_request.md new file mode 100644 index 000000000..7f3383906 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/package-stores-node-server-sdk-otel--feature_request.md @@ -0,0 +1,19 @@ +--- +name: '@launchdarkly/node-server-sdk-otel Feature Request' +about: Suggest an idea for this project +title: '' +labels: 'package: telemetry/node-server-sdk-otel, feature' +assignees: '' +--- + +**Is your feature request related to a problem? Please describe.** +A clear and concise description of what the problem is. Ex. I would love to see the SDK [...does something new...] + +**Describe the solution you'd like** +A clear and concise description of what you want to happen. + +**Describe alternatives you've considered** +A clear and concise description of any alternative solutions or features you've considered. + +**Additional context** +Add any other context about the feature request here. diff --git a/.github/workflows/manual-publish-docs.yml b/.github/workflows/manual-publish-docs.yml index 9380d4e17..88133c146 100644 --- a/.github/workflows/manual-publish-docs.yml +++ b/.github/workflows/manual-publish-docs.yml @@ -18,6 +18,7 @@ on: - packages/sdk/akamai-edgekv - packages/store/node-server-sdk-redis - packages/store/node-server-sdk-dynamodb + - packages/telemetry/node-server-sdk-otel name: Publish Documentation jobs: build-publish: diff --git a/.github/workflows/manual-publish.yml b/.github/workflows/manual-publish.yml index ca6ddc421..d75cb0319 100644 --- a/.github/workflows/manual-publish.yml +++ b/.github/workflows/manual-publish.yml @@ -21,6 +21,7 @@ on: - packages/sdk/akamai-edgekv - packages/store/node-server-sdk-redis - packages/store/node-server-sdk-dynamodb + - packages/telemetry/node-server-sdk-otel prerelease: description: 'Is this a prerelease. If so, then the latest tag will not be updated in npm.' type: boolean diff --git a/.github/workflows/node-otel.yml b/.github/workflows/node-otel.yml new file mode 100644 index 000000000..0edf497ae --- /dev/null +++ b/.github/workflows/node-otel.yml @@ -0,0 +1,27 @@ +name: telemetry/node-server-sdk-otel + +on: + push: + branches: [main, 'feat/**'] + paths-ignore: + - '**.md' #Do not need to run CI for markdown changes. + pull_request: + branches: [main, 'feat/**'] + paths-ignore: + - '**.md' + +jobs: + build-test-node-server-otel: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-node@v3 + with: + node-version: 16 + registry-url: 'https://registry.npmjs.org' + - id: shared + name: Shared CI Steps + uses: ./actions/ci + with: + workspace_name: '@launchdarkly/node-server-sdk-otel' + workspace_path: packages/telemetry/node-server-sdk-otel diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml index acbbd82b0..70dbaf2b5 100644 --- a/.github/workflows/release-please.yml +++ b/.github/workflows/release-please.yml @@ -21,6 +21,7 @@ jobs: package-akamai-edgekv-released: ${{ steps.release.outputs['packages/sdk/akamai-edgekv--release_created'] }} package-node-server-sdk-redis-release: ${{ steps.release.outputs['packages/store/node-server-sdk-redis--release_created'] }} package-node-server-sdk-dynamodb-release: ${{ steps.release.outputs['packages/store/node-server-sdk-dynamodb--release_created'] }} + package-node-server-sdk-otel-release: ${{ steps.release.outputs['packages/telemetry/node-server-sdk-otel--release_created'] }} steps: - uses: google-github-actions/release-please-action@v4 id: release @@ -286,7 +287,7 @@ jobs: permissions: id-token: write contents: write - if: ${{ needs.release-please.outputs.package-node-server-sdk-redis-release }} + if: ${{ needs.release-please.outputs.package-node-server-sdk-redis-release == 'true' }} steps: - uses: actions/checkout@v3 - uses: actions/setup-node@v3 @@ -313,7 +314,7 @@ jobs: permissions: id-token: write contents: write - if: ${{ needs.release-please.outputs.package-node-server-sdk-dynamodb-release }} + if: ${{ needs.release-please.outputs.package-node-server-sdk-dynamodb-release == 'true' }} steps: - uses: actions/checkout@v3 - uses: actions/setup-node@v3 @@ -331,3 +332,26 @@ jobs: with: workspace_path: packages/store/node-server-sdk-dynamodb aws_assume_role: ${{ vars.AWS_ROLE_ARN }} + + release-node-server-sdk-otel: + runs-on: ubuntu-latest + needs: ['release-please'] + permissions: + id-token: write + contents: write + if: ${{ needs.release-please.outputs.package-node-server-sdk-otel-release == 'true' }} + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-node@v3 + with: + node-version: 16.x + registry-url: 'https://registry.npmjs.org' + - uses: ./actions/install-npm-version + with: + npm_version: 9.5.0 + - id: release-node-server-sdk-otel + name: Full release of packages/telemetry/node-server-sdk-otel + uses: ./actions/full-release + with: + workspace_path: packages/telemetry/node-server-sdk-otel + aws_assume_role: ${{ vars.AWS_ROLE_ARN }} diff --git a/.release-please-manifest.json b/.release-please-manifest.json index dd9df542a..6e97de025 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -11,5 +11,6 @@ "packages/store/node-server-sdk-dynamodb": "6.1.5", "packages/store/node-server-sdk-redis": "4.1.5", "packages/shared/sdk-client": "1.0.3", - "packages/sdk/react-native": "10.0.5" + "packages/sdk/react-native": "10.0.5", + "packages/telemetry/node-server-sdk-otel": "0.0.1" } diff --git a/README.md b/README.md index 4ea80c2d0..c50a78700 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,10 @@ This includes shared libraries, used by SDKs and other tools, as well as SDKs. | [@launchdarkly/node-server-sdk-redis](packages/store/node-server-sdk-redis/README.md) | [![NPM][node-redis-npm-badge]][node-redis-npm-link] | [Node Redis][node-redis-issues] | [![Actions Status][node-redis-ci-badge]][node-redis-ci] | | [@launchdarkly/node-server-sdk-dynamodb](packages/store/node-server-sdk-dynamodb/README.md) | [![NPM][node-dynamodb-npm-badge]][node-dynamodb-npm-link] | [Node DynamoDB][node-dynamodb-issues] | [![Actions Status][node-dynamodb-ci-badge]][node-dynamodb-ci] | +| Telemetry Packages | npm | issues | tests | +| ------------------------------------------------------------------------------------------- | --------------------------------------------------------- | ------------------------------------- | ------------------------------------------------------------- | +| [@launchdarkly/node-server-sdk-otel](packages/telemetry/node-server-sdk-otel/README.md) | [![NPM][node-otel-npm-badge]][node-otel-npm-link] | [Node OTel][node-otel-issues] | [![Actions Status][node-otel-ci-badge]][node-otel-ci] | + ## Organization `packages` Top level directory containing package implementations. @@ -36,6 +40,8 @@ This includes shared libraries, used by SDKs and other tools, as well as SDKs. `packages/store` Persistent store packages for use with SDKs in this repository. +`packages/telemetry` Packages for adding telemetry support to SDKs. + ## LaunchDarkly overview [LaunchDarkly](https://www.launchdarkly.com) is a feature management platform that serves trillions of feature flags daily to help teams build better software, faster. [Get started](https://docs.launchdarkly.com/home/getting-started) using LaunchDarkly today! @@ -157,3 +163,9 @@ We encourage pull requests and other contributions from the community. Check out [node-dynamodb-npm-badge]: https://img.shields.io/npm/v/@launchdarkly/node-server-sdk-dynamodb.svg?style=flat-square [node-dynamodb-npm-link]: https://www.npmjs.com/package/@launchdarkly/node-server-sdk-dynamodb [node-dynamodb-issues]: https://github.com/launchdarkly/js-core/issues?q=is%3Aissue+is%3Aopen+label%3A%22package%3A+store%2Fnode-server-sdk-dynamodb%22+ +[//]: # 'telemetry/node-server-sdk-otel' +[node-otel-ci-badge]: https://github.com/launchdarkly/js-core/actions/workflows/node-otel.yml/badge.svg +[node-otel-ci]: https://github.com/launchdarkly/js-core/actions/workflows/node-otel.yml +[node-otel-npm-badge]: https://img.shields.io/npm/v/@launchdarkly/node-server-sdk-otel.svg?style=flat-square +[node-otel-npm-link]: https://www.npmjs.com/package/@launchdarkly/node-server-sdk-otel +[node-otel-issues]: https://github.com/launchdarkly/js-core/issues?q=is%3Aissue+is%3Aopen+label%3A%22package%3A+telemetry%2Fnode-server-sdk-otel%22+ \ No newline at end of file diff --git a/package.json b/package.json index 927b215bb..7b80f9ad6 100644 --- a/package.json +++ b/package.json @@ -13,14 +13,13 @@ "packages/sdk/react-native", "packages/sdk/react-native/example", "packages/sdk/vercel", - "packages/sdk/vercel/examples/complete", - "packages/sdk/vercel/examples/route-handler", "packages/sdk/akamai-base", "packages/sdk/akamai-base/example", "packages/sdk/akamai-edgekv", "packages/sdk/akamai-edgekv/example", "packages/store/node-server-sdk-redis", - "packages/store/node-server-sdk-dynamodb" + "packages/store/node-server-sdk-dynamodb", + "packages/telemetry/node-server-sdk-otel" ], "private": true, "scripts": { diff --git a/packages/telemetry/node-server-sdk-otel/LICENSE b/packages/telemetry/node-server-sdk-otel/LICENSE new file mode 100644 index 000000000..a3c1557e3 --- /dev/null +++ b/packages/telemetry/node-server-sdk-otel/LICENSE @@ -0,0 +1,13 @@ +Copyright 2024 Catamorphic, Co. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. \ No newline at end of file diff --git a/packages/telemetry/node-server-sdk-otel/README.md b/packages/telemetry/node-server-sdk-otel/README.md new file mode 100644 index 000000000..9783df499 --- /dev/null +++ b/packages/telemetry/node-server-sdk-otel/README.md @@ -0,0 +1,69 @@ +# LaunchDarkly Server-Side SDK for Node.js - OpenTelemetry integration + +[![NPM][node-otel-npm-badge]][node-otel-npm-link] +[![Actions Status][node-otel-ci-badge]][node-otel-ci] +[![Documentation](https://img.shields.io/static/v1?label=GitHub+Pages&message=API+reference&color=00add8)](https://launchdarkly.github.io/js-core/packages/telemetry/node-server-sdk-otel/docs/) + +## LaunchDarkly overview + +[LaunchDarkly](https://www.launchdarkly.com) is a feature management platform that serves over 100 billion feature flags daily to help teams build better software, faster. [Get started](https://docs.launchdarkly.com/home/getting-started) using LaunchDarkly today! + +[![Twitter Follow](https://img.shields.io/twitter/follow/launchdarkly.svg?style=social&label=Follow&maxAge=2592000)](https://twitter.com/intent/follow?screen_name=launchdarkly) + +## Supported Node versions + +This package is compatible with Node.js versions 14 and above. + +## Quick setup + +This assumes that you have already installed the LaunchDarkly Node.js SDK. + +1. Install this package with `npm` or `yarn`: + +```shell +npm install @launchdarkly/node-server-sdk-otel --save +``` + +2. If your application does not already have its' own dependency on the `@opentelemetry/api` package, add `@opentelemetry/api` as well: + +```shell +npm install @opentelemetry/api --save +``` + +3. Import the tracing hook: + +```typescript +import { TracingHook } from '@launchdarkly/node-server-sdk-otel'; +``` + +4. When configuring your SDK client, add the `TracingHook` + +```typescript +import { init } from '@launchdarkly/node-server-sdk'; + +const client = init('YOUR SDK KEY', {hooks: [new TracingHook()]}); +``` + +## Contributing + +We encourage pull requests and other contributions from the community. Check out our [contributing guidelines](CONTRIBUTING.md) for instructions on how to contribute to this SDK. + +## About LaunchDarkly + +- LaunchDarkly is a continuous delivery platform that provides feature flags as a service and allows developers to iterate quickly and safely. We allow you to easily flag your features and manage them from the LaunchDarkly dashboard. With LaunchDarkly, you can: + - Roll out a new feature to a subset of your users (like a group of users who opt-in to a beta tester group), gathering feedback and bug reports from real-world use cases. + - Gradually roll out a feature to an increasing percentage of users, and track the effect that the feature has on key metrics (for instance, how likely is a user to complete a purchase if they have feature A versus feature B?). + - Turn off a feature that you realize is causing performance problems in production, without needing to re-deploy, or even restart the application with a changed configuration file. + - Grant access to certain features based on user attributes, like payment plan (eg: users on the ‘gold’ plan get access to more features than users in the ‘silver’ plan). + - Disable parts of your application to facilitate maintenance, without taking everything offline. +- LaunchDarkly provides feature flag SDKs for a wide variety of languages and technologies. Check out [our documentation](https://docs.launchdarkly.com/sdk) for a complete list. +- Explore LaunchDarkly + - [launchdarkly.com](https://www.launchdarkly.com/ 'LaunchDarkly Main Website') for more information + - [docs.launchdarkly.com](https://docs.launchdarkly.com/ 'LaunchDarkly Documentation') for our documentation and SDK reference guides + - [apidocs.launchdarkly.com](https://apidocs.launchdarkly.com/ 'LaunchDarkly API Documentation') for our API documentation + - [blog.launchdarkly.com](https://blog.launchdarkly.com/ 'LaunchDarkly Blog Documentation') for the latest product updates + +[node-otel-ci-badge]: https://github.com/launchdarkly/js-core/actions/workflows/node-otel.yml/badge.svg +[node-otel-ci]: https://github.com/launchdarkly/js-core/actions/workflows/node-otel.yml +[node-otel-npm-badge]: https://img.shields.io/npm/v/@launchdarkly/node-server-sdk-otel.svg?style=flat-square +[node-otel-npm-link]: https://www.npmjs.com/package/@launchdarkly/node-server-sdk-otel diff --git a/packages/telemetry/node-server-sdk-otel/jest.config.js b/packages/telemetry/node-server-sdk-otel/jest.config.js new file mode 100644 index 000000000..bcd6a8d01 --- /dev/null +++ b/packages/telemetry/node-server-sdk-otel/jest.config.js @@ -0,0 +1,8 @@ +module.exports = { + transform: { '^.+\\.ts?$': 'ts-jest' }, + testMatch: ['**/*.test.ts?(x)'], + testEnvironment: 'node', + moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'], + collectCoverageFrom: ['src/**/*.ts'], + setupFilesAfterEnv: ['@launchdarkly/private-js-mocks/setup'], +}; diff --git a/packages/telemetry/node-server-sdk-otel/package.json b/packages/telemetry/node-server-sdk-otel/package.json new file mode 100644 index 000000000..70c2534e9 --- /dev/null +++ b/packages/telemetry/node-server-sdk-otel/package.json @@ -0,0 +1,63 @@ +{ + "name": "@launchdarkly/node-server-sdk-otel", + "version": "0.0.1", + "type": "commonjs", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "homepage": "https://github.com/launchdarkly/js-core/tree/main/packages/telemetry/node-server-sdk-otel", + "repository": { + "type": "git", + "url": "https://github.com/launchdarkly/js-core.git" + }, + "description": "OpenTelemetry integration for the LaunchDarkly Server-Side SDK for Node.js", + "files": [ + "dist" + ], + "keywords": [ + "launchdarkly", + "analytics", + "client" + ], + "scripts": { + "doc": "../../../scripts/build-doc.sh .", + "test": "npx jest --ci", + "build": "npx tsc", + "clean": "npx tsc --build --clean", + "prettier": "prettier --write 'src/*.@(js|ts|tsx|json)'", + "check": "yarn && yarn prettier && yarn lint && tsc && yarn test", + "lint": "npx eslint . --ext .ts" + }, + "license": "Apache-2.0", + "peerDependencies": { + "@launchdarkly/node-server-sdk": "9.2.2", + "@opentelemetry/api": ">=1.3.0" + }, + "devDependencies": { + "@launchdarkly/node-server-sdk": "9.2.2", + "@launchdarkly/private-js-mocks": "0.0.1", + "@opentelemetry/api": ">=1.3.0", + "@opentelemetry/sdk-node": "0.49.1", + "@opentelemetry/sdk-trace-node": "1.22.0", + "@testing-library/dom": "^9.3.1", + "@testing-library/jest-dom": "^5.16.5", + "@types/jest": "^29.5.3", + "@types/semver": "^7.5.0", + "@typescript-eslint/eslint-plugin": "^6.20.0", + "@typescript-eslint/parser": "^6.20.0", + "eslint": "^8.45.0", + "eslint-config-airbnb-base": "^15.0.0", + "eslint-config-airbnb-typescript": "^17.1.0", + "eslint-config-prettier": "^8.8.0", + "eslint-plugin-import": "^2.27.5", + "eslint-plugin-jest": "^27.6.3", + "eslint-plugin-prettier": "^5.0.0", + "jest": "^29.6.1", + "jest-diff": "^29.6.1", + "jest-environment-jsdom": "^29.6.1", + "launchdarkly-js-test-helpers": "^2.2.0", + "prettier": "^3.0.0", + "ts-jest": "^29.1.1", + "typedoc": "0.25.0", + "typescript": "5.1.6" + } +} diff --git a/packages/telemetry/node-server-sdk-otel/src/TracingHook.test.ts b/packages/telemetry/node-server-sdk-otel/src/TracingHook.test.ts new file mode 100644 index 000000000..7438fe9c3 --- /dev/null +++ b/packages/telemetry/node-server-sdk-otel/src/TracingHook.test.ts @@ -0,0 +1,138 @@ +import { trace } from '@opentelemetry/api'; +import { NodeSDK } from '@opentelemetry/sdk-node'; +import { InMemorySpanExporter, SimpleSpanProcessor } from '@opentelemetry/sdk-trace-node'; + +import { basicLogger, init, integrations } from '@launchdarkly/node-server-sdk'; + +import TracingHook from './TracingHook'; + +const spanExporter = new InMemorySpanExporter(); +const sdk = new NodeSDK({ + serviceName: 'ryan-test', + spanProcessors: [new SimpleSpanProcessor(spanExporter)], +}); +sdk.start(); + +it('validates configuration', async () => { + const messages: string[] = []; + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const hook = new TracingHook({ + // @ts-ignore + spans: 'potato', + // @ts-ignore + includeVariant: 'potato', + logger: basicLogger({ + destination: (text) => { + messages.push(text); + }, + }), + }); + + expect(messages.length).toEqual(2); + expect(messages[0]).toEqual( + 'error: [LaunchDarkly] Config option "includeVariant" should be of type boolean, got string, using default value', + ); + expect(messages[1]).toEqual( + 'error: [LaunchDarkly] Config option "spans" should be of type boolean, got string, using default value', + ); +}); + +it('instance can be created with default config', () => { + expect(() => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const hook = new TracingHook(); + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const hook2 = new TracingHook({}); + }).not.toThrow(); +}); + +describe('with a testing otel span collector', () => { + afterEach(async () => { + spanExporter.reset(); + }); + + it('produces span events', async () => { + const td = new integrations.TestData(); + const client = init('bad-key', { + sendEvents: false, + updateProcessor: td.getFactory(), + hooks: [new TracingHook()], + }); + + const tracer = trace.getTracer('trace-hook-test-tracer'); + await tracer.startActiveSpan('test-span', { root: true }, async (span) => { + await client.boolVariation('test-bool', { kind: 'user', key: 'user-key' }, false); + span.end(); + }); + + const spans = spanExporter.getFinishedSpans(); + const spanEvent = spans[0]!.events[0]!; + expect(spanEvent.name).toEqual('feature_flag'); + expect(spanEvent.attributes!['feature_flag.key']).toEqual('test-bool'); + expect(spanEvent.attributes!['feature_flag.provider_name']).toEqual('LaunchDarkly'); + expect(spanEvent.attributes!['feature_flag.context.key']).toEqual('user-key'); + expect(spanEvent.attributes!['feature_flag.variant']).toBeUndefined(); + }); + + it('can include variant in span events', async () => { + const td = new integrations.TestData(); + const client = init('bad-key', { + sendEvents: false, + updateProcessor: td.getFactory(), + hooks: [new TracingHook({ includeVariant: true })], + }); + + const tracer = trace.getTracer('trace-hook-test-tracer'); + await tracer.startActiveSpan('test-span', { root: true }, async (span) => { + await client.boolVariation('test-bool', { kind: 'user', key: 'user-key' }, false); + span.end(); + }); + + const spans = spanExporter.getFinishedSpans(); + const spanEvent = spans[0]!.events[0]!; + expect(spanEvent.attributes!['feature_flag.variant']).toEqual('false'); + }); + + it('can include variation spans', async () => { + const td = new integrations.TestData(); + const client = init('bad-key', { + sendEvents: false, + updateProcessor: td.getFactory(), + hooks: [new TracingHook({ spans: true })], + }); + + const tracer = trace.getTracer('trace-hook-test-tracer'); + await tracer.startActiveSpan('test-span', { root: true }, async (span) => { + await client.boolVariation('test-bool', { kind: 'user', key: 'user-key' }, false); + span.end(); + }); + + const spans = spanExporter.getFinishedSpans(); + const variationSpan = spans[0]; + expect(variationSpan.name).toEqual('LDClient.boolVariation'); + expect(variationSpan.attributes['feature_flag.context.key']).toEqual('user-key'); + }); + + it('can handle multi-context key requirements', async () => { + const td = new integrations.TestData(); + const client = init('bad-key', { + sendEvents: false, + updateProcessor: td.getFactory(), + hooks: [new TracingHook()], + }); + + const tracer = trace.getTracer('trace-hook-test-tracer'); + await tracer.startActiveSpan('test-span', { root: true }, async (span) => { + await client.boolVariation( + 'test-bool', + { kind: 'multi', user: { key: 'bob' }, org: { key: 'org-key' } }, + false, + ); + span.end(); + }); + + const spans = spanExporter.getFinishedSpans(); + const spanEvent = spans[0]!.events[0]!; + expect(spanEvent.attributes!['feature_flag.context.key']).toEqual('org:org-key:user:bob'); + }); +}); diff --git a/packages/telemetry/node-server-sdk-otel/src/TracingHook.ts b/packages/telemetry/node-server-sdk-otel/src/TracingHook.ts new file mode 100644 index 000000000..82aade9ff --- /dev/null +++ b/packages/telemetry/node-server-sdk-otel/src/TracingHook.ts @@ -0,0 +1,174 @@ +// eslint-disable-next-line max-classes-per-file +import { Attributes, context, Span, trace } from '@opentelemetry/api'; + +import { + basicLogger, + Context, + integrations, + LDEvaluationDetail, + LDLogger, + OptionMessages, + SafeLogger, + TypeValidators, +} from '@launchdarkly/node-server-sdk'; + +const FEATURE_FLAG_SCOPE = 'feature_flag'; +const FEATURE_FLAG_KEY_ATTR = `${FEATURE_FLAG_SCOPE}.key`; +const FEATURE_FLAG_PROVIDER_ATTR = `${FEATURE_FLAG_SCOPE}.provider_name`; +const FEATURE_FLAG_CONTEXT_KEY_ATTR = `${FEATURE_FLAG_SCOPE}.context.key`; +const FEATURE_FLAG_VARIANT_ATTR = `${FEATURE_FLAG_SCOPE}.variant`; + +const TRACING_HOOK_NAME = 'LaunchDarkly Tracing Hook'; + +/** + * Options which allow configuring the tracing hook. + */ +export interface TracingHookOptions { + /** + * Experimental: If set to true, then the tracing hook will add spans for each variation + * method call. Span events are always added and are unaffected by this + * setting. + * + * The default value is false. + * + * This feature is experimental and the data in the spans, or nesting of spans, + * could change in future versions. + */ + spans?: boolean; + + /** + * If set to true, then the tracing hook will add the evaluated flag value + * to span events and spans. + * + * The default is false. + */ + includeVariant?: boolean; + + /** + * Set to use a custom logging configuration, otherwise the logging will be done + * using `console`. + */ + logger?: LDLogger; +} + +interface ValidatedHookOptions { + spans: boolean; + includeVariant: boolean; + logger: LDLogger; +} + +type SpanTraceData = { + span?: Span; +}; + +const defaultOptions: ValidatedHookOptions = { + spans: false, + includeVariant: false, + logger: basicLogger({ name: TRACING_HOOK_NAME }), +}; + +function validateOptions(options?: TracingHookOptions): ValidatedHookOptions { + const validatedOptions: ValidatedHookOptions = { ...defaultOptions }; + + if (options?.logger !== undefined) { + validatedOptions.logger = new SafeLogger(options.logger, defaultOptions.logger); + } + + if (options?.includeVariant !== undefined) { + if (TypeValidators.Boolean.is(options.includeVariant)) { + validatedOptions.includeVariant = options.includeVariant; + } else { + validatedOptions.logger.error( + OptionMessages.wrongOptionType('includeVariant', 'boolean', typeof options?.includeVariant), + ); + } + } + + if (options?.spans !== undefined) { + if (TypeValidators.Boolean.is(options.spans)) { + validatedOptions.spans = options.spans; + } else { + validatedOptions.logger.error( + OptionMessages.wrongOptionType('spans', 'boolean', typeof options?.spans), + ); + } + } + + return validatedOptions; +} + +/** + * The TracingHook adds OpenTelemetry support to the LaunchDarkly SDK. + * + * By default, span events will be added for each call to a "Variation" method. + * + * The span event will include the canonicalKey of the context, the provider of the evaluation + * (LaunchDarkly), and the key of the flag being evaluated. + */ +export default class TracingHook implements integrations.Hook { + private readonly options: ValidatedHookOptions; + private readonly tracer = trace.getTracer('launchdarkly-client'); + + /** + * Construct a TracingHook with the given options. + * + * @param options Options to customize tracing behavior. + */ + constructor(options?: TracingHookOptions) { + this.options = validateOptions(options); + } + + /** + * Get the meta-data for the tracing hook. + */ + getMetadata(): integrations.HookMetadata { + return { + name: TRACING_HOOK_NAME, + }; + } + + /** + * Implements the "beforeEvaluation" stage of the TracingHook. + */ + beforeEvaluation?( + hookContext: integrations.EvaluationSeriesContext, + data: integrations.EvaluationSeriesData, + ): integrations.EvaluationSeriesData { + if (this.options.spans) { + const { canonicalKey } = Context.fromLDContext(hookContext.context); + + const span = this.tracer.startSpan(hookContext.method, undefined, context.active()); + span.setAttribute('feature_flag.context.key', canonicalKey); + span.setAttribute('feature_flag.key', hookContext.flagKey); + + return { ...data, span }; + } + return data; + } + + /** + * Implements the "afterEvaluation" stage of the TracingHook. + */ + afterEvaluation?( + hookContext: integrations.EvaluationSeriesContext, + data: integrations.EvaluationSeriesData, + detail: LDEvaluationDetail, + ): integrations.EvaluationSeriesData { + (data as SpanTraceData).span?.end(); + + const currentTrace = trace.getActiveSpan(); + if (currentTrace) { + const eventAttributes: Attributes = { + [FEATURE_FLAG_KEY_ATTR]: hookContext.flagKey, + [FEATURE_FLAG_PROVIDER_ATTR]: 'LaunchDarkly', + [FEATURE_FLAG_CONTEXT_KEY_ATTR]: Context.fromLDContext(hookContext.context).canonicalKey, + }; + if (this.options.includeVariant) { + eventAttributes[FEATURE_FLAG_VARIANT_ATTR] = JSON.stringify(detail.value); + } + currentTrace.addEvent(FEATURE_FLAG_SCOPE, eventAttributes); + } + + return data; + } +} diff --git a/packages/telemetry/node-server-sdk-otel/src/index.ts b/packages/telemetry/node-server-sdk-otel/src/index.ts new file mode 100644 index 000000000..c814dffa3 --- /dev/null +++ b/packages/telemetry/node-server-sdk-otel/src/index.ts @@ -0,0 +1,2 @@ +export { default as TracingHook } from './TracingHook'; +export { TracingHookOptions } from './TracingHook'; diff --git a/packages/telemetry/node-server-sdk-otel/tsconfig.eslint.json b/packages/telemetry/node-server-sdk-otel/tsconfig.eslint.json new file mode 100644 index 000000000..56c9b3830 --- /dev/null +++ b/packages/telemetry/node-server-sdk-otel/tsconfig.eslint.json @@ -0,0 +1,5 @@ +{ + "extends": "./tsconfig.json", + "include": ["/**/*.ts"], + "exclude": ["node_modules"] +} diff --git a/packages/telemetry/node-server-sdk-otel/tsconfig.json b/packages/telemetry/node-server-sdk-otel/tsconfig.json new file mode 100644 index 000000000..dbe90f4cc --- /dev/null +++ b/packages/telemetry/node-server-sdk-otel/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "rootDir": "src", + "outDir": "dist", + "target": "ES2017", + "lib": ["es6"], + "module": "commonjs", + "strict": true, + "noImplicitOverride": true, + // Needed for CommonJS modules: markdown-it, fs-extra + "allowSyntheticDefaultImports": true, + "sourceMap": true, + "declaration": true, + "declarationMap": true, // enables importers to jump to source + "stripInternal": true, + }, + "include": ["src"], + "exclude": ["**/*.test.ts", "dist", "node_modules", "__tests__"] +} diff --git a/packages/telemetry/node-server-sdk-otel/tsconfig.ref.json b/packages/telemetry/node-server-sdk-otel/tsconfig.ref.json new file mode 100644 index 000000000..0c86b2c55 --- /dev/null +++ b/packages/telemetry/node-server-sdk-otel/tsconfig.ref.json @@ -0,0 +1,7 @@ +{ + "extends": "./tsconfig.json", + "include": ["src/**/*"], + "compilerOptions": { + "composite": true + } +} diff --git a/packages/telemetry/node-server-sdk-otel/typedoc.json b/packages/telemetry/node-server-sdk-otel/typedoc.json new file mode 100644 index 000000000..7ac616b54 --- /dev/null +++ b/packages/telemetry/node-server-sdk-otel/typedoc.json @@ -0,0 +1,5 @@ +{ + "extends": ["../../../typedoc.base.json"], + "entryPoints": ["src/index.ts"], + "out": "docs" +} diff --git a/release-please-config.json b/release-please-config.json index 9aa9c3649..b15eecceb 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -18,7 +18,10 @@ "extra-files": ["src/index.ts"] }, "packages/store/node-server-sdk-dynamodb": {}, - "packages/store/node-server-sdk-redis": {} + "packages/store/node-server-sdk-redis": {}, + "packages/telemetry/node-server-sdk-otel": { + "bump-minor-pre-major": true + } }, "plugins": ["node-workspace"] } diff --git a/tsconfig.json b/tsconfig.json index 3c2f59d95..28f83728f 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -45,6 +45,9 @@ }, { "path": "./packages/store/node-server-sdk-dynamodb/tsconfig.ref.json" + }, + { + "path": "./packages/telemetry/node-server-sdk-otel/tsconfig.ref.json" } ] }