diff --git a/.github/workflows/verify.yaml b/.github/workflows/verify.yaml index f2576ef..b905617 100644 --- a/.github/workflows/verify.yaml +++ b/.github/workflows/verify.yaml @@ -41,3 +41,33 @@ jobs: - name: test run: | npm test + + verify-minimum-version-check: + + runs-on: ubuntu-latest + + strategy: + matrix: + node-version: [ '16.x' ] + + name: Verify Minimum Version Check (Node.js ${{ matrix.node-version }}) + + steps: + - uses: actions/checkout@v4 + with: + submodules: true + - uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node-version }} + cache: 'npm' + + - name: versions + run: | + node --version + npm --version + + - run: npm ci + + - name: integration test + run: | + npm run test:integration diff --git a/package-lock.json b/package-lock.json index 81cd2c0..3979ac5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -36,6 +36,7 @@ "nyc": "^15.1.0", "prettier": "^3.2.5", "protobufjs": "^7.2.6", + "semver": "^7.6.2", "sinon": "^17.0.1", "ts-node": "^10.9.2", "ts-proto": "^1.172.0", @@ -5019,17 +5020,6 @@ "get-func-name": "^2.0.1" } }, - "node_modules/lru-cache": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", - "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/make-dir": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", @@ -6080,12 +6070,9 @@ ] }, "node_modules/semver": { - "version": "7.6.0", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.0.tgz", - "integrity": "sha512-EnwXhrlwXMk9gKu5/flx5sv/an57AkRplG3hTK68W7FRDN+k+OWBj65M7719OkA82XLBxrcX0KSHj+X5COhOVg==", - "dependencies": { - "lru-cache": "^6.0.0" - }, + "version": "7.6.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.2.tgz", + "integrity": "sha512-FNAIBWCx9qcRhoHcgcJ0gvU7SN1lYU2ZXuSfl04bSC5OpvDHFyJCjdNHomPXxjQlCBU67YW64PzY7/VIEH7F2w==", "bin": { "semver": "bin/semver.js" }, @@ -6711,11 +6698,6 @@ "node": ">=10" } }, - "node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" - }, "node_modules/yargs": { "version": "17.7.2", "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", diff --git a/package.json b/package.json index 7217054..e4897ca 100644 --- a/package.json +++ b/package.json @@ -68,6 +68,7 @@ "nyc": "^15.1.0", "prettier": "^3.2.5", "protobufjs": "^7.2.6", + "semver": "^7.6.2", "sinon": "^17.0.1", "ts-node": "^10.9.2", "ts-proto": "^1.172.0", diff --git a/src/index.ts b/src/index.ts index 518873c..455ad64 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,71 +1,47 @@ // SPDX-FileCopyrightText: Copyright 2024 Dash0 Inc. // SPDX-License-Identifier: Apache-2.0 -import { getNodeAutoInstrumentations } from '@opentelemetry/auto-instrumentations-node'; -import { OTLPMetricExporter } from '@opentelemetry/exporter-metrics-otlp-proto'; -import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-proto'; -import { Detector, DetectorSync, envDetector, hostDetector, processDetector, Resource } from '@opentelemetry/resources'; -import { PeriodicExportingMetricReader } from '@opentelemetry/sdk-metrics'; -import { NodeSDK, NodeSDKConfiguration } from '@opentelemetry/sdk-node'; - -import { version } from '../package.json'; -import PodUidDetector from './detectors/node/opentelemetry-resource-detector-kubernetes-pod'; -import ServiceNameFallbackDetector from './detectors/node/opentelemetry-resource-detector-service-name-fallback'; -import { getResourceDetectorsFromEnv } from './util/sdkUtil'; - -if (process.env.DASH0_DEBUG) { - console.log('Dash0 OpenTelemetry distribution for Node.js: Starting NodeSDK.'); -} - -let baseUrl = 'http://dash0-opentelemetry-collector-daemonset.default.svc.cluster.local:4318'; -if (process.env.DASH0_OTEL_COLLECTOR_BASE_URL) { - baseUrl = process.env.DASH0_OTEL_COLLECTOR_BASE_URL; -} - -const instrumentationConfig: any = {}; -if ( - !process.env.DASH0_ENABLE_FS_INSTRUMENTATION || - process.env.DASH0_ENABLE_FS_INSTRUMENTATION.trim().toLowerCase() !== 'true' -) { - instrumentationConfig['@opentelemetry/instrumentation-fs'] = { - enabled: false, - }; +const majorVersionLowerBound = 18; +const majorVersionTestedUpperBound = 22; +const prefix = 'Dash0 OpenTelemtry Distribution'; + +function init() { + try { + const nodeJsRuntimeVersion = process.version; + const match = nodeJsRuntimeVersion.match(/v(?\d+)\.(?:\d+)\.(?:\d+)/); + if (!match || !match.groups) { + logProhibitiveError(`Cannot parse Node.js runtime version ${nodeJsRuntimeVersion}.`); + return; + } + const majorVersion = parseInt(match.groups.majorVersion, 10); + if (isNaN(majorVersion)) { + logProhibitiveError(`Cannot parse Node.js runtime version ${nodeJsRuntimeVersion}.`); + return; + } + if (majorVersion < majorVersionLowerBound) { + logProhibitiveError( + `The distribution does not support this Node.js runtime version (${nodeJsRuntimeVersion}). The minimum supported version is Node.js ${majorVersionLowerBound}.0.0.`, + ); + return; + } + if (majorVersion > majorVersionTestedUpperBound) { + logWarning( + `Please note: The distribution has not been explicitly tested with this Node.js runtime version (${nodeJsRuntimeVersion}). The maximum tested version is Node.js ${majorVersionTestedUpperBound}.`, + ); + } + + require('./init'); + } catch (e) { + logProhibitiveError(`Initialization failed: ${e}`); + } } -const configuration: Partial = { - traceExporter: new OTLPTraceExporter({ - url: `${baseUrl}/v1/traces`, - }), - metricReader: new PeriodicExportingMetricReader({ - exporter: new OTLPMetricExporter({ - url: `${baseUrl}/v1/metrics`, - }), - }), - - instrumentations: [getNodeAutoInstrumentations(instrumentationConfig)], - - resource: new Resource({ - 'telemetry.distro.name': 'dash0-nodejs', - 'telemetry.distro.version': version, - }), -}; +init(); -// Copy the behavior of the NodeSDK constructor with regard to resource detectors, but add the pod uid detector. -// https://github.com/open-telemetry/opentelemetry-js/blob/73fddf9b5e7a93bd4cf21c2dbf444cee31d26c88/experimental/packages/opentelemetry-sdk-node/src/sdk.ts#L126-L132 -let detectors: (Detector | DetectorSync)[]; -if (process.env.OTEL_NODE_RESOURCE_DETECTORS != null) { - detectors = getResourceDetectorsFromEnv(); -} else { - detectors = [envDetector, processDetector, hostDetector]; +function logProhibitiveError(message: string) { + console.error(`[${prefix}] ${message} OpenTelemetry data will not be sent to Dash0.`); } -detectors.push(new PodUidDetector()); -detectors.push(new ServiceNameFallbackDetector()); -configuration.resourceDetectors = detectors; - -const sdk = new NodeSDK(configuration); - -sdk.start(); -if (process.env.DASH0_DEBUG) { - console.log('Dash0 OpenTelemetry distribution for Node.js: NodeSDK started.'); +function logWarning(message: string) { + console.error(`[${prefix}] ${message}`); } diff --git a/src/init.ts b/src/init.ts new file mode 100644 index 0000000..518873c --- /dev/null +++ b/src/init.ts @@ -0,0 +1,71 @@ +// SPDX-FileCopyrightText: Copyright 2024 Dash0 Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { getNodeAutoInstrumentations } from '@opentelemetry/auto-instrumentations-node'; +import { OTLPMetricExporter } from '@opentelemetry/exporter-metrics-otlp-proto'; +import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-proto'; +import { Detector, DetectorSync, envDetector, hostDetector, processDetector, Resource } from '@opentelemetry/resources'; +import { PeriodicExportingMetricReader } from '@opentelemetry/sdk-metrics'; +import { NodeSDK, NodeSDKConfiguration } from '@opentelemetry/sdk-node'; + +import { version } from '../package.json'; +import PodUidDetector from './detectors/node/opentelemetry-resource-detector-kubernetes-pod'; +import ServiceNameFallbackDetector from './detectors/node/opentelemetry-resource-detector-service-name-fallback'; +import { getResourceDetectorsFromEnv } from './util/sdkUtil'; + +if (process.env.DASH0_DEBUG) { + console.log('Dash0 OpenTelemetry distribution for Node.js: Starting NodeSDK.'); +} + +let baseUrl = 'http://dash0-opentelemetry-collector-daemonset.default.svc.cluster.local:4318'; +if (process.env.DASH0_OTEL_COLLECTOR_BASE_URL) { + baseUrl = process.env.DASH0_OTEL_COLLECTOR_BASE_URL; +} + +const instrumentationConfig: any = {}; +if ( + !process.env.DASH0_ENABLE_FS_INSTRUMENTATION || + process.env.DASH0_ENABLE_FS_INSTRUMENTATION.trim().toLowerCase() !== 'true' +) { + instrumentationConfig['@opentelemetry/instrumentation-fs'] = { + enabled: false, + }; +} + +const configuration: Partial = { + traceExporter: new OTLPTraceExporter({ + url: `${baseUrl}/v1/traces`, + }), + metricReader: new PeriodicExportingMetricReader({ + exporter: new OTLPMetricExporter({ + url: `${baseUrl}/v1/metrics`, + }), + }), + + instrumentations: [getNodeAutoInstrumentations(instrumentationConfig)], + + resource: new Resource({ + 'telemetry.distro.name': 'dash0-nodejs', + 'telemetry.distro.version': version, + }), +}; + +// Copy the behavior of the NodeSDK constructor with regard to resource detectors, but add the pod uid detector. +// https://github.com/open-telemetry/opentelemetry-js/blob/73fddf9b5e7a93bd4cf21c2dbf444cee31d26c88/experimental/packages/opentelemetry-sdk-node/src/sdk.ts#L126-L132 +let detectors: (Detector | DetectorSync)[]; +if (process.env.OTEL_NODE_RESOURCE_DETECTORS != null) { + detectors = getResourceDetectorsFromEnv(); +} else { + detectors = [envDetector, processDetector, hostDetector]; +} +detectors.push(new PodUidDetector()); +detectors.push(new ServiceNameFallbackDetector()); +configuration.resourceDetectors = detectors; + +const sdk = new NodeSDK(configuration); + +sdk.start(); + +if (process.env.DASH0_DEBUG) { + console.log('Dash0 OpenTelemetry distribution for Node.js: NodeSDK started.'); +} diff --git a/test/.mocharc.integration.js b/test/.mocharc.integration.js index 9064490..e7b9db9 100644 --- a/test/.mocharc.integration.js +++ b/test/.mocharc.integration.js @@ -8,6 +8,8 @@ module.exports = { extension: ['ts'], ignore: ['test/**/node_modules/**'], + // As long as we test the minimum version check with a Node.js version < 18.0.0, we need to enable fetch explicitly. + 'node-option': ['experimental-fetch'], recursive: true, require: ['ts-node/register'], slow: 3000, diff --git a/test/collector/CollectorChildProcessWrapper.ts b/test/collector/CollectorChildProcessWrapper.ts index 007a799..744c7b9 100644 --- a/test/collector/CollectorChildProcessWrapper.ts +++ b/test/collector/CollectorChildProcessWrapper.ts @@ -19,6 +19,11 @@ export default class CollectorChildProcessWrapper extends ChildProcessWrapper { return stats.traces >= 1; } + async hasTelemetry() { + const stats = await collector().fetchStats(); + return stats.traces >= 1 || stats.metrics >= 1 || stats.logs >= 1; + } + async fetchTelemetry() { return await collector().sendRequest({ command: 'telemetry' }); } diff --git a/test/integration/ChildProcessWrapper.ts b/test/integration/ChildProcessWrapper.ts index 4a5ac6a..71d416f 100644 --- a/test/integration/ChildProcessWrapper.ts +++ b/test/integration/ChildProcessWrapper.ts @@ -158,3 +158,19 @@ export default class ChildProcessWrapper { } class ResponseEmitter extends EventEmitter {} + +export function defaultAppConfiguration(appPort: number): ChildProcessWrapperOptions { + return { + path: 'test/apps/express-typescript', + label: 'app', + useTsNode: true, + useDistro: true, + env: { + PORT: appPort.toString(), + // have the Node.js SDK send spans every 100 ms instead of every 5 seconcds to speed up tests + OTEL_BSP_SCHEDULE_DELAY: '100', + DASH0_OTEL_COLLECTOR_BASE_URL: 'http://localhost:4318', + // OTEL_LOG_LEVEL: 'VERBOSE', + }, + }; +} diff --git a/test/integration/minimumVersion_test.ts b/test/integration/minimumVersion_test.ts new file mode 100644 index 0000000..97aba6b --- /dev/null +++ b/test/integration/minimumVersion_test.ts @@ -0,0 +1,55 @@ +// SPDX-FileCopyrightText: Copyright 2024 Dash0 Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { expect } from 'chai'; +import semver from 'semver'; +import delay from '../util/delay'; + +import runCommand from '../util/runCommand'; +import ChildProcessWrapper, { defaultAppConfiguration } from './ChildProcessWrapper'; +import { collector } from './rootHooks'; + +const { fail } = expect; + +const appPort = 1302; + +// This test verifies that the distribution does not attempt to initialize itself if the Node.js runtime version is too +// old. +const skipWhenNodeJsVersionIsGreaterOrEquals = '18.0.0'; + +describe('minimum version check', () => { + let appUnderTest: ChildProcessWrapper; + + before(async function () { + if (semver.gte(process.version, skipWhenNodeJsVersionIsGreaterOrEquals)) { + // This suite is deliberatly skipped on supported versions and only run on a Node.js version that is below the + // minimum supported version. + this.skip(); + return; + } + + await runCommand('npm ci', 'test/apps/express-typescript'); + const appConfiguration = defaultAppConfiguration(appPort); + appUnderTest = new ChildProcessWrapper(appConfiguration); + await appUnderTest.start(); + }); + + after(async () => { + if (appUnderTest) { + await appUnderTest.stop(); + } + }); + + it('should stand down without loading the distribution', async () => { + await delay(1000); + const response = await fetch(`http://localhost:${appPort}/ohai`); + await delay(2000); + expect(response.status).to.equal(200); + const responsePayload = await response.json(); + expect(responsePayload).to.deep.equal({ message: 'We make Observability easy for every developer.' }); + + if (await collector().hasTelemetry()) { + fail('The collector received telemetry data although it should not have received anything.'); + } + }); +}); diff --git a/test/integration/test.ts b/test/integration/test.ts index 93c595c..bef1c99 100644 --- a/test/integration/test.ts +++ b/test/integration/test.ts @@ -4,18 +4,27 @@ import { SpanKind } from '@opentelemetry/api'; import { expect } from 'chai'; import { readFile } from 'node:fs/promises'; +import semver from 'semver'; + import { expectResourceAttribute, expectSpanAttribute } from '../util/expectAttribute'; import { expectMatchingSpan } from '../util/expectMatchingSpan'; import runCommand from '../util/runCommand'; import waitUntil from '../util/waitUntil'; -import ChildProcessWrapper, { ChildProcessWrapperOptions } from './ChildProcessWrapper'; +import ChildProcessWrapper, { defaultAppConfiguration } from './ChildProcessWrapper'; import { collector } from './rootHooks'; +const skipWhenNodeJsVersionIsSmallerThan = '18.0.0'; + const appPort = 1302; let expectedDistroVersion: number; describe('attach', () => { - before(async () => { + before(async function () { + if (semver.lt(process.version, skipWhenNodeJsVersionIsSmallerThan)) { + this.skip(); + return; + } + await runCommand('npm ci', 'test/apps/express-typescript'); expectedDistroVersion = JSON.parse(String(await readFile('package.json'))).version; }); @@ -24,7 +33,7 @@ describe('attach', () => { let appUnderTest: ChildProcessWrapper; before(async () => { - const appConfiguration = defaultAppConfiguration(); + const appConfiguration = defaultAppConfiguration(appPort); appUnderTest = new ChildProcessWrapper(appConfiguration); await appUnderTest.start(); }); @@ -57,7 +66,7 @@ describe('attach', () => { let appUnderTest: ChildProcessWrapper; before(async () => { - const appConfiguration = defaultAppConfiguration(); + const appConfiguration = defaultAppConfiguration(appPort); appConfiguration.emulateKubernetesPodUid = true; appUnderTest = new ChildProcessWrapper(appConfiguration); await appUnderTest.start(); @@ -86,7 +95,7 @@ describe('attach', () => { let appUnderTest: ChildProcessWrapper; before(async () => { - const appConfiguration = defaultAppConfiguration(); + const appConfiguration = defaultAppConfiguration(appPort); appUnderTest = new ChildProcessWrapper(appConfiguration); await appUnderTest.start(); }); @@ -113,22 +122,6 @@ describe('attach', () => { }); }); - function defaultAppConfiguration(): ChildProcessWrapperOptions { - return { - path: 'test/apps/express-typescript', - label: 'app', - useTsNode: true, - useDistro: true, - env: { - PORT: appPort.toString(), - // have the Node.js SDK send spans every 100 ms instead of every 5 seconcds to speed up tests - OTEL_BSP_SCHEDULE_DELAY: '100', - DASH0_OTEL_COLLECTOR_BASE_URL: 'http://localhost:4318', - // OTEL_LOG_LEVEL: 'VERBOSE', - }, - }; - } - async function waitForTelemetry() { const response = await fetch(`http://localhost:${appPort}/ohai`); expect(response.status).to.equal(200);