diff --git a/CHANGELOG.md b/CHANGELOG.md index 7717825..535c511 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,68 @@ All notable changes to this project will be documented in this file. This project adheres to [Semantic Versioning](http://semver.org/). The format is based on [Keep a Changelog](http://keepachangelog.com/). +## Version 1.1.2 - 2024-12-10 + +### Fixed + +- ConsoleSpanExporter: `cds.context` may be undefined in local scripting scenarios + +## Version 1.1.1 - 2024-11-28 + +### Fixed + +- Use attribute `url.path` (with fallback to deprecated `http.target`) for sampling decision + +## Version 1.1.0 - 2024-11-27 + +### Added + +- Predefined kind `telemetry-to-otlp` that creates exporters based on OTLP exporter configuration via environment variables +- Experimental!: Propagate W3C trace context to SAP HANA via session context `SAP_PASSPORT` + - Enable via environment variable `SAP_PASSPORT` +- If `@opentelemetry/instrumentation-runtime-node` is in the project's dependencies but not in `cds.env.requires.telemetry.instrumentations`, it is registered automatically + - Disable via `cds.env.requires.telemetry.instrumentations.instrumentation-runtime-node = false` + +### Changed + +- Base config moved to new `cds.requires.kinds.telemetry` for improved config merging + +### Fixed + +- Built-in `ConsoleMetricExporter` uses correct attribute name `process.cpu.state` while exporting host metrics +- Exporting traces to the console in the presence of a traceparent header + +## Version 1.0.1 - 2024-08-10 + +### Fixed + +- Explicitly pass own providers when registering instrumentations (the global providers may be influenced by, for example, Dynatrace OneAgent) + +## Version 1.0.0 - 2024-08-08 + +### Added + +- Support for tracing native db statements (i.e., `cds.run('SELECT * FROM DUMMY')`) +- Support for SAP Cloud Logging credentials via user-provided service +- Support for adding `@opentelemetry/instrumentation-runtime-node` + - `npm add @opentelemetry/instrumentation-runtime-node` + - To `cds.requires.telemetry.instrumentations`, add: + ```json + "instrumentation-runtime-node": { + "class": "RuntimeNodeInstrumentation", + "module": "@opentelemetry/instrumentation-runtime-node" + } + ``` + +### Changed + +- Instrumentations are registered after tracing and metrics are set up +- `telemetry-to-dynatrace`: Regardless of whether Dynatrace OneAgent is present or not, if dependency `@opentelemetry/exporter-trace-otlp-proto` is present, `@cap-js/telemetry` will export the traces via OpenTelemetry. + +### Fixed + +- Tracing of db statements without active span + ## Version 0.2.3 - 2024-06-17 ### Fixed @@ -40,8 +102,8 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/). ### Added - Support for own, high resolution timestamps - + Enable via `cds.env.requires.telemetry.tracing.hrtime = true` - + Enabled by default in development profile + - Enable via `cds.env.requires.telemetry.tracing.hrtime = true` + - Enabled by default in development profile ## Version 0.0.5 - 2024-03-11 @@ -54,10 +116,10 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/). ### Changed - By default, all `system.*` metrics collected by `@opentelemetry/host-metrics` are ignored - + Disable change via environment variable `HOST_METRICS_RETAIN_SYSTEM=true` + - Disable change via environment variable `HOST_METRICS_RETAIN_SYSTEM=true` - Metric exporter's property `temporalityPreference` always gets defaulted to `DELTA` - + Was previously only done for kind `telemetry-to-dynatrace` - + Set custom value via `cds.env.requires.telemetry.metrics.exporter.config.temporalityPreference` + - Was previously only done for kind `telemetry-to-dynatrace` + - Set custom value via `cds.env.requires.telemetry.metrics.exporter.config.temporalityPreference` ### Fixed diff --git a/README.md b/README.md index 3236c7b..7dca090 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,7 @@ Documentation can be found at [cap.cloud.sap](https://cap.cloud.sap/docs) and [o - [`telemetry-to-dynatrace`](#telemetry-to-dynatrace) - [`telemetry-to-cloud-logging`](#telemetry-to-cloud-logging) - [`telemetry-to-jaeger`](#telemetry-to-jaeger) + - [`telemetry-to-otlp`](#telemetry-to-otlp) - [Detailed Configuration Options](#detailed-configuration-options) - [Configuration Pass Through](#configuration-pass-through) - [Instrumentations](#instrumentations) @@ -67,7 +68,7 @@ See [Predefined Kinds](#predefined-kinds) for additional dependencies you need t The plugin can be disabled by setting environment variable `NO_TELEMETRY` to something truthy. -Database tracing is currently limited to [@cap-js/sqlite](https://www.npmjs.com/package/@cap-js/sqlite) and [@cap-js/hana](https://www.npmjs.com/package/@cap-js/hana). +Database tracing is limited to [@cap-js/cds-dbs](https://github.com/cap-js/cds-dbs)-based databases, such as [@cap-js/sqlite](https://www.npmjs.com/package/@cap-js/sqlite) and [@cap-js/hana](https://www.npmjs.com/package/@cap-js/hana). @@ -169,7 +170,7 @@ Hence, a Dynatrace instance is required and the app must be bound to that Dynatr Use via `cds.requires.telemetry.kind = 'to-dynatrace'`. Required additional dependencies: -- `@opentelemetry/exporter-trace-otlp-proto` +- `@opentelemetry/exporter-trace-otlp-proto` (optional, see [Leveraging Dynatrace OneAgent](#leveraging-dynatrace-oneagent)) - `@opentelemetry/exporter-metrics-otlp-proto` The necessary scopes for exporting traces (`openTelemetryTrace.ingest`) and metrics (`metrics.ingest`) are not part of the standard `apitoken` and must be requested. @@ -204,6 +205,7 @@ If [Dynatrace OneAgent](https://www.dynatrace.com/platform/oneagent) is present, (Your app still needs to be bound to a Dynatrace instance, of course. However, `@dynatrace/oneagent-sdk` is not required.) Hence, additionally dependency `@opentelemetry/exporter-trace-otlp-proto` and scope `openTelemetryTrace.ingest` are not required. This is actually the perferred operating model for `telemetry-to-dynatrace` as it provides a better experience than exporting via OpenTelemetry. +If dependency `@opentelemetry/exporter-trace-otlp-proto` is present anyway, `@cap-js/telemetry` will export the traces via OpenTelemetry as well. ### `telemetry-to-cloud-logging` @@ -261,6 +263,19 @@ Run Jaeger locally via [docker](https://www.docker.com): - With this, no custom credentials are needed - Open `localhost:16686` to see the traces +### `telemetry-to-otlp` + +Exports traces and metrics to an OTLP/gRPC or OTLP/HTTP endpoint based on [environment variables](https://opentelemetry.io/docs/languages/sdk-configuration/otlp-exporter). + +Use via `cds.requires.telemetry.kind = 'to-otlp'`. + +Required additional dependencies (`* = grpc|proto|http`): +- `@grpc/grpc-js` (in case of OTLP/gRPC) +- `@opentelemetry/exporter-trace-otlp-*` +- `@opentelemetry/exporter-metrics-otlp-*` + +Please note that `@cap-js/telemetry` does not validate the configuration via environment variables! + ## Detailed Configuration Options @@ -437,6 +452,7 @@ Hence, the `hrtime` mode is on by default in development but not in production. - `NO_TELEMETRY`: Disables the plugin - `NO_LOCATE`: Disables function location in tracing +- `SAP_PASSPORT`: Enables propagating W3C trace context to SAP HANA (experimental!) - `OTEL_LOG_LEVEL`: If not specified, the log level of cds logger `telemetry` is used - `OTEL_SERVICE_NAME`: If not specified, the name is determined from package.json (defaulting to "CAP Application") - `OTEL_SERVICE_VERSION`: If not specified, the version is determined from package.json (defaulting to "1.0.0") @@ -455,8 +471,7 @@ This project is open to feature requests/suggestions, bug reports etc. via [GitH ## Code of Conduct -We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone. By participating in this project, you agree to abide by its [Code of Conduct](CODE_OF_CONDUCT.md) at all times. - +We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone. By participating in this project, you agree to abide by its [Code of Conduct](https://github.com/cap-js/.github/blob/main/CODE_OF_CONDUCT.md) at all times. ## Licensing diff --git a/lib/exporter/ConsoleMetricExporter.js b/lib/exporter/ConsoleMetricExporter.js index c664232..8113808 100644 --- a/lib/exporter/ConsoleMetricExporter.js +++ b/lib/exporter/ConsoleMetricExporter.js @@ -42,7 +42,7 @@ class ConsoleMetricExporter extends StandardConsoleMetricExporter { } if (name === 'process.cpu.time' || name === 'process.cpu.utilization') { collector[name] ??= {} - for (const dp of metric.dataPoints) collector[name][dp.attributes.state] = dp.value + for (const dp of metric.dataPoints) collector[name][dp.attributes['process.cpu.state']] = dp.value } if (metric.descriptor.name === 'process.memory.usage') { collector[name] = metric.dataPoints[0].value diff --git a/lib/exporter/ConsoleSpanExporter.js b/lib/exporter/ConsoleSpanExporter.js index 3e23745..443d060 100644 --- a/lib/exporter/ConsoleSpanExporter.js +++ b/lib/exporter/ConsoleSpanExporter.js @@ -4,6 +4,14 @@ const LOG = cds.log('telemetry') const path = require('path') const { ExportResultCode, hrTimeToMilliseconds } = require('@opentelemetry/core') +const { + ATTR_URL_PATH, + SEMATTRS_CODE_FILEPATH: ATTR_CODE_FILEPATH, + SEMATTRS_CODE_LINENO: ATTR_CODE_LINENO + // ATTR_CODE_COLUMN +} = require('@opentelemetry/semantic-conventions') +// REVISIT: ATTR_CODE_COLUMN doesn't yet exist in semantic conventions 1.27 +const ATTR_CODE_COLUMN = 'code.column' const _padded = v => `${`${v}`.split('.')[0].padStart(3, ' ')}.${(`${v}`.split('.')[1] || '0').padEnd(2, '0').substring(0, 2)}` @@ -18,25 +26,25 @@ const _span2line = (span, parentStartTime = 0) => { let result = `\n ${_padded(start)} → ${_padded(end)} = ${_padded(duration)} ms` let name = span.name - if (name.match(/^[A-Z]+$/)) name = name + ' ' + span.attributes['http.target'] + if (name.match(/^[A-Z]+$/)) name = name + ' ' + span.attributes[ATTR_URL_PATH] if (name.length > 80) name = name.substring(0, 79) + '…' result += ' ' + (span.___indent || '') + name // REVISIT: what is this for? - if (span.attributes['code.filepath'] !== undefined) { + if (span.attributes[ATTR_CODE_FILEPATH] !== undefined) { if ( path - .normalize(span.attributes['code.filepath']) + .normalize(span.attributes[ATTR_CODE_FILEPATH]) .match(new RegExp(path.normalize(cds.env._home).replaceAll('\\', '\\\\'), 'g')) && - !path.normalize(span.attributes['code.filepath']).match(/node_modules/g) + !path.normalize(span.attributes[ATTR_CODE_FILEPATH]).match(/node_modules/g) ) { result += `: .${path - .normalize(span.attributes['code.filepath']) + .normalize(span.attributes[ATTR_CODE_FILEPATH]) .substring( path.normalize(cds.env._home).length + 1, - path.normalize(span.attributes['code.filepath']).length - )}:${span.attributes['code.lineno']}:${span.attributes['code.column']}` + path.normalize(span.attributes[ATTR_CODE_FILEPATH]).length + )}:${span.attributes[ATTR_CODE_LINENO]}:${span.attributes[ATTR_CODE_COLUMN]}` } } @@ -92,7 +100,8 @@ class ConsoleSpanExporter /* implements SpanExporter */ { */ _sendSpans(spans, done) { for (const span of spans) { - if (!span.parentSpanId || span.name === 'cds.spawn') { + const w3c_parent_id = cds.context?.http?.req.headers.traceparent?.split('-')[2] + if (!span.parentSpanId || span.parentSpanId === w3c_parent_id || span.name === 'cds.spawn') { let toLog = 'elapsed times:' toLog += _span2line(span) const children = this._temporaryStorage.get(span.spanContext().traceId) diff --git a/lib/index.js b/lib/index.js index 53c99d3..d9d37f7 100644 --- a/lib/index.js +++ b/lib/index.js @@ -1,10 +1,45 @@ const cds = require('@sap/cds') +const LOG = cds.log('telemetry') const { diag } = require('@opentelemetry/api') +const { registerInstrumentations } = require('@opentelemetry/instrumentation') +const { ATTR_SERVICE_NAME, ATTR_SERVICE_VERSION } = require('@opentelemetry/semantic-conventions') const tracing = require('./tracing') const metrics = require('./metrics') -const { getDiagLogLevel, getResource } = require('./utils') +const { getDiagLogLevel, getResource, _require } = require('./utils') + +function _getInstrumentations() { + const _instrumentations = cds.env.requires.telemetry.instrumentations + + // if @opentelemetry/instrumentation-runtime-node is in project's dependencies but not in cds.env.requires.telemetry.instrumentations, add it automatically + if ( + !Object.keys(_instrumentations).includes('instrumentation-runtime-node') && + !Object.values(_instrumentations).find(i => i?.module === '@opentelemetry/instrumentation-runtime-node') + ) { + try { + const pkg = require(require('path').join(cds.root, 'package')) + if (Object.keys(pkg.dependencies).includes('@opentelemetry/instrumentation-runtime-node')) { + _instrumentations['instrumentation-runtime-node'] = { + class: 'RuntimeNodeInstrumentation', + module: '@opentelemetry/instrumentation-runtime-node' + } + } + } catch (e) { + LOG._debug && LOG.debug('Failed to automatically add @opentelemetry/instrumentation-runtime-node:', e) + } + } + + const instrumentations = [] + for (const each of Object.values(_instrumentations)) { + if (!each) continue //> could be falsy + const module = _require(each.module) + if (!module[each.class]) throw new Error(`Unknown instrumentation "${each.class}" in module "${each.module}"`) + instrumentations.push(new module[each.class]({ ...(each.config || {}) })) + } + + return instrumentations +} module.exports = function () { // set logger and propagate log level @@ -14,17 +49,27 @@ module.exports = function () { // REVISIT: better way to make available? cds._telemetry = { - name: resource.attributes['service.name'], - version: resource.attributes['service.version'] + name: resource.attributes[ATTR_SERVICE_NAME], + version: resource.attributes[ATTR_SERVICE_VERSION] } /* - * add tracing + * setup tracing + */ + const tracerProvider = tracing(resource) + + /* + * setup metrics */ - tracing(resource) + const meterProvider = metrics(resource) /* - * add metrics + * register instrumentations */ - metrics(resource) + registerInstrumentations({ + tracerProvider, + meterProvider, + // loggerProvider, + instrumentations: _getInstrumentations() + }) } diff --git a/lib/metrics/db-pool.js b/lib/metrics/db-pool.js index c4c635a..55b7061 100644 --- a/lib/metrics/db-pool.js +++ b/lib/metrics/db-pool.js @@ -12,10 +12,6 @@ function init(pools) { pools.delete(tenant) } - // // REVISIT: is "-meter" appendix the standard approach? - // const meter = metrics.getMeter(`${cds._telemetry.name}-meter`) - - // REVISIT: what shall this name be? const meter = metrics.getMeter('@cap-js/telemetry:db-pool') const borrowed = meter.createObservableGauge('db.pool.borrowed', { diff --git a/lib/metrics/index.js b/lib/metrics/index.js index 156d2ec..ea1a943 100644 --- a/lib/metrics/index.js +++ b/lib/metrics/index.js @@ -2,6 +2,7 @@ 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 { AggregationTemporality, @@ -11,7 +12,13 @@ const { View } = require('@opentelemetry/sdk-metrics') -const { getDynatraceMetadata, getCredsForDTAsUPS, augmentCLCreds, _require } = require('../utils') +const { getDynatraceMetadata, getCredsForDTAsUPS, getCredsForCLSAsUPS, augmentCLCreds, _require } = require('../utils') + +const _protocol2module = { + grpc: '@opentelemetry/exporter-metrics-otlp-grpc', + 'http/protobuf': '@opentelemetry/exporter-metrics-otlp-proto', + 'http/json': '@opentelemetry/exporter-metrics-otlp-http' +} function _getExporter() { let { @@ -20,6 +27,18 @@ function _getExporter() { credentials } = cds.env.requires.telemetry + // for kind telemetry-to-otlp based on env vars + if (metricsExporter === 'env') { + const otlp_env = getEnvWithoutDefaults() + const dflt_env = getEnv() + const protocol = + otlp_env.OTEL_EXPORTER_OTLP_METRICS_PROTOCOL ?? + otlp_env.OTEL_EXPORTER_OTLP_PROTOCOL ?? + dflt_env.OTEL_EXPORTER_OTLP_METRICS_PROTOCOL ?? + dflt_env.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) @@ -27,7 +46,7 @@ function _getExporter() { throw new Error(`Unknown metrics exporter "${metricsExporter.class}" in module "${metricsExporter.module}"`) const metricsConfig = { ...(metricsExporter.config || {}) } - if (kind.match(/dynatrace/)) { + if (kind.match(/to-dynatrace$/)) { if (!credentials) credentials = getCredsForDTAsUPS() if (!credentials) throw new Error('No Dynatrace credentials found.') metricsConfig.url ??= `${credentials.apiurl}/v2/otlp/v1/metrics` @@ -41,7 +60,8 @@ function _getExporter() { metricsConfig.headers.authorization ??= `Api-Token ${token}` } - if (kind.match(/cloud-logging/)) { + if (kind.match(/to-cloud-logging$/)) { + if (!credentials) credentials = getCredsForCLSAsUPS() if (!credentials) throw new Error('No SAP Cloud Logging credentials found.') augmentCLCreds(credentials) metricsConfig.url ??= credentials.url @@ -101,4 +121,6 @@ module.exports = resource => { */ require('./db-pool')() require('./host')() + + return meterProvider } diff --git a/lib/tracing/index.js b/lib/tracing/index.js index 2567f2b..053d189 100644 --- a/lib/tracing/index.js +++ b/lib/tracing/index.js @@ -2,12 +2,19 @@ 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 { registerInstrumentations } = require('@opentelemetry/instrumentation') const { BatchSpanProcessor, SimpleSpanProcessor, SamplingDecision } = require('@opentelemetry/sdk-trace-base') const { NodeTracerProvider } = require('@opentelemetry/sdk-trace-node') -const { getDynatraceMetadata, getCredsForDTAsUPS, augmentCLCreds, _require } = require('../utils') +const { + getDynatraceMetadata, + getCredsForDTAsUPS, + getCredsForCLSAsUPS, + augmentCLCreds, + hasDependency, + _require +} = require('../utils') function _getSampler() { const { ignoreIncomingPaths } = cds.env.requires.telemetry.instrumentations?.http?.config || {} @@ -17,9 +24,9 @@ function _getSampler() { else { // eslint-disable-next-line no-unused-vars _shouldSample = (_context, _traceId, _name, _spanKind, attributes, _links) => { - const originalUrl = attributes?.['http.originalUrl'] - if (!originalUrl) return true - return !ignoreIncomingPaths.some(p => originalUrl.startsWith(p)) + const url_path = attributes?.['url.path'] || attributes?.['http.target'] //> http.target is deprecated + if (!url_path) return true + return !ignoreIncomingPaths.some(p => url_path.startsWith(p)) } } @@ -63,14 +70,10 @@ function _getPropagator() { return new core.CompositePropagator({ propagators }) } -function _getInstrumentations() { - const instrumentations = [] - for (const each of Object.values(cds.env.requires.telemetry.instrumentations)) { - const module = _require(each.module) - if (!module[each.class]) throw new Error(`Unknown instrumentation "${each.class}" in module "${each.module}"`) - instrumentations.push(new module[each.class]({ ...(each.config || {}) })) - } - return instrumentations +const _protocol2module = { + grpc: '@opentelemetry/exporter-trace-otlp-grpc', + 'http/protobuf': '@opentelemetry/exporter-trace-otlp-proto', + 'http/json': '@opentelemetry/exporter-trace-otlp-http' } function _getExporter() { @@ -80,6 +83,18 @@ function _getExporter() { credentials } = cds.env.requires.telemetry + // for kind telemetry-to-otlp based on env vars + if (tracingExporter === 'env') { + const otlp_env = getEnvWithoutDefaults() + const dflt_env = getEnv() + const protocol = + otlp_env.OTEL_EXPORTER_OTLP_TRACES_PROTOCOL ?? + otlp_env.OTEL_EXPORTER_OTLP_PROTOCOL ?? + dflt_env.OTEL_EXPORTER_OTLP_TRACES_PROTOCOL ?? + dflt_env.OTEL_EXPORTER_OTLP_PROTOCOL + tracingExporter = { module: _protocol2module[protocol], class: 'OTLPTraceExporter' } + } + // use _require for better error message const tracingExporterModule = tracingExporter.module === '@cap-js/telemetry' ? require('../exporter') : _require(tracingExporter.module) @@ -87,7 +102,7 @@ function _getExporter() { throw new Error(`Unknown tracing exporter "${tracingExporter.class}" in module "${tracingExporter.module}"`) const tracingConfig = { ...(tracingExporter.config || {}) } - if (kind.match(/dynatrace/)) { + if (kind.match(/to-dynatrace$/)) { if (!credentials) credentials = getCredsForDTAsUPS() if (!credentials) throw new Error('No Dynatrace credentials found') tracingConfig.url ??= `${credentials.apiurl}/v2/otlp/v1/traces` @@ -100,7 +115,8 @@ function _getExporter() { tracingConfig.headers.authorization ??= `Api-Token ${token}` } - if (kind.match(/cloud-logging/)) { + if (kind.match(/to-cloud-logging$/)) { + if (!credentials) credentials = getCredsForCLSAsUPS() if (!credentials) throw new Error('No SAP Cloud Logging credentials found') augmentCLCreds(credentials) tracingConfig.url ??= credentials.url @@ -124,16 +140,14 @@ module.exports = resource => { resource = new Resource({}).merge(resource).merge(dtmetadata) tracerProvider = new NodeTracerProvider({ resource, sampler: _getSampler() }) tracerProvider.register({ propagator: _getPropagator() }) - const instrumentations = _getInstrumentations() - registerInstrumentations({ tracerProvider, instrumentations }) } else { LOG._warn && LOG.warn('TracerProvider already initialized by a different module. It will be used as is.') tracerProvider = tracerProvider.getDelegate() } const via_one_agent = process.env.DT_NODE_PRELOAD_OPTIONS && - cds.env.requires.telemetry.kind.match(/dynatrace/) && - cds.env.requires.telemetry.tracing._force_export !== true + cds.env.requires.telemetry.kind.match(/to-dynatrace$/) && + !hasDependency('@opentelemetry/exporter-trace-otlp-proto') if (via_one_agent) { // if Dynatrace OneAgent is present, no exporter is needed LOG._info && LOG.info('Dynatrace OneAgent detected, disabling tracing exporter') @@ -152,11 +166,22 @@ module.exports = resource => { cds._telemetry.tracer._active = true }) + // clear sap passport for new tx + if (process.env.SAP_PASSPORT) { + cds.on('served', () => { + cds.db?.before('BEGIN', async function () { + if (this.dbc?.constructor.name in { HDBDriver: 1, HANAClientDriver: 1 }) this.dbc.set({ SAP_PASSPORT: '' }) + }) + }) + } + /* - * add CAP instrumentations + * add tracing */ require('./cds')() // REVISIT: we should be able to remove okra instrumentation entirely, but keep behind feature flag for now if (process.env.TRACE_OKRA) require('./okra')() require('./cloud_sdk')() + + return tracerProvider } diff --git a/lib/tracing/okra.js b/lib/tracing/okra.js index c91aa5e..9ca7593 100644 --- a/lib/tracing/okra.js +++ b/lib/tracing/okra.js @@ -15,7 +15,8 @@ module.exports = () => { const { process: _process } = OKRAService.prototype - if (!process.env.NO_LOCATE) { + const NO_LOCATE = process.env.NO_LOCATE || cds.env.requires.telemetry.tracing.no_locate + if (!NO_LOCATE) { let __location locate(_process).then(location => { __location = location diff --git a/lib/tracing/trace.js b/lib/tracing/trace.js index 9a7ea62..f9296c2 100644 --- a/lib/tracing/trace.js +++ b/lib/tracing/trace.js @@ -90,7 +90,7 @@ function _determineKind(targetObj, phase, isAsyncConsumer, options) { // DB Calls & Remote calls are client calls if (targetObj.dbc || targetObj.constructor.name === 'RemoteService' || options.outbound) return SpanKind.CLIENT if (targetObj.constructor.name === 'cds' || phase === 'emit') - // cds.spawn or srv.emit + // cds.spawn or srv.emit return SpanKind.PRODUCER if (isAsyncConsumer) return SpanKind.CONSUMER return SpanKind.INTERNAL @@ -171,7 +171,7 @@ function _getDynamicDBAttributes(options, args, parentSpan) { // the second time, args is the string query -> we need to get event and target from the previous invocation (i.e., parentSpan). const dbAttributes = new Map() const db_statement = - options.sql || (typeof args[0].query === 'string' && args[0].query) || (typeof args[0] === 'string' && args[0]) + options.sql || (typeof args[0].query === 'string' && args[0].query) || (typeof args[0] === 'string' && args[0]) if (db_statement) dbAttributes.set(ATTR_DB_STATEMENT, db_statement) const db_operation = args[0].event || parentSpan?.attributes[ATTR_DB_OPERATION] if (db_operation) dbAttributes.set(ATTR_DB_OPERATION, db_operation) @@ -195,10 +195,10 @@ function trace(name, fn, targetObj, args, options = {}) { const parentSpan = _getParentSpan() const isAsync = parentSpan?._is_async && !parentSpan?.name.match(/cds\.spawn/) const ctx = isAsync - ? ROOT_CONTEXT - : parentSpan - ? otel.trace.setSpan(otel.context.active(), parentSpan) - : otel.context.active() + ? ROOT_CONTEXT + : parentSpan + ? otel.trace.setSpan(otel.context.active(), parentSpan) + : otel.context.active() const spanOptions = { kind: _determineKind(targetObj, name?.phase, isAsync, options) } @@ -274,9 +274,9 @@ function trace(name, fn, targetObj, args, options = {}) { // augment db.statement at parent, if necessary if ( - span.attributes[ATTR_DB_STATEMENT] && - parentSpan?.attributes[ATTR_DB_SYSTEM] && - !parentSpan.attributes[ATTR_DB_STATEMENT] + span.attributes[ATTR_DB_STATEMENT] && + parentSpan?.attributes[ATTR_DB_SYSTEM] && + !parentSpan.attributes[ATTR_DB_STATEMENT] ) { parentSpan.setAttribute(ATTR_DB_STATEMENT, span.attributes[ATTR_DB_STATEMENT]) } @@ -292,7 +292,6 @@ function trace(name, fn, targetObj, args, options = {}) { */ return otel.context.with(cds.context?._otelctx.setValue(cds.context._otelKey, span), () => { const onSuccess = res => { - addDbRowCount(span, res); span.setStatus({ code: SpanStatusCode.OK }) return res } @@ -317,21 +316,4 @@ function trace(name, fn, targetObj, args, options = {}) { }) } -const addDbRowCount = (span, res) => { - if(!span.attributes["db.statement"] || !["all", "run"].includes(span.attributes["code.function"])) { - return; - } - let rowCount; - switch(span.attributes["db.operation"]) { - case "DELETE": - case "UPDATE": - case "CREATE": - rowCount = res.changes; - break; - case "READ": - rowCount = res.length ?? 1 - } - span.setAttribute("db.rowCount", rowCount); -} - module.exports = trace diff --git a/lib/tracing/wrap.js b/lib/tracing/wrap.js index 1c939f8..e4fe699 100644 --- a/lib/tracing/wrap.js +++ b/lib/tracing/wrap.js @@ -1,10 +1,13 @@ +const cds = require('@sap/cds') + const locate = require('./locate') const trace = require('./trace') function wrap(fn, options) { if (!fn.__wrapped) { // locate the function - if (!options.no_locate && !process.env.NO_LOCATE && !fn.__location) { + const NO_LOCATE = options.no_locate || process.env.NO_LOCATE || cds.env.requires.telemetry.tracing.no_locate + if (!NO_LOCATE && !fn.__location) { let __location = {} locate(fn).then(location => { __location = location diff --git a/lib/utils.js b/lib/utils.js index ef542a2..23be98a 100644 --- a/lib/utils.js +++ b/lib/utils.js @@ -6,7 +6,12 @@ const fs = require('fs') const { DiagLogLevel } = require('@opentelemetry/api') const { hrTimeToMilliseconds } = require('@opentelemetry/core') const { Resource } = require('@opentelemetry/resources') -const { SemanticResourceAttributes } = require('@opentelemetry/semantic-conventions') +const { + ATTR_SERVICE_NAME, + ATTR_SERVICE_VERSION, + SEMRESATTRS_SERVICE_NAMESPACE: ATTR_SERVICE_NAMESPACE, + SEMRESATTRS_SERVICE_INSTANCE_ID: ATTR_SERVICE_INSTANCE_ID +} = require('@opentelemetry/semantic-conventions') function getDiagLogLevel() { if (LOG._trace) return DiagLogLevel.VERBOSE @@ -33,16 +38,15 @@ function getResource() { const attributes = {} // Service - attributes[SemanticResourceAttributes.SERVICE_NAME] = process.env.OTEL_SERVICE_NAME || name - attributes[SemanticResourceAttributes.SERVICE_VERSION] = process.env.OTEL_SERVICE_VERSION || version + attributes[ATTR_SERVICE_NAME] = process.env.OTEL_SERVICE_NAME || name + attributes[ATTR_SERVICE_VERSION] = process.env.OTEL_SERVICE_VERSION || version // Service (Experimental) - if (process.env.OTEL_SERVICE_NAMESPACE) - attributes[SemanticResourceAttributes.SERVICE_NAMESPACE] = process.env.OTEL_SERVICE_NAMESPACE - if (VCAP_APPLICATION) attributes[SemanticResourceAttributes.SERVICE_INSTANCE_ID] = VCAP_APPLICATION.instance_id + if (process.env.OTEL_SERVICE_NAMESPACE) attributes[ATTR_SERVICE_NAMESPACE] = process.env.OTEL_SERVICE_NAMESPACE + if (VCAP_APPLICATION) attributes[ATTR_SERVICE_INSTANCE_ID] = VCAP_APPLICATION.instance_id if (process.env.CF_INSTANCE_GUID) { - attributes[SemanticResourceAttributes.SERVICE_INSTANCE_ID] = process.env.CF_INSTANCE_GUID + attributes[ATTR_SERVICE_INSTANCE_ID] = process.env.CF_INSTANCE_GUID attributes['sap.cf.instance_id'] = process.env.CF_INSTANCE_GUID } @@ -96,6 +100,15 @@ function getCredsForDTAsUPS() { return vcap['user-provided'].find(b => b.name.match(/dynatrace/)).credentials } +function getCredsForCLSAsUPS() { + if (!process.env.VCAP_SERVICES) return + const vcap = JSON.parse(process.env.VCAP_SERVICES) + + // to support connection via user-provided services, the instance name must contain "cloud-logging" + if (vcap['user-provided']?.some(b => b.name.match(/cloud-logging/))) + return vcap['user-provided'].find(b => b.name.match(/cloud-logging/)).credentials +} + function augmentCLCreds(credentials) { if (credentials._augmented) return credentials._augmented = true @@ -160,6 +173,7 @@ module.exports = { getResource, getDynatraceMetadata, getCredsForDTAsUPS, + getCredsForCLSAsUPS, augmentCLCreds, hasDependency, _hrnow, diff --git a/package.json b/package.json index cc44aca..f586b7b 100644 --- a/package.json +++ b/package.json @@ -1,8 +1,11 @@ { "name": "@cap-js/telemetry", - "version": "0.2.3", + "version": "1.1.2", "description": "CDS plugin providing observability features, incl. automatic OpenTelemetry instrumentation.", - "repository": "cap-js/telemetry", + "repository": { + "type": "git", + "url": "git+https://github.com/cap-js/telemetry.git" + }, "author": "SAP SE (https://www.sap.com)", "homepage": "https://cap.cloud.sap/", "license": "SEE LICENSE IN LICENSE", @@ -16,14 +19,15 @@ "test": "npx jest --silent" }, "dependencies": { - "@opentelemetry/api": "^1.7.0", - "@opentelemetry/core": "^1.11.0", - "@opentelemetry/instrumentation-http": "^0.52.1", - "@opentelemetry/resources": "^1.10.1", - "@opentelemetry/sdk-metrics": "^1.17.1", - "@opentelemetry/sdk-trace-base": "^1.10.1", - "@opentelemetry/sdk-trace-node": "^1.21.0", - "@opentelemetry/semantic-conventions": "^1.10.1" + "@opentelemetry/api": "^1.9", + "@opentelemetry/core": "^1.27", + "@opentelemetry/instrumentation": "^0.55", + "@opentelemetry/instrumentation-http": "^0.55", + "@opentelemetry/resources": "^1.27", + "@opentelemetry/sdk-metrics": "^1.27", + "@opentelemetry/sdk-trace-base": "^1.27", + "@opentelemetry/sdk-trace-node": "^1.27", + "@opentelemetry/semantic-conventions": "^1.27" }, "peerDependencies": { "@sap/cds": ">=7" @@ -31,15 +35,15 @@ "devDependencies": { "@cap-js/sqlite": "^1.4.0", "@cap-js/telemetry": "file:.", - "@sap/cds-mtxs": "^1.16.0", "@dynatrace/oneagent-sdk": "^1.5.0", "@grpc/grpc-js": "^1.9.14", - "@opentelemetry/exporter-metrics-otlp-grpc": "^0.52.1", - "@opentelemetry/exporter-metrics-otlp-proto": "^0.52.1", - "@opentelemetry/exporter-trace-otlp-grpc": "^0.52.1", - "@opentelemetry/exporter-trace-otlp-proto": "^0.52.1", + "@opentelemetry/exporter-metrics-otlp-grpc": "^0.56.0", + "@opentelemetry/exporter-metrics-otlp-proto": "^0.56.0", + "@opentelemetry/exporter-trace-otlp-grpc": "^0.56.0", + "@opentelemetry/exporter-trace-otlp-proto": "^0.56.0", "@opentelemetry/host-metrics": "^0.35.0", - "@opentelemetry/instrumentation": "^0.52.1", + "@opentelemetry/instrumentation-runtime-node": "^0.11.0", + "@sap/cds-mtxs": "^2.0.5", "axios": "^1.6.7", "chai": "^4.4.1", "chai-as-promised": "^7.1.1", @@ -51,39 +55,41 @@ "cds": { "requires": { "telemetry": { - "instrumentations": { - "http": { - "module": "@opentelemetry/instrumentation-http", - "class": "HttpInstrumentation", - "config": { - "ignoreIncomingPaths": [ - "/health" - ] + "kind": "telemetry-to-console" + }, + "kinds": { + "telemetry": { + "instrumentations": { + "http": { + "module": "@opentelemetry/instrumentation-http", + "class": "HttpInstrumentation", + "config": { + "ignoreIncomingPaths": [ + "/health" + ] + } } - } - }, - "tracing": { - "sampler": { - "kind": "ParentBasedSampler", - "root": "AlwaysOnSampler" - }, - "propagators": [ - "W3CTraceContextPropagator" - ], - "[development]": { - "hrtime": true }, - "_tx": false - }, - "metrics": { - "config": { - "exportIntervalMillis": 60000 + "tracing": { + "sampler": { + "kind": "ParentBasedSampler", + "root": "AlwaysOnSampler" + }, + "propagators": [ + "W3CTraceContextPropagator" + ], + "[development]": { + "hrtime": true + }, + "_tx": false }, - "_db_pool": true + "metrics": { + "config": { + "exportIntervalMillis": 60000 + }, + "_db_pool": true + } }, - "kind": "telemetry-to-console" - }, - "kinds": { "telemetry-to-console": { "tracing": { "exporter": { @@ -140,6 +146,14 @@ "class": "OTLPTraceExporter" } } + }, + "telemetry-to-otlp": { + "tracing": { + "exporter": "env" + }, + "metrics": { + "exporter": "env" + } } } } diff --git a/test/bookshop/package.json b/test/bookshop/package.json index 7c138b7..b67fac7 100644 --- a/test/bookshop/package.json +++ b/test/bookshop/package.json @@ -4,6 +4,7 @@ "@cap-js/telemetry": "*", "@cap-js/sqlite": "*", "@opentelemetry/host-metrics": "*", + "@opentelemetry/instrumentation-runtime-node": "*", "@sap/cds-mtxs": "*" }, "cds": { @@ -19,6 +20,10 @@ "/odata/v4/admin/Authors" ] } + }, + "instrumentation-runtime-node": { + "class": "RuntimeNodeInstrumentation", + "module": "@opentelemetry/instrumentation-runtime-node" } }, "_tracing": { diff --git a/test/tracing.test.js b/test/tracing.test.js index 3bd5202..a634ca7 100644 --- a/test/tracing.test.js +++ b/test/tracing.test.js @@ -17,6 +17,16 @@ describe('tracing', () => { expect(log.output).to.match(/\s+\d+\.\d+ → \s*\d+\.\d+ = \s*\d+\.\d+ ms \s* AdminService - READ AdminService.Books/) }) + // REVISIT: jest breaks otel's patching of incoming request handling -> no span for 'GET' -> behavior to test not reproducible + xtest('GET with traceparent is traced', async () => { + const config = { ...admin, headers: { traceparent: '00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01' } } + const { status } = await GET('/odata/v4/admin/Books', config) + expect(status).to.equal(200) + // primitive check that console has trace logs + expect(log.output).to.match(/\[telemetry\] - elapsed times:/) + expect(log.output).to.match(/\s+\d+\.\d+ → \s*\d+\.\d+ = \s*\d+\.\d+ ms \s* AdminService - READ AdminService.Books/) + }) + test('NonRecordingSpans are handled correctly', async () => { const { status: postStatus } = await POST('/odata/v4/admin/Authors', { ID: 42, name: 'Douglas Adams' }, admin) expect(postStatus).to.equal(201)