From 436940af8b404692e8de28f56e0c6131c8b0b6a9 Mon Sep 17 00:00:00 2001 From: David Luna Date: Thu, 21 Nov 2024 12:35:46 +0100 Subject: [PATCH] chore(otel-node): add env var parser (#442) --- packages/opentelemetry-node/lib/detectors.js | 4 +- .../lib/elastic-node-sdk.js | 25 ++- .../opentelemetry-node/lib/environment.js | 163 ++++++++++-------- .../lib/instrumentations.js | 4 +- packages/opentelemetry-node/package.json | 1 + .../test/OTEL_EXPORTER_OTLP_HEADERS.test.js | 99 ----------- .../opentelemetry-node/types/environment.d.ts | 30 +++- 7 files changed, 133 insertions(+), 193 deletions(-) delete mode 100644 packages/opentelemetry-node/test/OTEL_EXPORTER_OTLP_HEADERS.test.js diff --git a/packages/opentelemetry-node/lib/detectors.js b/packages/opentelemetry-node/lib/detectors.js index ba2f6219..eff801ce 100644 --- a/packages/opentelemetry-node/lib/detectors.js +++ b/packages/opentelemetry-node/lib/detectors.js @@ -103,9 +103,7 @@ function resolveDetectors(detectors) { return detectors; } - const detectorsFromEnv = getEnvVar('OTEL_NODE_RESOURCE_DETECTORS') || 'all'; - let detectorKeys = detectorsFromEnv.split(',').map((s) => s.trim()); - + let detectorKeys = getEnvVar('OTEL_NODE_RESOURCE_DETECTORS'); if (detectorKeys.some((k) => k === 'all')) { detectorKeys = Object.keys(defaultDetectors); } else if (detectorKeys.some((k) => k === 'none')) { diff --git a/packages/opentelemetry-node/lib/elastic-node-sdk.js b/packages/opentelemetry-node/lib/elastic-node-sdk.js index 076c4a00..2e791de9 100644 --- a/packages/opentelemetry-node/lib/elastic-node-sdk.js +++ b/packages/opentelemetry-node/lib/elastic-node-sdk.js @@ -28,7 +28,11 @@ const {BatchLogRecordProcessor} = require('@opentelemetry/sdk-logs'); const {log, registerOTelDiagLogger} = require('./logging'); const {resolveDetectors} = require('./detectors'); -const {setupEnvironment, restoreEnvironment} = require('./environment'); +const { + setupEnvironment, + restoreEnvironment, + getEnvVar, +} = require('./environment'); const {getInstrumentations} = require('./instrumentations'); const {enableHostMetrics, HOST_METRICS_VIEWS} = require('./metrics/host'); // @ts-ignore - compiler options do not allow lookp outside `lib` folder @@ -63,8 +67,7 @@ class ElasticNodeSDK extends NodeSDK { // Get logs exporter protocol based on environment. const logsExportProtocol = process.env.OTEL_EXPORTER_OTLP_LOGS_PROTOCOL || - process.env.OTEL_EXPORTER_OTLP_PROTOCOL || - 'http/protobuf'; + getEnvVar('OTEL_EXPORTER_OTLP_PROTOCOL'); let logExporterType = exporterPkgNameFromEnvVar[logsExportProtocol]; if (!logExporterType) { log.warn( @@ -88,15 +91,12 @@ class ElasticNodeSDK extends NodeSDK { // TODO what `temporalityPreference`? // Disable metrics by config - const metricsDisabled = - process.env.ELASTIC_OTEL_METRICS_DISABLED === 'true'; + const metricsDisabled = getEnvVar('ELASTIC_OTEL_METRICS_DISABLED'); if (!metricsDisabled) { // Get metrics exporter protocol based on environment. - const metricsExportProtocol = process.env.OTEL_EXPORTER_OTLP_METRICS_PROTOCOL || - process.env.OTEL_EXPORTER_OTLP_PROTOCOL || - 'http/protobuf'; + getEnvVar('OTEL_EXPORTER_OTLP_PROTOCOL'); let metricExporterType = exporterPkgNameFromEnvVar[metricsExportProtocol]; if (!metricExporterType) { @@ -111,12 +111,9 @@ class ElasticNodeSDK extends NodeSDK { const {OTLPMetricExporter} = require( `@opentelemetry/exporter-metrics-otlp-${metricExporterType}` ); - // Note: Default values has been taken from the specs - // https://opentelemetry.io/docs/specs/otel/configuration/sdk-environment-variables/#periodic-exporting-metricreader - const metricsInterval = - Number(process.env.OTEL_METRIC_EXPORT_INTERVAL) || 60000; - const metricsTimeout = - Number(process.env.OTEL_METRIC_EXPORT_TIMEOUT) || 30000; + + const metricsInterval = getEnvVar('OTEL_METRIC_EXPORT_INTERVAL'); + const metricsTimeout = getEnvVar('OTEL_METRIC_EXPORT_TIMEOUT'); defaultConfig.metricReader = new metrics.PeriodicExportingMetricReader({ exporter: new OTLPMetricExporter(), diff --git a/packages/opentelemetry-node/lib/environment.js b/packages/opentelemetry-node/lib/environment.js index ad00e9be..7bec8f4c 100644 --- a/packages/opentelemetry-node/lib/environment.js +++ b/packages/opentelemetry-node/lib/environment.js @@ -17,54 +17,53 @@ * under the License. */ -// @ts-ignore - compiler options do not allow lookp outside `lib` folder -const ELASTIC_SDK_VERSION = require('../package.json').version; -const OTEL_SDK_VERSION = - require('@opentelemetry/sdk-node/package.json').version; -const USER_AGENT_PREFIX = `elastic-otel-node/${ELASTIC_SDK_VERSION}`; -const USER_AGENT_HEADER = `${USER_AGENT_PREFIX} OTel-OTLP-Exporter-JavaScript/${OTEL_SDK_VERSION}`; +// NOTE: this API may be removed in future +// ref: https://github.com/open-telemetry/opentelemetry-js/issues/5172 +const {getEnv} = require('@opentelemetry/core'); /** @type {NodeJS.ProcessEnv} */ const envToRestore = {}; /** - * Reads a string in the format `key-1=value,key2=value2` and parses - * it into an object. This is the format specified for key value pairs - * for OTEL environment vars. Example: - * https://opentelemetry.io/docs/concepts/sdk-configuration/otlp-exporter-configuration/#otel_exporter_otlp_headers - * - * If the param is not defined or falsy it returns an empty object - * + * Returns an array of strings from the given input. If undefined returns the fallback + * value. * @param {string | undefined} str - * @returns {Record} + * @param {string[]} [fallback=[]] + * @returns {string[]} */ -function parseKeyValuePairs(str) { +function parseStringList(str, fallback = []) { if (!str) { - return {}; + return fallback; } + return str.split(',').map((s) => s.trim()); +} - const pairs = str.split(','); - - return pairs.reduce((record, text) => { - const sepIndex = text.indexOf('='); - const key = text.substring(0, sepIndex); - const val = text.substring(sepIndex + 1); - - record[key] = val; - return record; - }, {}); +/** + * Returns a boolean from the given input + * @param {string | undefined} str + * @param {boolean} fallback + * @returns {boolean} + */ +function parseBoolean(str, fallback) { + if (!str) { + return fallback; + } + return str.toLowerCase() === 'true'; } /** - * Serializes an object to a string in the format `key-1=value,key2=value2` - * - * @param {Record} pairs - * @returns {string} + * Returns a boolean from te given input + * @param {string | undefined} str + * @param {number} fallback + * @returns {number} */ -function serializeKeyValuePairs(pairs) { - return Object.entries(pairs) - .map(([key, val]) => `${key}=${val}`) - .join(','); +function parseNumber(str, fallback) { + if (!str) { + return fallback; + } + + const num = Number(str); + return isNaN(num) ? fallback : num; } /** @@ -79,37 +78,6 @@ function setupEnvironment() { process.env.OTEL_TRACES_EXPORTER = 'otlp'; } - // Work with exporter headers: - // - Add our `user-agent` header in headers for traces, matrics & logs - // - comply with OTEL_EXPORTER_OTLP_HEADERS spec until the issue is fixed - // TODO: should we stash and restore? if so the restoration should be done - // after start - const userAgentHeader = {'User-Agent': USER_AGENT_HEADER}; - // TODO: for now we omit our user agent if already defined elsewhere - const tracesHeaders = Object.assign( - {}, - userAgentHeader, - parseKeyValuePairs(process.env.OTEL_EXPORTER_OTLP_TRACES_HEADERS) - ); - process.env.OTEL_EXPORTER_OTLP_TRACES_HEADERS = - serializeKeyValuePairs(tracesHeaders); - - const metricsHeaders = Object.assign( - {}, - userAgentHeader, - parseKeyValuePairs(process.env.OTEL_EXPORTER_OTLP_METRICS_HEADERS) - ); - process.env.OTEL_EXPORTER_OTLP_METRICS_HEADERS = - serializeKeyValuePairs(metricsHeaders); - - const logsHeaders = Object.assign( - {}, - userAgentHeader, - parseKeyValuePairs(process.env.OTEL_EXPORTER_OTLP_LOGS_HEADERS) - ); - process.env.OTEL_EXPORTER_OTLP_LOGS_HEADERS = - serializeKeyValuePairs(logsHeaders); - if ('OTEL_LOG_LEVEL' in process.env) { envToRestore['OTEL_LOG_LEVEL'] = process.env.OTEL_LOG_LEVEL; // Make sure NodeSDK doesn't see this envvar and overwrite our diag @@ -127,7 +95,7 @@ function setupEnvironment() { } /** - * Restores any value stashed in the stup process + * Restores any value stashed in the setup process */ function restoreEnvironment() { Object.keys(envToRestore).forEach((k) => { @@ -136,12 +104,67 @@ function restoreEnvironment() { } /** - * Gets the env var value also checking in the vars pending to be restored - * @param {string} name - * @returns {string | undefined} + * @typedef {Object} EdotEnv + * @property {string[]} OTEL_NODE_RESOURCE_DETECTORS + * @property {number} OTEL_METRIC_EXPORT_INTERVAL + * @property {number} OTEL_METRIC_EXPORT_TIMEOUT + * @property {boolean} ELASTIC_OTEL_METRICS_DISABLED + */ +/** + * @typedef {keyof EdotEnv} EdotEnvKey + */ +/** @type {EdotEnv} */ +const edotEnv = { + // Missing OTEL_ vars from global spec and nodejs specific spec + OTEL_NODE_RESOURCE_DETECTORS: parseStringList( + process.env.OTEL_NODE_RESOURCE_DETECTORS, + ['all'] + ), + // Note: Default values has been taken from the specs + // https://opentelemetry.io/docs/specs/otel/configuration/sdk-environment-variables/#periodic-exporting-metricreader + OTEL_METRIC_EXPORT_INTERVAL: parseNumber( + process.env.OTEL_METRIC_EXPORT_INTERVAL, + 60000 + ), + OTEL_METRIC_EXPORT_TIMEOUT: parseNumber( + process.env.OTEL_METRIC_EXPORT_TIMEOUT, + 30000 + ), + // ELASTIC_OTEL_ vars + ELASTIC_OTEL_METRICS_DISABLED: parseBoolean( + process.env.ELASTIC_OTEL_METRICS_DISABLED, + false + ), +}; + +/** + * @typedef {import('@opentelemetry/core').ENVIRONMENT} OtelEnv + */ +/** + * @typedef {keyof OtelEnv} OtelEnvKey + */ +const otelEnv = getEnv(); + +/** + * @template T + * @typedef {T extends OtelEnvKey ? OtelEnv[T] : T extends EdotEnvKey ? EdotEnv[T] : never} EnvValue + */ +/** + * @template {OtelEnvKey | EdotEnvKey} T + * Returns the value of the env var already parsed to the proper type. If + * the variable is not defined it will return the default value based on + * the environmment variables spec https://opentelemetry.io/docs/specs/otel/configuration/sdk-environment-variables/ + * @param {T} name + * @returns {EnvValue} */ function getEnvVar(name) { - return process.env[name] || envToRestore[name]; + if (name in otelEnv) { + // @ts-ignore -- T is {keyof OtelEnv} but not sure how to make TS infer that + return otelEnv[name]; + } + + // @ts-ignore -- T is {keyof EdotEnv} but not sure how to make TS infer that + return edotEnv[name]; } module.exports = { diff --git a/packages/opentelemetry-node/lib/instrumentations.js b/packages/opentelemetry-node/lib/instrumentations.js index 0ad43e4c..68b14ec2 100644 --- a/packages/opentelemetry-node/lib/instrumentations.js +++ b/packages/opentelemetry-node/lib/instrumentations.js @@ -92,6 +92,7 @@ const {UndiciInstrumentation} = require('@opentelemetry/instrumentation-undici') const {WinstonInstrumentation} = require('@opentelemetry/instrumentation-winston'); const {log} = require('./logging'); +const { getEnvVar } = require('./environment'); // Instrumentations attach their Hook (for require-in-the-middle or import-in-the-middle) // when the `enable` method is called and this happens inside their constructor @@ -236,8 +237,7 @@ function getInstrumentations(opts = {}) { } // Skip if metrics are disabled by env var - const isMetricsDisabled = - process.env.ELASTIC_OTEL_METRICS_DISABLED === 'true'; + const isMetricsDisabled = getEnvVar('ELASTIC_OTEL_METRICS_DISABLED'); if ( isMetricsDisabled && name === '@opentelemetry/instrumentation-runtime-node' diff --git a/packages/opentelemetry-node/package.json b/packages/opentelemetry-node/package.json index f4395e78..68cbc6a5 100644 --- a/packages/opentelemetry-node/package.json +++ b/packages/opentelemetry-node/package.json @@ -64,6 +64,7 @@ }, "types": "types/index.d.ts", "dependencies": { + "@opentelemetry/core": "1.27.0", "@opentelemetry/exporter-logs-otlp-grpc": "^0.54.0", "@opentelemetry/exporter-logs-otlp-http": "^0.54.0", "@opentelemetry/exporter-logs-otlp-proto": "^0.54.0", diff --git a/packages/opentelemetry-node/test/OTEL_EXPORTER_OTLP_HEADERS.test.js b/packages/opentelemetry-node/test/OTEL_EXPORTER_OTLP_HEADERS.test.js deleted file mode 100644 index abdeb9db..00000000 --- a/packages/opentelemetry-node/test/OTEL_EXPORTER_OTLP_HEADERS.test.js +++ /dev/null @@ -1,99 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you 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. - */ - -// Test that `User-Agent` is properly set into `OTEL_EXPORTER_OTLP_*_HEADERS` -// environment vars vif not defined. - -const {test} = require('tape'); -const {safeGetPackageVersion, runTestFixtures} = require('./testutils'); - -const ELASTIC_SDK_VERSION = require('../package.json').version; -const ELASTIC_UA_PREFIX = `elastic-otel-node/${ELASTIC_SDK_VERSION}`; -const OTEL_EXPORTER_VERSION = safeGetPackageVersion( - '@opentelemetry/otlp-exporter-base' -); -const OTEL_UA_EXPORTER = `OTel-OTLP-Exporter-JavaScript/${OTEL_EXPORTER_VERSION}`; -const USER_AGENT_HEADER = `User-Agent=${ELASTIC_UA_PREFIX} ${OTEL_UA_EXPORTER}`; - -/** @type {import('./testutils').TestFixture[]} */ -const testFixtures = [ - { - name: 'basic scenario without User-Agent set', - args: ['./fixtures/use-env.js'], - cwd: __dirname, - env: { - NODE_OPTIONS: '--require=@elastic/opentelemetry-node', - OTEL_EXPORTER_OTLP_TRACES_HEADERS: 't-key=t-value,key=override', - OTEL_EXPORTER_OTLP_METRICS_HEADERS: 'm-key=m-value', - OTEL_EXPORTER_OTLP_LOGS_HEADERS: 'l-key=l-value', - }, - // verbose: true, - checkResult: (t, err, stdout, stderr) => { - t.error(err); - const lines = stdout.split('\n'); - const getLine = (start) => lines.find((l) => l.startsWith(start)); - t.equal( - getLine('OTEL_EXPORTER_OTLP_TRACES_HEADERS'), - `OTEL_EXPORTER_OTLP_TRACES_HEADERS ${USER_AGENT_HEADER},t-key=t-value,key=override` - ); - t.equal( - getLine('OTEL_EXPORTER_OTLP_METRICS_HEADERS'), - `OTEL_EXPORTER_OTLP_METRICS_HEADERS ${USER_AGENT_HEADER},m-key=m-value` - ); - t.equal( - getLine('OTEL_EXPORTER_OTLP_LOGS_HEADERS'), - `OTEL_EXPORTER_OTLP_LOGS_HEADERS ${USER_AGENT_HEADER},l-key=l-value` - ); - }, - }, - { - name: 'scenario with User-Agent override', - args: ['./fixtures/use-env.js'], - cwd: __dirname, - env: { - NODE_OPTIONS: '--require=@elastic/opentelemetry-node', - OTEL_EXPORTER_OTLP_TRACES_HEADERS: 't-key=t-value,User-Agent=t-ua', - OTEL_EXPORTER_OTLP_METRICS_HEADERS: 'm-key=m-value,User-Agent=m-ua', - OTEL_EXPORTER_OTLP_LOGS_HEADERS: 'l-key=l-value,User-Agent=l-ua', - }, - // verbose: true, - checkResult: (t, err, stdout, stderr) => { - t.error(err); - const lines = stdout.split('\n'); - const getLine = (start) => lines.find((l) => l.startsWith(start)); - t.equal( - getLine('OTEL_EXPORTER_OTLP_TRACES_HEADERS'), - 'OTEL_EXPORTER_OTLP_TRACES_HEADERS User-Agent=t-ua,t-key=t-value' - ); - t.equal( - getLine('OTEL_EXPORTER_OTLP_METRICS_HEADERS'), - 'OTEL_EXPORTER_OTLP_METRICS_HEADERS User-Agent=m-ua,m-key=m-value' - ); - t.equal( - getLine('OTEL_EXPORTER_OTLP_LOGS_HEADERS'), - 'OTEL_EXPORTER_OTLP_LOGS_HEADERS User-Agent=l-ua,l-key=l-value' - ); - }, - }, -]; - -test('OTEL_EXPORTER_OTLP[*]_HEADERS', (suite) => { - runTestFixtures(suite, testFixtures); - suite.end(); -}); diff --git a/packages/opentelemetry-node/types/environment.d.ts b/packages/opentelemetry-node/types/environment.d.ts index f3630533..52a4599d 100644 --- a/packages/opentelemetry-node/types/environment.d.ts +++ b/packages/opentelemetry-node/types/environment.d.ts @@ -1,3 +1,16 @@ +/** + * + */ +export type EnvValue = T extends OtelEnvKey ? OtelEnv[T] : T extends EdotEnvKey ? EdotEnv[T] : never; +export type EdotEnv = { + OTEL_NODE_RESOURCE_DETECTORS: string[]; + OTEL_METRIC_EXPORT_INTERVAL: number; + OTEL_METRIC_EXPORT_TIMEOUT: number; + ELASTIC_OTEL_METRICS_DISABLED: boolean; +}; +export type EdotEnvKey = keyof EdotEnv; +export type OtelEnv = import('@opentelemetry/core').ENVIRONMENT; +export type OtelEnvKey = keyof OtelEnv; /** * This funtion makes necessari changes to the environment so: * - Avoid OTEL's NodeSDK known warnings (eg. OTEL_TRACES_EXPORTER not set) @@ -6,12 +19,19 @@ */ export function setupEnvironment(): void; /** - * Restores any value stashed in the stup process + * Restores any value stashed in the setup process */ export function restoreEnvironment(): void; /** - * Gets the env var value also checking in the vars pending to be restored - * @param {string} name - * @returns {string | undefined} + * @template T + * @typedef {T extends OtelEnvKey ? OtelEnv[T] : T extends EdotEnvKey ? EdotEnv[T] : never} EnvValue + */ +/** + * @template {OtelEnvKey | EdotEnvKey} T + * Returns the value of the env var already parsed to the proper type. If + * the variable is not defined it will return the default value based on + * the environmment variables spec https://opentelemetry.io/docs/specs/otel/configuration/sdk-environment-variables/ + * @param {T} name + * @returns {EnvValue} */ -export function getEnvVar(name: string): string | undefined; +export function getEnvVar(name: T): EnvValue;