diff --git a/README.md b/README.md index c2fb440c..37362166 100644 --- a/README.md +++ b/README.md @@ -4,11 +4,6 @@ -> [!WARNING] -> [OpenTelemetry SDK 2.0](https://github.com/open-telemetry/opentelemetry-js/releases/tag/v2.0.0) is not yet supported. - - - ## About This Project `@cap-js/telemetry` is a CDS plugin providing observability features, including [automatic OpenTelemetry instrumentation](https://opentelemetry.io/docs/concepts/instrumentation/automatic). diff --git a/cds-plugin.js b/cds-plugin.js index a3b091f2..1704e142 100644 --- a/cds-plugin.js +++ b/cds-plugin.js @@ -8,41 +8,5 @@ if (!!process.env.NO_TELEMETRY && process.env.NO_TELEMETRY !== 'false') return - const _version_of = module => { - let pkg - try { - pkg = require(`${module}/package.json`) - } catch { - try { - const path = require.resolve(module).split(module)[0] + module + '/package.json' - pkg = JSON.parse(require('fs').readFileSync(path, 'utf-8')) - } catch { - // ignore - } - } - if (!pkg) { - cds.log('telemetry').warn(`Unable to determine version of ${module}`) - return - } - return pkg.version - } - - // check versions of @opentelemetry dependencies - const { dependencies } = require(require('path').join(cds.root, 'package')) - let violations = [] - for (const each in dependencies) { - if (!each.match(/^@opentelemetry\//)) continue - const version = _version_of(each) - if (!version) continue - const [major, minor] = version.split('.') - if (major >= 2 || minor >= 200) violations.push(`${each}@${version}`) - } - if (violations.length) { - const msg = - '@cap-js/telemetry does not yet support OpenTelemetry SDK 2.0 (^2 and ^0.200):' + - `\n - ${violations.join('\n - ')}\n` - throw new Error(msg) - } - require('./lib')() })() diff --git a/lib/exporter/ConsoleSpanExporter.js b/lib/exporter/ConsoleSpanExporter.js index 023ae9f5..1564be74 100644 --- a/lib/exporter/ConsoleSpanExporter.js +++ b/lib/exporter/ConsoleSpanExporter.js @@ -58,7 +58,7 @@ const _span_sorter = (a, b) => { const _list2tree = (span, spans, flat, indent) => { const spanId = span.spanContext().spanId - const children = spans.filter(s => s.parentSpanId === spanId) + const children = spans.filter(s => s.parentSpanContext?.spanId === spanId) if (children.length === 0) return children.sort(_span_sorter) for (const each of children) { @@ -100,13 +100,13 @@ class ConsoleSpanExporter /* implements SpanExporter */ { _sendSpans(spans, done) { for (const span of spans) { const w3c_parent_id = cds.context?.http?.req.headers.traceparent?.split('-')[2] - if (!span.parentSpanId || span.parentSpanId === w3c_parent_id) { + if (!span.parentSpanContext?.spanId || span.parentSpanContext?.spanId === w3c_parent_id) { let toLog = 'elapsed times:' toLog += _span2line(span) const children = this._temporaryStorage.get(span.spanContext().traceId) if (children) { const ids = new Set(children.map(s => s.spanContext().spanId).filter(s => !!s)) - const reqs = children.filter(s => s.spanContext().spanId && !ids.has(s.parentSpanId)) + const reqs = children.filter(s => s.spanContext().spanId && !ids.has(s.parentSpanContext?.spanId)) const flat = [] reqs.sort(_span_sorter) for (const each of reqs) { diff --git a/lib/index.js b/lib/index.js index 1c573822..d4368f01 100644 --- a/lib/index.js +++ b/lib/index.js @@ -4,6 +4,7 @@ const LOG = cds.log('telemetry') const path = require('path') const { diag } = require('@opentelemetry/api') +const { getStringFromEnv, diagLogLevelFromString } = require('@opentelemetry/core') const { registerInstrumentations } = require('@opentelemetry/instrumentation') const tracing = require('./tracing') @@ -60,7 +61,7 @@ function _getInstrumentations() { module.exports = function () { // set logger and propagate log level - diag.setLogger(cds.log('telemetry'), process.env.OTEL_LOG_LEVEL || getDiagLogLevel()) + diag.setLogger(cds.log('telemetry'), diagLogLevelFromString(getStringFromEnv('OTEL_LOG_LEVEL')) || getDiagLogLevel()) const resource = getResource() diff --git a/lib/logging/index.js b/lib/logging/index.js index 6d77d766..6701bdd2 100644 --- a/lib/logging/index.js +++ b/lib/logging/index.js @@ -1,7 +1,7 @@ const cds = require('@sap/cds') const LOG = cds.log('telemetry') -const { getEnv, getEnvWithoutDefaults } = require('@opentelemetry/core') +const { getStringFromEnv } = require('@opentelemetry/core') const { getCredsForCLSAsUPS, augmentCLCreds, _require } = require('../utils') @@ -20,15 +20,13 @@ function _getExporter() { // for kind telemetry-to-otlp based on env vars if (loggingExporter === 'env') { - const cstm_env = getEnvWithoutDefaults() - const otlp_env = getEnv() - let protocol = cstm_env.OTEL_EXPORTER_OTLP_LOGS_PROTOCOL ?? cstm_env.OTEL_EXPORTER_OTLP_PROTOCOL + let protocol = getStringFromEnv('OTEL_EXPORTER_OTLP_LOGS_PROTOCOL') ?? getStringFromEnv('OTEL_EXPORTER_OTLP_PROTOCOL') // on kyma, the otlp endpoint speaks grpc, but otel's default protocol is http/protobuf -> fix default if (!protocol) { - const endpoint = otlp_env.OTEL_EXPORTER_OTLP_LOGS_ENDPOINT ?? otlp_env.OTEL_EXPORTER_OTLP_ENDPOINT ?? '' + const endpoint = (getStringFromEnv('OTEL_EXPORTER_OTLP_LOGS_ENDPOINT') ?? getStringFromEnv('OTEL_EXPORTER_OTLP_ENDPOINT') ?? '') if (endpoint.match(/:4317/)) protocol = 'grpc' } - protocol ??= otlp_env.OTEL_EXPORTER_OTLP_LOGS_PROTOCOL ?? otlp_env.OTEL_EXPORTER_OTLP_PROTOCOL + protocol ??= (getStringFromEnv('OTEL_EXPORTER_OTLP_LOGS_PROTOCOL') ?? getStringFromEnv('OTEL_EXPORTER_OTLP_PROTOCOL')) loggingExporter = { module: _protocol2module[protocol], class: 'OTLPLogExporter' } } diff --git a/lib/metrics/index.js b/lib/metrics/index.js index 28c001f8..0f93ca4a 100644 --- a/lib/metrics/index.js +++ b/lib/metrics/index.js @@ -2,20 +2,19 @@ const cds = require('@sap/cds') const LOG = cds.log('telemetry') const { metrics } = require('@opentelemetry/api') -const { getEnv, getEnvWithoutDefaults } = require('@opentelemetry/core') -const { Resource } = require('@opentelemetry/resources') +const { getStringFromEnv } = require('@opentelemetry/core') +const { resourceFromAttributes } = require('@opentelemetry/resources') const { AggregationTemporality, - DropAggregation, + AggregationType, MeterProvider, - PeriodicExportingMetricReader, - View + PeriodicExportingMetricReader } = require('@opentelemetry/sdk-metrics') const { getDynatraceMetadata, getCredsForDTAsUPS, getCredsForCLSAsUPS, augmentCLCreds, _require } = require('../utils') const _protocol2module = { - grpc: '@opentelemetry/exporter-metrics-otlp-grpc', + 'grpc': '@opentelemetry/exporter-metrics-otlp-grpc', 'http/protobuf': '@opentelemetry/exporter-metrics-otlp-proto', 'http/json': '@opentelemetry/exporter-metrics-otlp-http' } @@ -27,55 +26,59 @@ function _getExporter() { credentials } = cds.env.requires.telemetry - // for kind telemetry-to-otlp based on env vars - if (metricsExporter === 'env') { - const cstm_env = getEnvWithoutDefaults() - const otlp_env = getEnv() - let protocol = cstm_env.OTEL_EXPORTER_OTLP_METRICS_PROTOCOL ?? cstm_env.OTEL_EXPORTER_OTLP_PROTOCOL - // on kyma, the otlp endpoint speaks grpc, but otel's default protocol is http/protobuf -> fix default + if (metricsExporter === 'env') { // ... process env to determine exporter module to use + let protocol = getStringFromEnv('OTEL_EXPORTER_OTLP_METRICS_PROTOCOL') ?? getStringFromEnv('OTEL_EXPORTER_OTLP_PROTOCOL') + if (!protocol) { - const endpoint = otlp_env.OTEL_EXPORTER_OTLP_METRICS_ENDPOINT ?? otlp_env.OTEL_EXPORTER_OTLP_ENDPOINT ?? '' + // > On kyma, the otlp endpoint speaks grpc, but otel's default protocol is http/protobuf -> fix default + const endpoint = (getStringFromEnv('OTEL_EXPORTER_OTLP_METRICS_ENDPOINT') ?? getStringFromEnv('OTEL_EXPORTER_OTLP_ENDPOINT') ?? '') if (endpoint.match(/:4317/)) protocol = 'grpc' } - protocol ??= otlp_env.OTEL_EXPORTER_OTLP_METRICS_PROTOCOL ?? otlp_env.OTEL_EXPORTER_OTLP_PROTOCOL + + protocol ??= (getStringFromEnv('OTEL_EXPORTER_OTLP_METRICS_PROTOCOL') ?? getStringFromEnv('OTEL_EXPORTER_OTLP_PROTOCOL')) metricsExporter = { module: _protocol2module[protocol], class: 'OTLPMetricExporter' } } - // use _require for better error message - const metricsExporterModule = - metricsExporter.module === '@cap-js/telemetry' ? require('../exporter') : _require(metricsExporter.module) + // Import the configured exporter module > use _require for better error message + const metricsExporterModule = metricsExporter.module === '@cap-js/telemetry' + ? require('../exporter') + : _require(metricsExporter.module) if (!metricsExporterModule[metricsExporter.class]) throw new Error(`Unknown metrics exporter "${metricsExporter.class}" in module "${metricsExporter.module}"`) + const config = { ...(metricsExporter.config || {}) } + config.temporalityPreference ??= AggregationTemporality.DELTA + // Augment configruation depending on 'kind' of telementry if (kind.match(/to-dynatrace$/)) { if (!credentials) credentials = getCredsForDTAsUPS() if (!credentials) throw new Error('No Dynatrace credentials found.') + config.url ??= `${credentials.apiurl}/v2/otlp/v1/metrics` config.headers ??= {} - // credentials.rest_apitoken?.token is deprecated and only supported for compatibility reasons + + // Extract REST API token from credentials to configure auth: + // > 'metrics_apitoken' for compatibility with previous releases + // > 'credentials.rest_apitoken?.token' is deprecated and only supported for compatibility reasons const { token_name } = cds.env.requires.telemetry - // metrics_apitoken for compatibility with previous releases const token = credentials[token_name] || credentials.metrics_apitoken || credentials.rest_apitoken?.token - if (!token) - throw new Error(`Neither "${token_name}" nor deprecated "rest_apitoken.token" found in Dynatrace credentials`) + if (!token) throw new Error(`Neither "${token_name}" nor deprecated "rest_apitoken.token" found in Dynatrace credentials`) + config.headers.authorization ??= `Api-Token ${token}` } if (kind.match(/to-cloud-logging$/)) { if (!credentials) credentials = getCredsForCLSAsUPS() if (!credentials) throw new Error('No SAP Cloud Logging credentials found.') + augmentCLCreds(credentials) + config.url ??= credentials.url config.credentials ??= credentials.credentials } - // default to DELTA - config.temporalityPreference ??= AggregationTemporality.DELTA - const exporter = new metricsExporterModule[metricsExporter.class](config) LOG._debug && LOG.debug('Using metrics exporter:', exporter) - return exporter } @@ -85,40 +88,40 @@ module.exports = resource => { /* * general setup */ + const metricsConfig = cds.env.requires.telemetry.metrics.config + let exporter = _getExporter() + + if (typeof exporter.export === 'function') { + // In case export is a function to be called by this runtime (push): + // > The exporter needs to be wrappeed thus, to set an export interval + exporter = new PeriodicExportingMetricReader({ ...metricsConfig, exporter }) + } + let meterProvider = metrics.getMeterProvider() if (meterProvider.constructor.name === 'NoopMeterProvider') { const dtmetadata = getDynatraceMetadata() - resource = new Resource({}).merge(resource).merge(dtmetadata) + resource = resourceFromAttributes({}).merge(resource).merge(dtmetadata) // unfortunately, we have to pass views to the MeterProvider constructor // something like meterProvider.addView() would be a lot nicer for locality let views = [] if (process.env.HOST_METRICS_RETAIN_SYSTEM) { // nothing to do } else { - views.push( - new View({ - meterName: '@cap-js/telemetry:host-metrics', - instrumentName: 'system.*', - aggregation: new DropAggregation() - }) - ) + views.push({ + meterName: '@cap-js/telemetry:host-metrics', + instrumentName: 'system.*', + aggregation: { + type: AggregationType.DROP + } + }) } - meterProvider = new MeterProvider({ resource, views }) + meterProvider = new MeterProvider({ resource, readers: [exporter], views }) metrics.setGlobalMeterProvider(meterProvider) } else { + // TODO: CALM LOG._warn && LOG.warn('MeterProvider already initialized by a different module. It will be used as is.') } - const metricsConfig = cds.env.requires.telemetry.metrics.config - const exporter = _getExporter() - // push vs. pull - if (typeof exporter.export === 'function') { - const metricReader = new PeriodicExportingMetricReader({ ...metricsConfig, exporter }) - meterProvider.addMetricReader(metricReader) - } else { - meterProvider.addMetricReader(exporter) - } - /* * add individual metrics */ diff --git a/lib/tracing/index.js b/lib/tracing/index.js index 7d735ddf..d9e5fdaa 100644 --- a/lib/tracing/index.js +++ b/lib/tracing/index.js @@ -2,8 +2,8 @@ const cds = require('@sap/cds') const LOG = cds.log('telemetry') const { trace } = require('@opentelemetry/api') -const { getEnv, getEnvWithoutDefaults } = require('@opentelemetry/core') -const { Resource } = require('@opentelemetry/resources') +const { getStringFromEnv } = require('@opentelemetry/core') +const { resourceFromAttributes } = require('@opentelemetry/resources') const { BatchSpanProcessor, SimpleSpanProcessor, SamplingDecision } = require('@opentelemetry/sdk-trace-base') const { NodeTracerProvider } = require('@opentelemetry/sdk-trace-node') @@ -85,15 +85,13 @@ function _getExporter() { // for kind telemetry-to-otlp based on env vars if (tracingExporter === 'env') { - const cstm_env = getEnvWithoutDefaults() - const otlp_env = getEnv() - let protocol = cstm_env.OTEL_EXPORTER_OTLP_TRACES_PROTOCOL ?? cstm_env.OTEL_EXPORTER_OTLP_PROTOCOL + let protocol = getStringFromEnv('OTEL_EXPORTER_OTLP_TRACES_PROTOCOL') ?? getStringFromEnv('OTEL_EXPORTER_OTLP_PROTOCOL') // on kyma, the otlp endpoint speaks grpc, but otel's default protocol is http/protobuf -> fix default if (!protocol) { - const endpoint = otlp_env.OTEL_EXPORTER_OTLP_TRACES_ENDPOINT ?? otlp_env.OTEL_EXPORTER_OTLP_ENDPOINT ?? '' + const endpoint = (getStringFromEnv('OTEL_EXPORTER_OTLP_TRACES_ENDPOINT') ?? getStringFromEnv('OTEL_EXPORTER_OTLP_ENDPOINT') ?? '') if (endpoint.match(/:4317/)) protocol = 'grpc' } - protocol ??= otlp_env.OTEL_EXPORTER_OTLP_TRACES_PROTOCOL ?? otlp_env.OTEL_EXPORTER_OTLP_PROTOCOL + protocol ??= (getStringFromEnv('OTEL_EXPORTER_OTLP_TRACES_PROTOCOL') ?? getStringFromEnv('OTEL_EXPORTER_OTLP_PROTOCOL')) tracingExporter = { module: _protocol2module[protocol], class: 'OTLPTraceExporter' } } @@ -137,16 +135,7 @@ module.exports = resource => { /* * general setup */ - let tracerProvider = trace.getTracerProvider() - if (!tracerProvider.getDelegateTracer()) { - const dtmetadata = getDynatraceMetadata() - resource = new Resource({}).merge(resource).merge(dtmetadata) - tracerProvider = new NodeTracerProvider({ resource, sampler: _getSampler() }) - tracerProvider.register({ propagator: _getPropagator() }) - } else { - LOG._warn && LOG.warn('TracerProvider already initialized by a different module. It will be used as is.') - tracerProvider = tracerProvider.getDelegate() - } + let processor const via_one_agent = process.env.DT_NODE_PRELOAD_OPTIONS && cds.env.requires.telemetry.kind.match(/to-dynatrace$/) && @@ -157,11 +146,22 @@ module.exports = resource => { } else { const exporter = _getExporter() const processorConfig = cds.env.requires.telemetry.tracing.processor?.config || {} - const processor = + processor = process.env.NODE_ENV === 'production' ? new BatchSpanProcessor(exporter, processorConfig) : new SimpleSpanProcessor(exporter, processorConfig) - tracerProvider.addSpanProcessor(processor) + } + + let tracerProvider = trace.getTracerProvider() + if (!tracerProvider.getDelegateTracer()) { + const dtmetadata = getDynatraceMetadata() + resource = resourceFromAttributes({}).merge(resource).merge(dtmetadata) + tracerProvider = new NodeTracerProvider({ resource, spanProcessors: [processor], sampler: _getSampler() }) + tracerProvider.register({ propagator: _getPropagator() }) + } else { + // TODO: CALM + LOG._warn && LOG.warn('TracerProvider already initialized by a different module. It will be used as is.') + tracerProvider = tracerProvider.getDelegate() } // clear sap passport for new tx diff --git a/lib/tracing/trace.js b/lib/tracing/trace.js index 9cc3b120..f7914446 100644 --- a/lib/tracing/trace.js +++ b/lib/tracing/trace.js @@ -278,7 +278,7 @@ function trace(req, fn, that, args, opts = {}) { if (!root && parent?.isRecording() === false) return fn.apply(that, args) // augment root span with request attributes, overwrite start time, and adjust root name - if (parent?.instrumentationLibrary?.name === '@opentelemetry/instrumentation-http' && !parent[$adjusted]) { + if (parent?.instrumentationScope?.name === '@opentelemetry/instrumentation-http' && !parent[$adjusted]) { parent[$adjusted] = true _setAttributes(parent, _getRequestAttributes()) const ctx = cds.context diff --git a/lib/utils.js b/lib/utils.js index 0c6f983f..cf9cbb80 100644 --- a/lib/utils.js +++ b/lib/utils.js @@ -4,8 +4,8 @@ const LOG = cds.log('telemetry') const fs = require('fs') const { DiagLogLevel } = require('@opentelemetry/api') -const { hrTimeToMilliseconds } = require('@opentelemetry/core') -const { Resource } = require('@opentelemetry/resources') +const { hrTimeToMilliseconds, getStringFromEnv } = require('@opentelemetry/core') +const { resourceFromAttributes } = require('@opentelemetry/resources') const { ATTR_SERVICE_NAME, ATTR_SERVICE_VERSION, @@ -38,11 +38,11 @@ function getResource() { const attributes = {} // Service - attributes[ATTR_SERVICE_NAME] = process.env.OTEL_SERVICE_NAME || name - attributes[ATTR_SERVICE_VERSION] = process.env.OTEL_SERVICE_VERSION || version + attributes[ATTR_SERVICE_NAME] = getStringFromEnv('OTEL_SERVICE_NAME') || name + attributes[ATTR_SERVICE_VERSION] = getStringFromEnv('OTEL_SERVICE_VERSION') || version // Service (Experimental) - if (process.env.OTEL_SERVICE_NAMESPACE) attributes[ATTR_SERVICE_NAMESPACE] = process.env.OTEL_SERVICE_NAMESPACE + if (getStringFromEnv('OTEL_SERVICE_NAMESPACE')) attributes[ATTR_SERVICE_NAMESPACE] = getStringFromEnv('OTEL_SERVICE_NAMESPACE') if (VCAP_APPLICATION) attributes[ATTR_SERVICE_INSTANCE_ID] = VCAP_APPLICATION.instance_id if (process.env.CF_INSTANCE_GUID) { @@ -64,14 +64,14 @@ function getResource() { attributes['sap.cf.process.type'] = VCAP_APPLICATION.process_type } - return new Resource(attributes) + return resourceFromAttributes(attributes) } let dtmetadata function getDynatraceMetadata() { if (dtmetadata) return dtmetadata - dtmetadata = new Resource({}) + dtmetadata = resourceFromAttributes({}) for (let name of [ 'dt_metadata_e617c525669e072eebe3d0f08212e8f2.json', '/var/lib/dynatrace/enrichment/dt_metadata.json' @@ -82,7 +82,7 @@ function getDynatraceMetadata() { .readFileSync(name.startsWith('/var') ? name : fs.readFileSync(name).toString('utf-8').trim()) .toString('utf-8') LOG._debug && LOG.debug('Successful') - dtmetadata = dtmetadata.merge(new Resource(JSON.parse(content))) + dtmetadata = dtmetadata.merge(resourceFromAttributes(JSON.parse(content))) break } catch (err) { LOG._debug && LOG.debug('Failed with error:', err) diff --git a/package.json b/package.json index 2c795049..b62af35f 100644 --- a/package.json +++ b/package.json @@ -19,13 +19,13 @@ }, "dependencies": { "@opentelemetry/api": "^1.9", - "@opentelemetry/core": "^1.30", - "@opentelemetry/instrumentation": "^0.57", - "@opentelemetry/instrumentation-http": "^0.57", - "@opentelemetry/resources": "^1.30", - "@opentelemetry/sdk-metrics": "^1.30", - "@opentelemetry/sdk-trace-base": "^1.30", - "@opentelemetry/sdk-trace-node": "^1.30", + "@opentelemetry/core": "^2", + "@opentelemetry/instrumentation": "^0.200", + "@opentelemetry/instrumentation-http": "^0.200", + "@opentelemetry/resources": "^2", + "@opentelemetry/sdk-metrics": "^2", + "@opentelemetry/sdk-trace-base": "^2", + "@opentelemetry/sdk-trace-node": "^2", "@opentelemetry/semantic-conventions": "^1.36" }, "peerDependencies": { @@ -37,10 +37,10 @@ "@cap-js/telemetry": "file:.", "@dynatrace/oneagent-sdk": "^1.5.0", "@grpc/grpc-js": "^1.9.14", - "@opentelemetry/exporter-metrics-otlp-grpc": "^0.57.0", - "@opentelemetry/exporter-metrics-otlp-proto": "^0.57.0", - "@opentelemetry/exporter-trace-otlp-grpc": "^0.57.0", - "@opentelemetry/exporter-trace-otlp-proto": "^0.57.0", + "@opentelemetry/exporter-metrics-otlp-grpc": "^0.200", + "@opentelemetry/exporter-metrics-otlp-proto": "^0.200", + "@opentelemetry/exporter-trace-otlp-grpc": "^0.200", + "@opentelemetry/exporter-trace-otlp-proto": "^0.200", "@opentelemetry/host-metrics": "^0.36.0", "@opentelemetry/instrumentation-runtime-node": "^0.17.0", "@sap/cds-mtxs": ">=2",