diff --git a/.ci/tav.json b/.ci/tav.json index 2afb38b24a..146aabd26f 100644 --- a/.ci/tav.json +++ b/.ci/tav.json @@ -5,11 +5,10 @@ "@elastic/elasticsearch", "@elastic/elasticsearch-canary", "@hapi/hapi", - "@koa/router", "@opentelemetry/api", + "@opentelemetry/sdk-metrics", "apollo-server-express", "aws-sdk", - "bluebird", "cassandra-driver", "elasticsearch", "express", @@ -18,25 +17,23 @@ "fastify", "finalhandler", "generic-pool", - "got", "graphql", - "handlebars", "ioredis", "knex", - "koa-router", "memcached", - "mimic-response", "mongodb", "mongodb-core", "mysql", "mysql2", "next", "pg", - "pug", "redis", "restify", "tedious", "undici", - "ws" + "ws", + "@koa/router,koa-router", + "handlebars,pug", + "bluebird,got,mimic-response" ] } diff --git a/.eslintrc.json b/.eslintrc.json index c38fc5a95d..9648af87d7 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -15,6 +15,7 @@ "/.nyc_output", "/build", "node_modules", + "elastic-apm-node.js", "/examples/esbuild/dist", "/examples/typescript/dist", "/examples/nextjs", @@ -30,6 +31,6 @@ "/test/types/transpile/index.js", "/test/types/transpile-default/index.js", "/test_output", - "/tmp" + "tmp" ] } diff --git a/.github/dependabot.yml b/.github/dependabot.yml index d3f84bffaf..cc300f4d14 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -24,6 +24,22 @@ updates: reviewers: - "elastic/apm-agent-node-js" + - package-ecosystem: "npm" + directory: "/test/opentelemetry-bridge" + schedule: + interval: "weekly" + open-pull-requests-limit: 5 + reviewers: + - "elastic/apm-agent-node-js" + + - package-ecosystem: "npm" + directory: "/test/opentelemetry-metrics/fixtures" + schedule: + interval: "weekly" + open-pull-requests-limit: 5 + reviewers: + - "elastic/apm-agent-node-js" + - package-ecosystem: "npm" directory: "/examples/opentelemetry-bridge" schedule: diff --git a/.github/workflows/tav.yml b/.github/workflows/tav.yml index 8f0e5f129c..2d4d3bc2e9 100644 --- a/.github/workflows/tav.yml +++ b/.github/workflows/tav.yml @@ -41,6 +41,9 @@ jobs: max-parallel: 30 fail-fast: false matrix: + # A job matrix limit is 256. We do some grouping of TAV modules to + # stay under that limit. + # https://docs.github.com/en/actions/learn-github-actions/usage-limits-billing-and-administration node: ${{ fromJSON(needs.prepare-matrix.outputs.versions) }} module: ${{ fromJSON(needs.prepare-matrix.outputs.modules) }} steps: diff --git a/.gitignore b/.gitignore index e58f59c9e4..cf0a8d7eda 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,6 @@ /build node_modules /test/benchmarks/.tmp -/tmp +tmp /examples/*/dist .next diff --git a/.tav.yml b/.tav.yml index ea74e6dcb8..be49c4a31d 100644 --- a/.tav.yml +++ b/.tav.yml @@ -537,13 +537,3 @@ undici: commands: node test/instrumentation/modules/undici/undici.test.js node: '>=14' -"@opentelemetry/api": - versions: '>=1.0.0 <1.5.0' - node: '>=8.0.0' - commands: - - node test/opentelemetry-bridge/OTelBridgeNonRecordingSpan.test.js - - node test/opentelemetry-bridge/OTelBridgeRunContext.test.js - - node test/opentelemetry-bridge/active-span-and-context-interop.test.js - - node test/opentelemetry-bridge/fixtures.test.js - - node test/opentelemetry-bridge/interface-ContextManager.test.js - - node test/opentelemetry-bridge/otel-bridge-feature.test.js diff --git a/CHANGELOG.asciidoc b/CHANGELOG.asciidoc index 05bf8be84e..4a96e52016 100644 --- a/CHANGELOG.asciidoc +++ b/CHANGELOG.asciidoc @@ -48,6 +48,13 @@ Notes: then the Lambda extension will report the failed transaction so it can be seen in the Kibana APM app. ({pull}3285[#3285]) +* Add OpenTelemetry Metrics API and Metrics SDK support. This is currently + experimental and may change. With this change, you may use the OpenTelemetry + Metrics API to create custom metrics and the APM agent will ship those + metrics to APM server. As well, you may use the OpenTelemetry Metrics SDK + and the APM agent will automatically add a MetricReader to ship metrics to + APM server. See the <> for details. ({pull}3152[#3152]) + [float] ===== Bug fixes diff --git a/docs/api-opentelemetry.asciidoc b/docs/api-opentelemetry.asciidoc index 8d60a87782..1ac32f4863 100644 --- a/docs/api-opentelemetry.asciidoc +++ b/docs/api-opentelemetry.asciidoc @@ -6,27 +6,26 @@ endif::[] [[opentelemetry-bridge]] == OpenTelemetry bridge -NOTE: Added as experimental in v3.34.0. -To enable it, set <> to `true`. +NOTE: Integration with the OpenTelemetry Tracing API was added as experimental in v3.34.0. +Integration with the OpenTelemetry Metrics API was added as experimental in v3.45.0. The Elastic APM OpenTelemetry bridge allows one to use the vendor-neutral -https://opentelemetry.io/docs/instrumentation/js/api/[OpenTelemetry Tracing API] -(https://www.npmjs.com/package/@opentelemetry/api[`@opentelemetry/api`]) to -manually instrument your code, and have the Elastic Node.js APM agent handle -those API calls. This allows one to use the Elastic APM agent for tracing, -without any vendor lock-in from adding manual tracing using the APM agent's own -<>. +https://opentelemetry.io/docs/instrumentation/js/[OpenTelemetry API] +(https://www.npmjs.com/package/@opentelemetry/api[`@opentelemetry/api`]) in +your code, and have the Elastic Node.js APM agent handle those API calls. +This allows one to use the Elastic APM agent for tracing and metrics without any +vendor lock-in to the APM agent's own <> when adding manual +tracing or custom metrics. [float] -[[otel-getting-started]] -=== Getting started +[[otel-tracing-api]] +=== Using the OpenTelemetry Tracing API -The goal of the OpenTelemetry bridge is to allow using the OpenTelemetry API -with the APM agent. ① First, you will need to add those dependencies to your -project. The minimum required OpenTelemetry API version is 1.0.0; see -<> for the -current maximum supported API version. For example: +① First, you will need to add the Elastic APM agent and OpenTelemetry API +dependencies to your project. The minimum required OpenTelemetry API version is +1.0.0; see <> +for the current maximum supported API version. For example: [source,bash] ---- @@ -41,15 +40,14 @@ your application code): ---- export ELASTIC_APM_SERVER_URL='' export ELASTIC_APM_SECRET_TOKEN='' # or ELASTIC_APM_API_KEY=... -export ELASTIC_APM_OPENTELEMETRY_BRIDGE_ENABLED=true +export ELASTIC_APM_OPENTELEMETRY_BRIDGE_ENABLED=true <1> export NODE_OPTIONS='-r elastic-apm-node/start.js' # Tell node to preload and start the APM agent node my-app.js ---- +<1> Future versions may drop this config var and enable usage of the tracing API by default. Or, alternatively, you can configure and start the APM agent at the top of your -application code as follows. (Note: For automatic instrumentations to function -properly, this must be executed before other `require` statements and -application code.) +application code: [source,js] ---- @@ -62,11 +60,11 @@ require('elastic-apm-node').start({ // Application code ... ---- -NOTE: These examples show the minimal configuration. See <> for other configuration options. +See <> for other configuration options. -③ Finally, you can use the OpenTelemetry API for any manual tracing in your code. -For example, the following script uses -https://open-telemetry.github.io/opentelemetry-js-api/interfaces/tracer.html#startactivespan[Tracer#startActiveSpan()] +③ Finally, you can use the https://open-telemetry.github.io/opentelemetry-js/modules/_opentelemetry_api.html[OpenTelemetry API] +for any manual tracing in your code. For example, the following script uses +https://open-telemetry.github.io/opentelemetry-js/interfaces/_opentelemetry_api.Tracer.html#startActiveSpan[Tracer#startActiveSpan()] to trace an outgoing HTTPS request: [source,js] @@ -89,62 +87,173 @@ tracer.startActiveSpan('makeRequest', span => { ---- The APM agent source code repository includes -https://github.com/elastic/apm-agent-nodejs/tree/main/examples/opentelemetry-bridge[some examples using the OpenTelemetry bridge]. +https://github.com/elastic/apm-agent-nodejs/tree/main/examples/opentelemetry-bridge[some examples using the OpenTelemetry tracing bridge]. + + +[float] +[[otel-metrics-api]] +=== Using the OpenTelemetry Metrics API + +① As above, install the needed dependencies. The minimum required OpenTelemetry +API version is 1.3.0 (the version when metrics were added); see <> +for the current maximum supported API version. For example: + +[source,bash] +---- +npm install --save elastic-apm-node @opentelemetry/api +---- + +② Configure and start the APM agent. This can be done completely with +environment variables -- as shown below -- or in code. (See <> +and <> for other +configuration options.) + +[source,bash] +---- +export ELASTIC_APM_SERVER_URL='' +export ELASTIC_APM_SECRET_TOKEN='' # or ELASTIC_APM_API_KEY=... +export NODE_OPTIONS='-r elastic-apm-node/start.js' # Tell node to preload and start the APM agent +node my-app.js +---- + +③ Finally, you can use the OpenTelemetry Metrics API, to +https://open-telemetry.github.io/opentelemetry-js/interfaces/_opentelemetry_api.Meter.html[create metrics] +and the APM agent will periodically ship those metrics to your Elastic APM +deployment where you can visualize them in Kibana. + +[source,js] +---- +// otel-metrics-hello-world.js <1> +const { createServer } = require('http') +const otel = require('@opentelemetry/api') + +const meter = otel.metrics.getMeter('my-meter') +const numReqs = meter.createCounter('num_requests', { description: 'number of HTTP requests' }) + +const server = createServer((req, res) => { + numReqs.add(1) + req.resume() + req.on('end', () => { + res.end('pong\n') + }) +}) +server.listen(3000, () => { + console.log('listening at http://127.0.0.1:3000/') +}) +---- +<1> The full example is https://github.com/elastic/apm-agent-nodejs/blob/main/examples/opentelemetry-metrics/otel-metrics-hello-world.js[here]. + + +[float] +[[otel-metrics-sdk]] +==== Using the OpenTelemetry Metrics SDK + +The Elastic APM agent also supports exporting metrics to APM server when the +OpenTelemetry Metrics *SDK* is being used directly. You might want to use +the OpenTelemetry Metrics SDK to use a https://opentelemetry.io/docs/reference/specification/metrics/sdk/#view[`View`] +to configure histogram bucket sizes, to setup a Prometheus exporter, or for +other reasons. For example: + +[source,js] +---- +// use-otel-metrics-sdk.js <1> +const otel = require('@opentelemetry/api') +const { MeterProvider } = require('@opentelemetry/sdk-metrics') +const { PrometheusExporter } = require('@opentelemetry/exporter-prometheus') + +const exporter = new PrometheusExporter({ host: '127.0.0.1', port: 3001 }) +const meterProvider = new MeterProvider() +meterProvider.addMetricReader(exporter) +otel.metrics.setGlobalMeterProvider(meterProvider) + +const meter = otel.metrics.getMeter('my-meter') +const latency = meter.createHistogram('latency', { description: 'Response latency (s)' }) +// ... +---- +<1> The full example is https://github.com/elastic/apm-agent-nodejs/blob/main/examples/opentelemetry-metrics/use-otel-metrics-sdk.js[here]. + + +[float] +[[otel-metrics-conf]] +==== OpenTelemetry Metrics configuration + +A few configuration options can be used to control OpenTelemetry Metrics support. + +- Specific metrics names can be filtered out via the <> configuration option. +- Integration with the OpenTelemetry Metrics API can be disabled via the <> configuration option. +- Integration with the OpenTelemetry Metrics SDK can be disabled via the <> configuration option. +- All metrics support in the APM agent can be disabled via the <> configuration option. +- The default histogram bucket boundaries are different from the OpenTelemetry default, to provide better resolution. The boundaries used by the APM agent can be configured with the <> configuration option. [float] [[otel-architecture]] === Bridge architecture -The OpenTelemetry bridge works similarly to the -https://github.com/open-telemetry/opentelemetry-js[OpenTelemetry JS SDK]. It -registers Tracer and ContextManager providers with the OpenTelemetry API. -Subsequent `@opentelemetry/api` calls in user code will call into those -providers. The APM agent translates from OpenTelemetry to Elastic APM semantics -and sends tracing data to your APM server for full support in +The OpenTelemetry Tracing bridge works similarly to the +https://github.com/open-telemetry/opentelemetry-js/tree/main/packages/opentelemetry-sdk-trace-node/[OpenTelemetry Node.js Trace SDK]. +It registers Tracer and ContextManager providers with the OpenTelemetry API. +Subsequent `@opentelemetry/api` calls in user code will use those providers. +The APM agent translates from OpenTelemetry to Elastic APM semantics and sends +tracing data to your APM server for full support in https://www.elastic.co/apm[Elastic Observability's APM app]. -Here are a couple examples of semantic translations: The first entry span of a +Some examples of semantic translations: The first entry span of a service (e.g. an incoming HTTP request) will be converted to an {apm-guide-ref}/data-model-transactions.html[Elasic APM `Transaction`], subsequent spans are mapped to -{apm-guide-ref}/data-model-spans.html[Elastic APM `Span`]. OpenTelemetry Span +{apm-guide-ref}/data-model-spans.html[Elastic APM `Span`s]. OpenTelemetry Span attributes are translated into the appropriate fields in Elastic APM's data model. The only difference, from the user's point of view, is in the setup of tracing. Instead of setting up the OpenTelemetry JS SDK, one sets up the APM agent -as <>. +as <>. + +--- +The OpenTelemetry Metrics support, is slightly different. If your code uses +just the Metrics *API*, then the APM agent provides a full MeterProvider so +that metrics are accumulated and sent to APM server. If your code uses the +Metrics *SDK*, then the APM agents adds a MetricReader to your MeterProvider +to send metrics on to APM server. This allows you to use the APM agent as +either an easy setup for using metrics or in conjunction with your existing +OpenTelemetry Metrics configuration. [float] [[otel-caveats]] === Caveats -Not all features of the OpenTelemetry API are supported. -[float] -[[otel-metrics]] -===== Metrics -This bridge only supports the tracing API. -The Metrics API is currently not supported. +Not all features of the OpenTelemetry API are supported. This section describes +any limitations and differences. [float] -[[otel-span-links]] -===== Span Link Attributes +[[otel-caveats-tracing]] +===== Tracing -Adding links when -https://open-telemetry.github.io/opentelemetry-js-api/interfaces/tracer.html[starting a span] -*is* currently supported, but any span link *attributes are silently dropped*. +- Span Link Attributes. Adding links when https://open-telemetry.github.io/opentelemetry-js/interfaces/\_opentelemetry_api.Tracer.html[starting a span] is supported, but any added span link *attributes* are silently dropped. +- Span events (https://open-telemetry.github.io/opentelemetry-js/interfaces/_opentelemetry_api.Span.html#addEvent[`Span#addEvent()`]) are not currently supported. Events will be silently dropped. +- https://open-telemetry.github.io/opentelemetry-js/classes/_opentelemetry_api.PropagationAPI.html[Propagating baggage] within or outside the process is not supported. Baggage items are silently dropped. [float] -[[otel-span-events]] -===== Span Events -Span events (https://open-telemetry.github.io/opentelemetry-js-api/interfaces/span.html#addevent[`Span#addEvent()`]) -is not currently supported. Events will be silently dropped. +[[otel-caveats-metrics]] +===== Metrics + +- Metrics https://opentelemetry.io/docs/reference/specification/metrics/data-model/#exemplars[exemplars] are not supported. +- https://opentelemetry.io/docs/reference/specification/metrics/data-model/#summary-legacy[Summary metrics] are not supported. +- https://opentelemetry.io/docs/reference/specification/metrics/data-model/#exponentialhistogram[Exponential Histograms] are not yet supported. +- The `sum`, `count`, `min` and `max` within the OpenTelemetry histogram data are discarded. +- The default histogram bucket boundaries are different from the OpenTelemetry default. They provide better resolution. They can be configured with the <> configuration option. +- Metrics label names are dedotted (`s/\./_/g`) in APM server to avoid possible mapping collisions in Elasticsearch. +- The default https://github.com/elastic/apm/blob/main/specs/agents/metrics-otel.md#aggregation-temporality[Aggregation Temporality] used differs from the OpenTelemetry default -- preferring *delta*-temporality (nicer for visualizing in Kibana) to cumulative-temporality. + +Metrics support requires an APM server >=7.11 -- for earlier APM server +versions, metrics with label names including `.`, `*`, or `"` will get dropped. + [float] -[[otel-baggage]] -===== Baggage -https://open-telemetry.github.io/opentelemetry-js-api/classes/propagationapi.html[Propagating baggage] -within or outside the process is not supported. Baggage items are silently -dropped. +[[otel-caveats-logs]] +===== Logs + +The OpenTelemetry Logs API is currently not support -- only the Tracing and +Metrics APIs. diff --git a/docs/configuration.asciidoc b/docs/configuration.asciidoc index 1ea4db4f54..65554bd5e7 100644 --- a/docs/configuration.asciidoc +++ b/docs/configuration.asciidoc @@ -1167,6 +1167,74 @@ the self-time spent in each unique type of span. This data drives the chart in the APM app. +[[disable-metrics]] +==== `disableMetrics` + +[small]#Added in: v3.45.0# + +* *Type:* Array +* *Env:* `ELASTIC_APM_DISABLE_METRICS` + +The `disableMetrics` configuration variable is a list of wildcard patterns of metric names to *not* send to APM server. The filter is applied to <>, custom metrics defined by <>, and metrics defined <>. + +For example, setting the `ELASTIC_APM_DISABLE_METRICS="nodejs.*,my_counter"` environment variable (or the equivalent `disableMetrics: ['nodejs.*', 'my_counter']` option to <>) will result in reported metrics excluding any metric named `my_counter` and any starting with `nodejs.`. Wildcard matches are case-insensitive by default. You may make wildcard searches case-sensitive by using the `(?-i)` prefix. + +Use `metricsInterval: '0s'` to completely disable metrics collection. See <>. + + +[[custom-metrics-histogram-boundaries]] +==== `customMetricsHistogramBoundaries` + +[small]#Added in: v3.45.0 as experimental# + +* *Type:* number[] +* *Default:* (See below.) +* *Env:* `ELASTIC_APM_CUSTOM_METRICS_HISTOGRAM_BOUNDARIES` + +Defines the default bucket boundaries to use for OpenTelemetry Metrics +histograms. By default the value is: + +[source,js] +---- +[ + 0.00390625, 0.00552427, 0.0078125, 0.0110485, + 0.015625, 0.0220971, 0.03125, 0.0441942, + 0.0625, 0.0883883, 0.125, 0.176777, + 0.25, 0.353553, 0.5, 0.707107, + 1, 1.41421, 2, 2.82843, + 4, 5.65685, 8, 11.3137, + 16, 22.6274, 32, 45.2548, + 64, 90.5097, 128, 181.019, + 256, 362.039, 512, 724.077, + 1024, 1448.15, 2048, 2896.31, + 4096, 5792.62, 8192, 11585.2, + 16384, 23170.5, 32768, 46341, + 65536, 92681.9, 131072 +] +---- + +This differs from the https://opentelemetry.io/docs/reference/specification/metrics/sdk/#explicit-bucket-histogram-aggregation[OpenTelemetry default histogram boundaries]. To use the OpenTelemetry default boundaries, configure the APM agent with: + +[source,js] +---- +apm.start({ + customMetricsHistogramBoundaries: [ 0, 5, 10, 25, 50, 75, 100, 250, 500, 750, 1000, 2500, 5000, 7500, 10000 ], + // ... +}) +---- + +or + +[source,bash] +---- +export ELASTIC_APM_CUSTOM_METRICS_HISTOGRAM_BOUNDARIES=0,5,10,25,50,75,100,250,500,750,1000,2500,5000,7500,10000 +---- + +To customize the boundaries for specific histogram metrics, use an OpenTelemetry Metrics SDK https://opentelemetry.io/docs/reference/specification/metrics/sdk/#view[`View`]. See https://github.com/elastic/apm-agent-nodejs/blob/main/examples/opentelemetry-metrics/use-otel-metrics-sdk.js[this script] for an example. + +See <> for a general guide on using OpenTelemetry with this APM agent. + + [[cloud-provider]] ==== `cloudProvider` * *Type:* String diff --git a/docs/supported-technologies.asciidoc b/docs/supported-technologies.asciidoc index cd4f68d5dc..50696c8b37 100644 --- a/docs/supported-technologies.asciidoc +++ b/docs/supported-technologies.asciidoc @@ -103,12 +103,15 @@ These are the frameworks that we officially support: === OpenTelemetry The Node.js Elastic APM agent supports usage of the OpenTelemetry Tracing API -via its <>. +via its <>. As well, it instruments the OpenTelemetry +Metrics API and Metrics SDK to allow +<>. [options="header"] |======================================================================= -| Framework | Version +| Framework | Version | <> | >=1.0.0 <1.5.0 +| https://www.npmjs.com/package/@opentelemetry/sdk-metrics[@opentelemetry/sdk-metrics] | >=1.11.0 <2 |======================================================================= diff --git a/examples/opentelemetry-bridge/README.md b/examples/opentelemetry-bridge/README.md index bd203308f5..769610dea2 100644 --- a/examples/opentelemetry-bridge/README.md +++ b/examples/opentelemetry-bridge/README.md @@ -1,6 +1,7 @@ This directory includes example Node.js scripts showing usage of the -OpenTelemetry JS API. These can be instrumented with the Elastic Node.js APM -agent using its OpenTelemetry Bridge. +OpenTelemetry JS Tracing API. These can be instrumented with the Elastic +Node.js APM agent using its OpenTelemetry Bridge +(https://www.elastic.co/guide/en/apm/agent/nodejs/current/opentelemetry-bridge.html). Setup dependencies via: @@ -19,8 +20,10 @@ For example: node -r elastic-apm-node/start.js trace-https-request.js While these examples are written to use the `node -r elastic-apm-node/start.js ...` -mechanism to start the APM agent. That isn't required. One can still load and -start the APM agent at the top of a script like this: +mechanism to start the APM agent, there are other ways to start the APM agent. +For example, one can start the APM agent at the top of a script as follows. +(See https://www.elastic.co/guide/en/apm/agent/nodejs/current/starting-the-agent.html +for details.) ```js require('elastic-apm-node').start({ diff --git a/examples/opentelemetry-metrics/.npmrc b/examples/opentelemetry-metrics/.npmrc new file mode 100644 index 0000000000..43c97e719a --- /dev/null +++ b/examples/opentelemetry-metrics/.npmrc @@ -0,0 +1 @@ +package-lock=false diff --git a/examples/opentelemetry-metrics/README.md b/examples/opentelemetry-metrics/README.md new file mode 100644 index 0000000000..787ef4bb24 --- /dev/null +++ b/examples/opentelemetry-metrics/README.md @@ -0,0 +1,55 @@ +This directory shows how you can use the OpenTelemetry Metrics API with the Elastic APM Node.js agent. + +1. You will need an Elastic deployment to which to send APM data. + If you don't have one, start here to use a free hosted trial: + https://www.elastic.co/guide/en/apm/guide/current/apm-quick-start.html + +2. Install dependencies: + + ``` + git clone https://github.com/elastic/apm-agent-nodejs.git + cd apm-agent-nodejs/examples/opentelemetry-metrics + npm install + ``` + +3. Run [the "Hello World" example](./otel-metrics-hello-world.js). This shows + a minimal example using the `@opentelemetry/api` to create a custom counter + metric. + + ``` + export ELASTIC_APM_SERVER_URL=https://... # your Elastic APM Server URL + export ELASTIC_APM_SECRET_TOKEN=... + node -r elastic-apm-node/start.js otel-metrics-hello-world.js + ``` + + Then add some load to exercise the custom metrics in that script: + + ``` + while true; do curl http://127.0.0.1:3000/; sleep 1; done + ``` + + **The `num_requests` custom metric counter can now be visualized in Kibana.** + + ![A chart of num_requests](./num_requests-chart.png) + +5. Stop the hello world example and run [the "Using the OTel Metrics SDK" example](./use-otel-metrics-sdk.js). + This example shows how to use the OpenTelemetry Metrics *SDK* to export metrics + both via a Prometheus endpoint *and* to Elastic APM: + + ``` + export ELASTIC_APM_SERVER_URL=https://... # your Elastic APM Server URL + export ELASTIC_APM_SECRET_TOKEN=... + node -r elastic-apm-node/start.js use-otel-metrics-sdk.js + ``` + + Again apply some load to exercise the the script: + + ``` + while true; do curl http://127.0.0.1:3000/; sleep 1; done + ``` + + **The a percentile of the `latency` custom histogram metric can now be visualized in Kibana.** + + +See https://www.elastic.co/guide/en/apm/agent/nodejs/current/opentelemetry-bridge.html +for more details on the OpenTelemetry integration in the Elastic APM Node.js agent. diff --git a/examples/opentelemetry-metrics/num_requests-chart.png b/examples/opentelemetry-metrics/num_requests-chart.png new file mode 100644 index 0000000000..2d2ca05848 Binary files /dev/null and b/examples/opentelemetry-metrics/num_requests-chart.png differ diff --git a/examples/opentelemetry-metrics/otel-metrics-hello-world.js b/examples/opentelemetry-metrics/otel-metrics-hello-world.js new file mode 100644 index 0000000000..e7cd72f7b0 --- /dev/null +++ b/examples/opentelemetry-metrics/otel-metrics-hello-world.js @@ -0,0 +1,22 @@ +/* + * Copyright Elasticsearch B.V. and other contributors where applicable. + * Licensed under the BSD 2-Clause License; you may not use this file except in + * compliance with the BSD 2-Clause License. + */ + +const { createServer } = require('http') +const otel = require('@opentelemetry/api') + +const meter = otel.metrics.getMeter('my-meter') +const numReqs = meter.createCounter('num_requests', { description: 'number of HTTP requests' }) + +const server = createServer((req, res) => { + numReqs.add(1) + req.resume() + req.on('end', () => { + res.end('pong\n') + }) +}) +server.listen(3000, () => { + console.log('listening at http://127.0.0.1:3000/') +}) diff --git a/examples/opentelemetry-metrics/package.json b/examples/opentelemetry-metrics/package.json new file mode 100644 index 0000000000..2ceb9e6bc3 --- /dev/null +++ b/examples/opentelemetry-metrics/package.json @@ -0,0 +1,11 @@ +{ + "name": "examples-opentelemetry-metrics", + "version": "1.0.0", + "private": true, + "dependencies": { + "@opentelemetry/api": "^1.4.1", + "@opentelemetry/exporter-prometheus": "^0.38.0", + "@opentelemetry/sdk-metrics": "^1.12.0", + "elastic-apm-node": "file:../.." + } +} diff --git a/examples/opentelemetry-metrics/use-otel-metrics-sdk.js b/examples/opentelemetry-metrics/use-otel-metrics-sdk.js new file mode 100644 index 0000000000..265a761e9b --- /dev/null +++ b/examples/opentelemetry-metrics/use-otel-metrics-sdk.js @@ -0,0 +1,65 @@ +/* + * Copyright Elasticsearch B.V. and other contributors where applicable. + * Licensed under the BSD 2-Clause License; you may not use this file except in + * compliance with the BSD 2-Clause License. + */ + +// This is a small example showing how to use the OTel Metrics SDK and API +// together with the Elastic APM agent. +// +// This sets up a simple HTTP server with a histogram metric for response +// latency. +// while true; do curl http://127.0.0.1:3000/; sleep 1; done +// +// It shows using a `View` to configure the buckets for a histogram metric and +// sets up a Prometheus endpoint: +// curl -i http://127.0.0.1:3001/metrics +// +// When the Elastic APM agent is started, the histogram metric will also +// periodically be exported to the configured Elastic APM server: +// export ELASTIC_APM_SERVER_URL='' +// export ELASTIC_APM_SECRET_TOKEN='' +// node -r elastic-apm-node/start.js use-otel-metrics-sdk.js + +'use strict' + +const { createServer } = require('http') +const { performance } = require('perf_hooks') + +const otel = require('@opentelemetry/api') +const { MeterProvider, View, ExplicitBucketHistogramAggregation } = require('@opentelemetry/sdk-metrics') +const { PrometheusExporter } = require('@opentelemetry/exporter-prometheus') + +const exporter = new PrometheusExporter({ host: '127.0.0.1', port: 3001 }) +const meterProvider = new MeterProvider({ + views: [ + new View({ + instrumentName: 'latency', + aggregation: new ExplicitBucketHistogramAggregation( + // Use the same default buckets as in `prom-client` for comparison. + // This is to demonstrate using a View. The default buckets used by the + // APM agent would suffice as well. + [0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10]) + }) + ] +}) +meterProvider.addMetricReader(exporter) +otel.metrics.setGlobalMeterProvider(meterProvider) +console.log('Prometheus metrics at http://127.0.0.1:3001/metrics') + +const meter = otel.metrics.getMeter('my-meter') +const latency = meter.createHistogram('latency', { description: 'Response latency (s)' }) + +const server = createServer((req, res) => { + const start = performance.now() + req.resume() + req.on('end', () => { + setTimeout(() => { + res.end('pong\n') + latency.record((performance.now() - start) / 1000) // latency in seconds + }, Math.random() * 80) // random latency up to 80ms + }) +}) +server.listen(3000, () => { + console.log('Listening at http://127.0.0.1:3000/') +}) diff --git a/lib/agent.js b/lib/agent.js index b9d27b8357..241f3e8123 100644 --- a/lib/agent.js +++ b/lib/agent.js @@ -26,6 +26,7 @@ var symbols = require('./symbols') const { frameCacheStats, initStackTraceCollection } = require('./stacktraces') const Span = require('./instrumentation/span') const Transaction = require('./instrumentation/transaction') +const { isOTelMetricsFeatSupported, createOTelMeterProvider } = require('./opentelemetry-metrics') var IncomingMessage = http.IncomingMessage var ServerResponse = http.ServerResponse @@ -49,6 +50,7 @@ function Agent () { this._inflightEvents = new InflightEventSet() this._instrumentation = new Instrumentation(this) this._metrics = new Metrics(this) + this._otelMeterProvider = null this._errorFilters = new Filters() this._transactionFilters = new Filters() this._spanFilters = new Filters() @@ -119,6 +121,17 @@ Agent.prototype.destroy = function () { this._metrics.stop() this._instrumentation.stop() + if (this._otelMeterProvider) { + // Dev Note: It is unfortunate that `.destroy()` can return while this + // shutdown work might still be ongoing. See + // https://github.com/elastic/apm-agent-nodejs/issues/3222 to fix that. + this._otelMeterProvider.shutdown({ timeoutMillis: 1000 }) + .catch(reason => { + this.logger.warn('failed to shutdown OTel MeterProvider:', reason) + }) + this._otelMetricsProvider = null + } + // Allow a new Agent instance to `.start()`. Typically this is only relevant // for tests that may use multiple Agent instances in a single test process. global[symbols.agentInitialized] = null @@ -278,7 +291,10 @@ Agent.prototype.start = function (opts) { setupOTelBridge(this) } this._instrumentation.start(runContextClass) - this._metrics.start() + + if (this._isMetricsEnabled()) { + this._metrics.start() + } Error.stackTraceLimit = this._conf.stackTraceLimit if (this._conf.captureExceptions) this.handleUncaughtExceptions() @@ -286,6 +302,34 @@ Agent.prototype.start = function (opts) { return this } +Agent.prototype._isMetricsEnabled = function () { + return this._conf.metricsInterval !== 0 && !this._conf.contextPropagationOnly +} + +/** + * Lazily create a singleton OTel MeterProvider that periodically exports + * metrics to APM server. This may return null if the MeterProvider is + * unsupported for this node version, metrics are disabled, etc. + * + * @returns {import('@opentelemetry/api').MeterProvider | null} + */ +Agent.prototype._getOrCreateOTelMeterProvider = function () { + if (this._otelMeterProvider) { + return this._otelMeterProvider + } + + if (!this._isMetricsEnabled()) { + return null + } + if (!isOTelMetricsFeatSupported) { + return null + } + + this.logger.trace('create Elastic APM MeterProvider for @opentelemetry/api') + this._otelMeterProvider = createOTelMeterProvider(this) + return this._otelMeterProvider +} + Agent.prototype.getServiceName = function () { return this._conf ? this._conf.serviceName : undefined } @@ -646,7 +690,7 @@ Agent.prototype.handleUncaughtExceptions = function (cb) { // flush *everything* that has come before). However it handles the common use // case of flushing synchronously after ending a span or capturing an error: // mySpan.end() -// apm.flush(function () { ... }) +// await apm.flush() // and it simplifies the implementation. // // # Dev Notes @@ -749,3 +793,19 @@ Agent.prototype.registerMetric = function (name, labelsOrCallback, callback) { this._metrics.getOrCreateGauge(name, callback, labels) } + +/** + * Return true iff the given metric name is "disabled", according to the + * `disableMetrics` config var. + * + * @returns {boolean} + */ +Agent.prototype._isMetricNameDisabled = function (name) { + const regexps = this._conf.disableMetricsRegExp + for (var i = 0; i < regexps.length; i++) { + if (regexps[i].test(name)) { + return true + } + } + return false +} diff --git a/lib/config.js b/lib/config.js index 8fff25c209..d8550b77fb 100644 --- a/lib/config.js +++ b/lib/config.js @@ -53,7 +53,26 @@ var DEFAULTS = { // because normalizeContextManager() needs to know if a value was provided by // the user. contextPropagationOnly: false, + // Exponential powers-of-2 bucket boundaries, rounded to 6 significant figures. + // 2**N for N in [-8, -7.5, -7, ..., 16, 16.5, 17] + // https://github.com/elastic/apm/blob/main/specs/agents/metrics-otel.md#histogram-aggregation + customMetricsHistogramBoundaries: [ + 0.00390625, 0.00552427, 0.0078125, 0.0110485, + 0.015625, 0.0220971, 0.03125, 0.0441942, + 0.0625, 0.0883883, 0.125, 0.176777, + 0.25, 0.353553, 0.5, 0.707107, + 1, 1.41421, 2, 2.82843, + 4, 5.65685, 8, 11.3137, + 16, 22.6274, 32, 45.2548, + 64, 90.5097, 128, 181.019, + 256, 362.039, 512, 724.077, + 1024, 1448.15, 2048, 2896.31, + 4096, 5792.62, 8192, 11585.2, + 16384, 23170.5, 32768, 46341, + 65536, 92681.9, 131072 + ], disableInstrumentations: [], + disableMetrics: [], disableSend: false, elasticsearchCaptureBodyUrls: [ '*/_search', '*/_search/template', '*/_msearch', '*/_msearch/template', @@ -131,7 +150,9 @@ var ENV_TABLE = { containerId: 'ELASTIC_APM_CONTAINER_ID', contextManager: 'ELASTIC_APM_CONTEXT_MANAGER', contextPropagationOnly: 'ELASTIC_APM_CONTEXT_PROPAGATION_ONLY', + customMetricsHistogramBoundaries: 'ELASTIC_APM_CUSTOM_METRICS_HISTOGRAM_BOUNDARIES', disableInstrumentations: 'ELASTIC_APM_DISABLE_INSTRUMENTATIONS', + disableMetrics: 'ELASTIC_APM_DISABLE_METRICS', disableSend: 'ELASTIC_APM_DISABLE_SEND', environment: 'ELASTIC_APM_ENVIRONMENT', exitSpanMinDuration: 'ELASTIC_APM_EXIT_SPAN_MIN_DURATION', @@ -301,6 +322,7 @@ var MINUS_ONE_EQUAL_INFINITY = [ var ARRAY_OPTS = [ 'disableInstrumentations', + 'disableMetrics', 'elasticsearchCaptureBodyUrls', 'sanitizeFieldNames', 'transactionIgnoreUrls', @@ -345,6 +367,7 @@ function initialConfig (logger) { const cfg = Object.assign({}, DEFAULTS) // Reproduce the generated properties for `Config`. + cfg.disableMetricsRegExp = [] cfg.ignoreUrlStr = [] cfg.ignoreUrlRegExp = [] cfg.ignoreUserAgentStr = [] @@ -366,6 +389,7 @@ function createConfig (opts, logger) { class Config { constructor (opts, logger) { + this.disableMetricsRegExp = [] this.ignoreUrlStr = [] this.ignoreUrlRegExp = [] this.ignoreUserAgentStr = [] @@ -565,6 +589,7 @@ class Config { serverUrl: true } const NICE_REGEXPS_FIELDS = { + disableMetricsRegExp: true, ignoreUrlRegExp: true, ignoreUserAgentRegExp: true, transactionIgnoreUrlRegExp: true, @@ -734,11 +759,13 @@ function normalize (opts, logger) { normalizeBools(opts, logger) normalizeIgnoreOptions(opts) normalizeElasticsearchCaptureBodyUrls(opts) + normalizeDisableMetrics(opts) normalizeSanitizeFieldNames(opts) normalizeContextManager(opts, logger) // Must be after normalizeBools(). normalizeCloudProvider(opts, logger) normalizeTransactionSampleRate(opts, logger) normalizeTraceContinuationStrategy(opts, logger) + normalizeCustomMetricsHistogramBoundaries(opts, logger) // This must be after `normalizeDurationOptions()` and `normalizeBools()` // because it synthesizes the deprecated `spanFramesMinDuration` and @@ -748,6 +775,41 @@ function normalize (opts, logger) { truncateOptions(opts) } +// `customMetricsHistogramBoundaries` must be a sorted array of numbers, +// without duplicates. +function normalizeCustomMetricsHistogramBoundaries (opts, logger) { + if (!('customMetricsHistogramBoundaries' in opts)) { + return + } + let val = opts.customMetricsHistogramBoundaries + if (typeof val === 'string') { + val = val.split(',').map(v => Number(v.trim())) + } + let errReason = null + if (!Array.isArray(val)) { + errReason = 'value is not an array' + } else if (val.some(el => typeof el !== 'number' || isNaN(el))) { + errReason = 'array includes non-numbers' + } else { + for (let i = 0; i < val.length - 1; i++) { + if (val[i] === val[i + 1]) { + errReason = 'array has duplicate values' + break + } else if (val[i] > val[i + 1]) { + errReason = 'array is not sorted' + break + } + } + } + if (errReason) { + logger.warn('Invalid "customMetricsHistogramBoundaries" config value %j, %s; falling back to default', + opts.customMetricsHistogramBoundaries, errReason) + opts.customMetricsHistogramBoundaries = DEFAULTS.customMetricsHistogramBoundaries + } else { + opts.customMetricsHistogramBoundaries = val + } +} + const ALLOWED_TRACE_CONTINUATION_STRATEGY = { [TRACE_CONTINUATION_STRATEGY_CONTINUE]: true, [TRACE_CONTINUATION_STRATEGY_RESTART]: true, @@ -907,6 +969,16 @@ function normalizeSanitizeFieldNames (opts) { } } +function normalizeDisableMetrics (opts) { + if (opts.disableMetrics) { + const wildcard = new WildcardMatcher() + for (const ptn of opts.disableMetrics) { + const re = wildcard.compile(ptn) + opts.disableMetricsRegExp.push(re) + } + } +} + function normalizeCloudProvider (opts, logger) { if ('cloudProvider' in opts) { const allowedValues = ['auto', 'gcp', 'azure', 'aws', 'none'] diff --git a/lib/instrumentation/index.js b/lib/instrumentation/index.js index eea471fc05..c8605e05fc 100644 --- a/lib/instrumentation/index.js +++ b/lib/instrumentation/index.js @@ -34,6 +34,8 @@ var MODULES = [ ['@elastic/elasticsearch', '@elastic/elasticsearch-canary'], '@node-redis/client/dist/lib/client', '@node-redis/client/dist/lib/client/commands-queue', + '@opentelemetry/api', + '@opentelemetry/sdk-metrics', '@redis/client/dist/lib/client', '@redis/client/dist/lib/client/commands-queue', 'apollo-server-core', diff --git a/lib/instrumentation/modules/@opentelemetry/api.js b/lib/instrumentation/modules/@opentelemetry/api.js new file mode 100644 index 0000000000..673d46cba8 --- /dev/null +++ b/lib/instrumentation/modules/@opentelemetry/api.js @@ -0,0 +1,71 @@ +/* + * Copyright Elasticsearch B.V. and other contributors where applicable. + * Licensed under the BSD 2-Clause License; you may not use this file except in + * compliance with the BSD 2-Clause License. + */ + +'use strict' + +// Instrument `.metrics.getMeterProvider()` from `@opentelemetry/api` to +// provide an Elastic APM provider if user code hasn't registered one itself. +// +// This covers use case 2 in the OTel metrics spec: +// https://github.com/elastic/apm/blob/main/specs/agents/metrics-otel.md#exporter-installation + +const semver = require('semver') + +const { isOTelMetricsFeatSupported } = require('../../../opentelemetry-metrics') +const shimmer = require('../../shimmer') + +/** + * `otel.metrics.getMeterProvider()` returns a singleton instance of the + * internal `NoopMeterProvider` class if no global meter provider has been + * set. There isn't an explicitly API to determine if a provider is a noop + * one. This function attempts to sniff that out. + * + * We cannot rely on comparing to the `NOOP_METER_PROVIDER` exported by + * "src/metrics/NoopMeterProvider.ts" because there might be multiple + * "@opentelemetry/api" packages in play. + * + * @param {import('@opentelemetry/api').MeterProvider} + * @returns {boolean} + */ +function isNoopMeterProvider (provider) { + return !!(provider && provider.constructor && provider.constructor.name === 'NoopMeterProvider') +} + +module.exports = function (mod, agent, { version, enabled }) { + const log = agent.logger + + if (!enabled) { + return mod + } + if (!agent._isMetricsEnabled()) { + log.trace('metrics are not enabled, skipping @opentelemetry/api instrumentation', version) + return mod + } + if (!semver.satisfies(version, '>=1.3.0 <2', { includePrerelease: true })) { + log.debug('@opentelemetry/api version %s not supported, skipping @opentelemetry/api instrumentation', version) + return mod + } + if (!isOTelMetricsFeatSupported) { + log.debug('elastic-apm-node OTel Metrics support does not support node %s, skipping @opentelemetry/api instrumentation', process.version) + return mod + } + + log.debug('shimming @opentelemetry/api .metrics.getMeterProvider()') + shimmer.wrap(mod.metrics, 'getMeterProvider', wrapGetMeterProvider) + + return mod + + function wrapGetMeterProvider (orig) { + return function wrappedGetMeterProvider () { + const provider = orig.apply(this, arguments) + if (!isNoopMeterProvider(provider)) { + return provider + } + const elMeterProvider = agent._getOrCreateOTelMeterProvider() + return elMeterProvider || provider + } + } +} diff --git a/lib/instrumentation/modules/@opentelemetry/sdk-metrics.js b/lib/instrumentation/modules/@opentelemetry/sdk-metrics.js new file mode 100644 index 0000000000..8594afd613 --- /dev/null +++ b/lib/instrumentation/modules/@opentelemetry/sdk-metrics.js @@ -0,0 +1,59 @@ +/* + * Copyright Elasticsearch B.V. and other contributors where applicable. + * Licensed under the BSD 2-Clause License; you may not use this file except in + * compliance with the BSD 2-Clause License. + */ + +'use strict' + +// This instruments '@opentelemetry/sdk-metrics' to automatically add a metric +// reader to any `MeterProvider` created by user code. The added metric +// reader will export metrics to the configured APM server. +// +// This covers use case 1 in the OTel metrics spec: +// https://github.com/elastic/apm/blob/main/specs/agents/metrics-otel.md#exporter-installation +// +// Dev Note: This avoids instrumenting the `MeterProvider` used *internally* +// by the APM agent itself (see "lib/opentelemetry-metrics/index.js") because +// that file imports `MeterProvider` before the APM agent is started. + +const semver = require('semver') + +const { isOTelMetricsFeatSupported, createOTelMetricReader } = require('../../../opentelemetry-metrics') + +module.exports = function (mod, agent, { version, enabled }) { + const log = agent.logger + + if (!enabled) { + return mod + } + if (!agent._isMetricsEnabled()) { + log.trace('metrics are not enabled, skipping @opentelemetry/sdk-metrics instrumentation', version) + return mod + } + // Minimum supported version is 1.11.0 because that version included the fix + // for side-effects from having two MetricReaders. + // https://github.com/open-telemetry/opentelemetry-js/issues/3664 + if (!semver.satisfies(version, '>=1.11.0 <2', { includePrerelease: true })) { + log.debug('@opentelemetry/sdk-metrics@%s is not supported, skipping @opentelemetry/sdk-metrics instrumentation', version) + return mod + } + if (!isOTelMetricsFeatSupported) { + log.debug('elastic-apm-node OTel Metrics feature does not support node %s, skipping @opentelemetry/sdk-metrics instrumentation', process.version) + return mod + } + + class ApmMeterProvider extends mod.MeterProvider { + constructor (...args) { + super(...args) + // We create a new metric reader for each new MeterProvider instance, + // because they shutdown independently -- they cannot be shared between + // multiple MeterProviders. + log.trace('@opentelemetry/sdk-metrics ins: create Elastic APM MetricReader') + this.addMetricReader(createOTelMetricReader(agent)) + } + } + Object.defineProperty(mod, 'MeterProvider', { configurable: true, enumerable: true, get: function () { return ApmMeterProvider } }) + + return mod +} diff --git a/lib/metrics/index.js b/lib/metrics/index.js index 5cbeedd731..ea0d445c6f 100644 --- a/lib/metrics/index.js +++ b/lib/metrics/index.js @@ -6,6 +6,8 @@ 'use strict' +const assert = require('assert') + const MetricsRegistry = require('./registry') const { createQueueMetrics } = require('./queue') @@ -29,17 +31,15 @@ class Metrics { start (refTimers) { const metricsInterval = this[agentSymbol]._conf.metricsInterval - const enabled = metricsInterval !== 0 && !this[agentSymbol]._conf.contextPropagationOnly - if (enabled) { - this[registrySymbol] = new MetricsRegistry(this[agentSymbol], { - reporterOptions: { - defaultReportingIntervalInSeconds: metricsInterval, - enabled: enabled, - unrefTimers: !refTimers, - logger: new NoopLogger() - } - }) - } + assert(metricsInterval > 0, 'Metrics.start() should not be called if metricsInterval <= 0') + this[registrySymbol] = new MetricsRegistry(this[agentSymbol], { + reporterOptions: { + defaultReportingIntervalInSeconds: metricsInterval, + enabled: true, + unrefTimers: !refTimers, + logger: new NoopLogger() + } + }) } stop () { diff --git a/lib/metrics/reporter.js b/lib/metrics/reporter.js index 83efa5c7de..20e56db5fc 100644 --- a/lib/metrics/reporter.js +++ b/lib/metrics/reporter.js @@ -36,6 +36,9 @@ class MetricsReporter extends Reporter { // Due to limitations in measured-reporting, metrics dropped // due to `metricsLimit` leave empty slots in the list. if (!metric) continue + if (this._agent._isMetricNameDisabled(metric.name)) { + continue + } if (this.isStaleMetric(metric)) { this.removeMetricFromRegistry(metric, this._registry) diff --git a/lib/opentelemetry-metrics/ElasticApmMetricExporter.js b/lib/opentelemetry-metrics/ElasticApmMetricExporter.js new file mode 100644 index 0000000000..7c346cfb96 --- /dev/null +++ b/lib/opentelemetry-metrics/ElasticApmMetricExporter.js @@ -0,0 +1,267 @@ +/* + * Copyright Elasticsearch B.V. and other contributors where applicable. + * Licensed under the BSD 2-Clause License; you may not use this file except in + * compliance with the BSD 2-Clause License. + */ + +const { ExportResultCode } = require('@opentelemetry/core') +const { + AggregationTemporality, + InstrumentType, + ExplicitBucketHistogramAggregation, + SumAggregation, + LastValueAggregation, + DropAggregation, + DataPointType +} = require('@opentelemetry/sdk-metrics') +const LRU = require('lru-cache') + +/** + * The `timestamp` in a metricset for APM Server intake is "UTC based and + * formatted as microseconds since Unix epoch". + * + * Dev note: We need to round because APM server intake requires an integer. + * This means a loss of sub-ms precision, which for this use case is fine. + */ +function metricTimestampFromOTelHrTime (otelHrTime) { + // OTel's HrTime is `[, ]` + return Math.round(otelHrTime[0] * 1e6 + otelHrTime[1] / 1e3) +} + +// From oteljs/packages/sdk-metrics/src/utils.ts#hashAttributes +function hashAttributes (attributes) { + let keys = Object.keys(attributes) + if (keys.length === 0) return '' + + // Return a string that is stable on key orders. + keys = keys.sort() + return JSON.stringify(keys.map(key => [key, attributes[key]])) +} + +/** + * Fill in an Intake V2 API "sample" object for a histogram from an OTel Metrics + * histogram DataPoint. + * + * Algorithm is from the spec (`convertBucketBoundaries`) + */ +function fillIntakeHistogramSample (sample, otelDataPoint) { + const otelCounts = otelDataPoint.value.buckets.counts + const otelBoundaries = otelDataPoint.value.buckets.boundaries + + const bucketCount = otelCounts.length + if (bucketCount === 0) { + return + } + + const intakeCounts = sample.counts = [] + const intakeValues = sample.values = [] + sample.type = 'histogram' + + // otelBoundaries has a size of bucketCount-1 + // the first bucket has the boundaries ( -inf, otelBoundaries[0] ] + // the second bucket has the boundaries ( otelBoundaries[0], otelBoundaries[1] ] + // .. + // the last bucket has the boundaries (otelBoundaries[bucketCount-2], inf) + for (let i = 0; i < bucketCount; i++) { + if (otelCounts[i] !== 0) { // ignore empty buckets + intakeCounts.push(otelCounts[i]) + if (i === 0) { // first bucket + let bound = otelBoundaries[i] + if (bound > 0) { + bound /= 2 + } + intakeValues.push(bound) + } else if (i === bucketCount - 1) { // last bucket + intakeValues.push(otelBoundaries[bucketCount - 2]) + } else { // in between + const lower = otelBoundaries[i - 1] + const upper = otelBoundaries[i] + intakeValues.push(lower + (upper - lower) / 2) + } + } + } +} + +/** + * A PushMetricExporter that exports to an Elastic APM server. It is meant to be + * used with a PeriodicExportingMetricReader -- which defers to + * `selectAggregation` and `selectAggregationTemporality` on this class. + * + * @implements {import('@opentelemetry/sdk-metrics').PushMetricExporter} + */ +class ElasticApmMetricExporter { + constructor (agent) { + this._agent = agent + this._histogramAggregation = new ExplicitBucketHistogramAggregation( + this._agent._conf.customMetricsHistogramBoundaries) + this._sumAggregation = new SumAggregation() + this._lastValueAggregation = new LastValueAggregation() + this._dropAggregation = new DropAggregation() + this._attrDropWarnCache = new LRU({ max: 1000 }) + this._dataPointTypeDropWarnCache = new LRU({ max: 1000 }) + } + + /** + * Spec: https://github.com/elastic/apm/blob/main/specs/agents/metrics-otel.md#aggregation + * + * @param {import('@opentelemetry/sdk-metrics').InstrumentType} instrumentType + * @returns {import('@opentelemetry/sdk-metrics').Aggregation} + */ + selectAggregation (instrumentType) { + // The same behaviour as OTel's `DefaultAggregation`, except for changes + // to the default Histogram bucket sizes and support for the + // `custom_metrics_histogram_boundaries` config var. + switch (instrumentType) { + case InstrumentType.COUNTER: + case InstrumentType.UP_DOWN_COUNTER: + case InstrumentType.OBSERVABLE_COUNTER: + case InstrumentType.OBSERVABLE_UP_DOWN_COUNTER: + return this._sumAggregation + case InstrumentType.OBSERVABLE_GAUGE: + return this._lastValueAggregation + case InstrumentType.HISTOGRAM: + return this._histogramAggregation + default: + this._agent.logger.warn(`cannot selectAggregation: unknown OTel Metric instrumentType: ${instrumentType}`) + return this._dropAggregation + } + } + + // Spec: https://github.com/elastic/apm/blob/main/specs/agents/metrics-otel.md#aggregation-temporality + // + // Note: This differs from the OTel SDK default. + // `OTEL_EXPORTER_OTLP_METRICS_TEMPORALITY_PREFERENCE=Cumulative` + // https://opentelemetry.io/docs/reference/specification/metrics/sdk_exporters/otlp/ + selectAggregationTemporality (instrumentType) { + switch (instrumentType) { + case InstrumentType.COUNTER: + case InstrumentType.OBSERVABLE_COUNTER: + case InstrumentType.HISTOGRAM: + case InstrumentType.OBSERVABLE_GAUGE: + return AggregationTemporality.DELTA + case InstrumentType.UP_DOWN_COUNTER: + case InstrumentType.OBSERVABLE_UP_DOWN_COUNTER: + return AggregationTemporality.CUMULATIVE + } + } + + async forceFlush () { + return this._agent.flush() + } + + async shutdown () { + return this._agent.flush() + } + + /** + * Export an OTel `ResourceMetrics` to Elastic APM intake `metricset`s. + * + * Dev notes: + * - Explicitly *not* including `metricData.descriptor.unit` because the APM + * spec doesn't include it. It isn't clear there is value. + */ + export (resourceMetrics, resultCallback) { + // console.log('resourceMetrics:'); console.dir(resourceMetrics, { depth: 10 }) + for (const scopeMetrics of resourceMetrics.scopeMetrics) { + // Metrics from separate instrumentation scopes must be in separate + // `metricset` objects. In the future, the APM spec may dictate that we + // add labels for the instrumentation scope -- perhaps `otel.scope.*`. + // Discussion: https://github.com/elastic/apm/pull/742#discussion_r1061444699 + const metricsetFromAttrHash = {} + + for (const metricData of scopeMetrics.metrics) { + const metricName = metricData.descriptor.name + if (this._agent._isMetricNameDisabled(metricName)) { + continue + } + if (!(metricData.dataPointType === DataPointType.GAUGE || + metricData.dataPointType === DataPointType.SUM || + metricData.dataPointType === DataPointType.HISTOGRAM)) { + if (!this._dataPointTypeDropWarnCache.has(metricName)) { + this._agent.logger.warn(`dropping metric "${metricName}": cannot export metrics with dataPointType=${metricData.dataPointType}`) + this._dataPointTypeDropWarnCache.set(metricName, true) + } + } + + for (const dataPoint of metricData.dataPoints) { + const labels = this._labelsFromOTelMetricAttributes(dataPoint.attributes, metricData.descriptor.name) + const attrHash = hashAttributes(labels) + let metricset = metricsetFromAttrHash[attrHash] + if (!metricset) { + metricset = { + samples: {}, + // Assumption: `endTime` is the same for all `dataPoint`s in + // this `metricData`. + timestamp: metricTimestampFromOTelHrTime(dataPoint.endTime), + tags: labels + } + metricsetFromAttrHash[attrHash] = metricset + } + const sample = {} + switch (metricData.dataPointType) { + case DataPointType.GAUGE: + sample.type = 'gauge' + sample.value = dataPoint.value + break + case DataPointType.SUM: + if (metricData.isMonotonic) { + sample.type = 'counter' + } else { + sample.type = 'gauge' + } + sample.value = dataPoint.value + break + case DataPointType.HISTOGRAM: + fillIntakeHistogramSample(sample, dataPoint) + break + } + if (sample.type) { + metricset.samples[metricData.descriptor.name] = sample + } + } + } + + // Importantly, if a metric has no `dataPoints` then we send nothing. This + // satisfies the following from the APM agents spec: + // + // > For all instrument types with delta temporality, agents MUST filter out + // > zero values before exporting. + Object.values(metricsetFromAttrHash).forEach(metricset => { + this._agent._transport.sendMetricSet(metricset) + }) + } + + return resultCallback({ code: ExportResultCode.SUCCESS }) + } + + /** + * Convert from `dataPoint.attributes` to a set of labels (a.k.a. tags) for + * Elastic APM intake. Attributes with an *array* value are not supported -- + * they are dropped with a log.warn that mentions the metric and attribute + * names. + * + * This makes *in-place* changes to the given `attrs` argument. It returns + * the same object. + * + * https://github.com/elastic/apm/blob/main/specs/agents/metrics-otel.md#labels + */ + _labelsFromOTelMetricAttributes (attrs, metricName) { + const keys = Object.keys(attrs) + for (var i = 0; i < keys.length; i++) { + const k = keys[i] + const v = attrs[k] + if (Array.isArray(v)) { + delete attrs[k] + const cacheKey = metricName + '/' + k + if (!this._attrDropWarnCache.has(cacheKey)) { + this._agent.logger.warn({ metricName, attrName: k }, + 'dropping array-valued metric attribute: array attribute values are not supported') + this._attrDropWarnCache.set(cacheKey, true) + } + } + } + return attrs + } +} + +module.exports = ElasticApmMetricExporter diff --git a/lib/opentelemetry-metrics/index.js b/lib/opentelemetry-metrics/index.js new file mode 100644 index 0000000000..e8c3e4b6b9 --- /dev/null +++ b/lib/opentelemetry-metrics/index.js @@ -0,0 +1,40 @@ +/* + * Copyright Elasticsearch B.V. and other contributors where applicable. + * Licensed under the BSD 2-Clause License; you may not use this file except in + * compliance with the BSD 2-Clause License. + */ + +const assert = require('assert') + +const { MeterProvider, PeriodicExportingMetricReader } = require('@opentelemetry/sdk-metrics') +const semver = require('semver') + +const ElasticApmMetricExporter = require('./ElasticApmMetricExporter') + +// `isOTelMetricsFeatSupported` is true if the agent's included OTel Metrics +// feature is supported. Currently this depends on the Node.js version supported +// by the Metrics SDK package. +const _supportRange = require('@opentelemetry/sdk-metrics/package.json').engines.node +const isOTelMetricsFeatSupported = semver.satisfies(process.version, _supportRange) + +function createOTelMetricReader (agent) { + const metricsInterval = agent._conf.metricsInterval + assert(metricsInterval > 0, 'createOTelMeterProvider() should not be called if metricsInterval <= 0') + return new PeriodicExportingMetricReader({ + exporter: new ElasticApmMetricExporter(agent), + exportIntervalMillis: metricsInterval * 1000, + exportTimeoutMillis: metricsInterval / 2 * 1000 + }) +} + +function createOTelMeterProvider (agent) { + const meterProvider = new MeterProvider() + meterProvider.addMetricReader(createOTelMetricReader(agent)) + return meterProvider +} + +module.exports = { + isOTelMetricsFeatSupported, + createOTelMetricReader, + createOTelMeterProvider +} diff --git a/package-lock.json b/package-lock.json index 368998b779..ac857c4e5c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,8 @@ "dependencies": { "@elastic/ecs-pino-format": "^1.2.0", "@opentelemetry/api": "^1.4.1", + "@opentelemetry/core": "^1.11.0", + "@opentelemetry/sdk-metrics": "^1.12.0", "after-all-results": "^2.0.0", "async-cache": "^1.1.0", "async-value-promise": "^1.1.1", @@ -3400,6 +3402,59 @@ "node": ">=8.0.0" } }, + "node_modules/@opentelemetry/core": { + "version": "1.12.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-1.12.0.tgz", + "integrity": "sha512-4DWYNb3dLs2mSCGl65jY3aEgbvPWSHVQV/dmDWiYeWUrMakZQFcymqZOSUNZO0uDrEJoxMu8O5tZktX6UKFwag==", + "dependencies": { + "@opentelemetry/semantic-conventions": "1.12.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.5.0" + } + }, + "node_modules/@opentelemetry/resources": { + "version": "1.12.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-1.12.0.tgz", + "integrity": "sha512-gunMKXG0hJrR0LXrqh7BVbziA/+iJBL3ZbXCXO64uY+SrExkwoyJkpiq9l5ismkGF/A20mDEV7tGwh+KyPw00Q==", + "dependencies": { + "@opentelemetry/core": "1.12.0", + "@opentelemetry/semantic-conventions": "1.12.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.5.0" + } + }, + "node_modules/@opentelemetry/sdk-metrics": { + "version": "1.12.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-1.12.0.tgz", + "integrity": "sha512-zOy88Jfk88eTxqu+9ypHLs184dGydJocSWtvWMY10QKVVaxhC3SLKa0uxI/zBtD9S+x0LP65wxrTSfSoUNtCOA==", + "dependencies": { + "@opentelemetry/core": "1.12.0", + "@opentelemetry/resources": "1.12.0", + "lodash.merge": "4.6.2" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.5.0" + } + }, + "node_modules/@opentelemetry/semantic-conventions": { + "version": "1.12.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.12.0.tgz", + "integrity": "sha512-hO+bdeGOlJwqowUBoZF5LyP3ORUFOP1G0GRv8N45W/cztXbT2ZEXaAzfokRS9Xc9FWmYrDj32mF6SzH6wuoIyA==", + "engines": { + "node": ">=14" + } + }, "node_modules/@protobufjs/aspromise": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", @@ -10217,6 +10272,11 @@ "integrity": "sha1-1SfftUVuynzJu5XV2ur4i6VKVFE=", "dev": true }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==" + }, "node_modules/lodash.sortby": { "version": "4.7.0", "resolved": "https://registry.npmjs.org/lodash.sortby/-/lodash.sortby-4.7.0.tgz", @@ -18011,6 +18071,38 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.4.1.tgz", "integrity": "sha512-O2yRJce1GOc6PAy3QxFM4NzFiWzvScDC1/5ihYBL6BUEVdq0XMWN01sppE+H6bBXbaFYipjwFLEWLg5PaSOThA==" }, + "@opentelemetry/core": { + "version": "1.12.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-1.12.0.tgz", + "integrity": "sha512-4DWYNb3dLs2mSCGl65jY3aEgbvPWSHVQV/dmDWiYeWUrMakZQFcymqZOSUNZO0uDrEJoxMu8O5tZktX6UKFwag==", + "requires": { + "@opentelemetry/semantic-conventions": "1.12.0" + } + }, + "@opentelemetry/resources": { + "version": "1.12.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-1.12.0.tgz", + "integrity": "sha512-gunMKXG0hJrR0LXrqh7BVbziA/+iJBL3ZbXCXO64uY+SrExkwoyJkpiq9l5ismkGF/A20mDEV7tGwh+KyPw00Q==", + "requires": { + "@opentelemetry/core": "1.12.0", + "@opentelemetry/semantic-conventions": "1.12.0" + } + }, + "@opentelemetry/sdk-metrics": { + "version": "1.12.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-1.12.0.tgz", + "integrity": "sha512-zOy88Jfk88eTxqu+9ypHLs184dGydJocSWtvWMY10QKVVaxhC3SLKa0uxI/zBtD9S+x0LP65wxrTSfSoUNtCOA==", + "requires": { + "@opentelemetry/core": "1.12.0", + "@opentelemetry/resources": "1.12.0", + "lodash.merge": "4.6.2" + } + }, + "@opentelemetry/semantic-conventions": { + "version": "1.12.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.12.0.tgz", + "integrity": "sha512-hO+bdeGOlJwqowUBoZF5LyP3ORUFOP1G0GRv8N45W/cztXbT2ZEXaAzfokRS9Xc9FWmYrDj32mF6SzH6wuoIyA==" + }, "@protobufjs/aspromise": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", @@ -23342,6 +23434,11 @@ "integrity": "sha1-1SfftUVuynzJu5XV2ur4i6VKVFE=", "dev": true }, + "lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==" + }, "lodash.sortby": { "version": "4.7.0", "resolved": "https://registry.npmjs.org/lodash.sortby/-/lodash.sortby-4.7.0.tgz", diff --git a/package.json b/package.json index 9221013039..164c354a66 100644 --- a/package.json +++ b/package.json @@ -15,8 +15,8 @@ "lint:yaml-files": "./dev-utils/lint-yaml-files.sh # requires node >=10", "coverage": "COVERAGE=true ./test/script/run_tests.sh", "test": "./test/script/run_tests.sh", - "test:deps": "dependency-check index.js start.js start-next.js 'lib/**/*.js' 'test/**/*.js' '!test/activation-method/fixtures' '!test/instrumentation/azure-functions/fixtures' '!test/instrumentation/modules/next/a-nextjs-app' --no-dev -i async_hooks -i perf_hooks -i node:http -i @azure/functions-core", - "test:tav": "tav --quiet && (cd test/instrumentation/modules/next/a-nextjs-app && tav --quiet)", + "test:deps": "dependency-check index.js start.js start-next.js 'lib/**/*.js' 'test/**/*.js' '!test/activation-method/fixtures' '!test/instrumentation/azure-functions/fixtures' '!test/instrumentation/modules/next/a-nextjs-app' '!test/opentelemetry-bridge' '!test/opentelemetry-metrics/fixtures' --no-dev -i async_hooks -i perf_hooks -i node:http -i @azure/functions-core", + "test:tav": "(cd test/opentelemetry-metrics/fixtures && tav --quiet) && (cd test/opentelemetry-bridge && tav --quiet) && (cd test/instrumentation/modules/next/a-nextjs-app && tav --quiet) && tav --quiet", "test:docs": "./test/script/docker/run_docs.sh", "test:types": "tsc --project test/types/tsconfig.json && tsc --project test/types/transpile/tsconfig.json && node test/types/transpile/index.js && tsc --project test/types/transpile-default/tsconfig.json && node test/types/transpile-default/index.js # requires node >=12.20", "test:babel": "babel test/babel/src.js --out-file test/babel/out.js && cd test/babel && node out.js", @@ -88,6 +88,8 @@ "dependencies": { "@elastic/ecs-pino-format": "^1.2.0", "@opentelemetry/api": "^1.4.1", + "@opentelemetry/core": "^1.11.0", + "@opentelemetry/sdk-metrics": "^1.12.0", "after-all-results": "^2.0.0", "async-cache": "^1.1.0", "async-value-promise": "^1.1.1", diff --git a/test/_utils.js b/test/_utils.js index 8b9085f7d1..99aaa5184b 100644 --- a/test/_utils.js +++ b/test/_utils.js @@ -27,21 +27,48 @@ function dottedLookup (obj, str) { return o } -// Return the first element in the array that has a `key` with the given `val`. +// Return the first element in the array that has a `key` with the given `val`; +// or if `val` is undefined, then the first element with any value for the given +// `key`. // // The `key` maybe a nested field given in dot-notation, for example: // 'context.db.statement'. function findObjInArray (arr, key, val) { let result = null arr.some(function (elm) { - if (dottedLookup(elm, key) === val) { - result = elm - return true + const actualVal = dottedLookup(elm, key) + if (val === undefined) { + if (actualVal !== undefined) { + result = elm + return true + } + } else { + if (actualVal === val) { + result = elm + return true + } } }) return result } +// Same as `findObjInArray` but return all matches instead of just the first. +function findObjsInArray (arr, key, val) { + return arr.filter(function (elm) { + const actualVal = dottedLookup(elm, key) + if (val === undefined) { + if (actualVal !== undefined) { + return true + } + } else { + if (actualVal === val) { + return true + } + } + return false + }) +} + // "Safely" get the version of the given package, if possible. Otherwise return // null. // @@ -93,6 +120,7 @@ function formatForTComment (data) { module.exports = { dottedLookup, findObjInArray, + findObjsInArray, formatForTComment, safeGetPackageVersion } diff --git a/test/config.test.js b/test/config.test.js index 3e47bbecc2..525f2f747f 100644 --- a/test/config.test.js +++ b/test/config.test.js @@ -125,6 +125,7 @@ var optionFixtures = [ ['centralConfig', 'CENTRAL_CONFIG', true], ['containerId', 'CONTAINER_ID'], ['contextPropagationOnly', 'CONTEXT_PROPAGATION_ONLY', false], + ['customMetricsHistogramBoundaries', 'CUSTOM_METRICS_HISTOGRAM_BOUNDARIES', config.DEFAULTS.customMetricsHistogramBoundaries.slice()], ['disableSend', 'DISABLE_SEND', false], ['disableInstrumentations', 'DISABLE_INSTRUMENTATIONS', []], ['environment', 'ENVIRONMENT', 'development'], @@ -179,6 +180,8 @@ optionFixtures.forEach(function (fixture) { } else if (fixture[0] === 'serverCaCertFile') { // special case for files, so a temp file can be written type = 'file' + } else if (fixture[0] === 'customMetricsHistogramBoundaries') { + type = 'customMetricsHistogramBoundaries' } else if (fixture[0] === 'traceContinuationStrategy') { type = 'traceContinuationStrategy' } else if (typeof fixture[2] === 'number' || fixture[0] === 'errorMessageMaxLength') { @@ -214,6 +217,9 @@ optionFixtures.forEach(function (fixture) { fs.writeFileSync(tmpfile, tmpfile) value = tmpfile break + case 'customMetricsHistogramBoundaries': + value = [1, 2, 3] // a valid non-default value + break case 'traceContinuationStrategy': value = 'restart' // a valid non-default value break @@ -239,7 +245,7 @@ optionFixtures.forEach(function (fixture) { t.deepEqual(agent._conf[fixture[0]], value) break default: - t.strictEqual(agent._conf[fixture[0]], value) + t.deepEqual(agent._conf[fixture[0]], value) } // Restore process.env state. @@ -284,6 +290,10 @@ optionFixtures.forEach(function (fixture) { value1 = ['overwriting-value'] value2 = ['custom-value'] break + case 'customMetricsHistogramBoundaries': + value1 = [1, 2, 3, 4] + value2 = [1, 5, 10, 50, 100] + break case 'traceContinuationStrategy': value1 = 'restart' value2 = 'continue' @@ -303,6 +313,7 @@ optionFixtures.forEach(function (fixture) { switch (type) { case 'array': + case 'customMetricsHistogramBoundaries': t.deepEqual(agent._conf[fixture[0]], value2) break default: @@ -333,6 +344,7 @@ optionFixtures.forEach(function (fixture) { switch (type) { case 'array': + case 'customMetricsHistogramBoundaries': t.deepEqual(agent._conf[fixture[0]], fixture[2]) break default: diff --git a/test/metrics/index.test.js b/test/metrics/index.test.js index 3f8f183c18..6b40933838 100644 --- a/test/metrics/index.test.js +++ b/test/metrics/index.test.js @@ -24,6 +24,9 @@ function mockAgent (conf = {}, onMetricSet) { _conf: conf, _transport: { sendMetricSet: onMetricSet + }, + _isMetricNameDisabled (name) { + return false } } } diff --git a/test/opentelemetry-bridge/.tav.yml b/test/opentelemetry-bridge/.tav.yml new file mode 100644 index 0000000000..a44e6100bb --- /dev/null +++ b/test/opentelemetry-bridge/.tav.yml @@ -0,0 +1,11 @@ +"@opentelemetry/api": + versions: '>=1.0.0 <1.5.0' + node: '>=8.0.0' + commands: + - node OTelBridgeNonRecordingSpan.test.js + - node OTelBridgeRunContext.test.js + - node active-span-and-context-interop.test.js + - node fixtures.test.js + - node interface-ContextManager.test.js + - node otel-bridge-feature.test.js + diff --git a/test/opentelemetry-bridge/package-lock.json b/test/opentelemetry-bridge/package-lock.json new file mode 100644 index 0000000000..a5c5757f42 --- /dev/null +++ b/test/opentelemetry-bridge/package-lock.json @@ -0,0 +1,30 @@ +{ + "name": "opentelemetry-bridge-tests", + "version": "1.0.0", + "lockfileVersion": 2, + "requires": true, + "packages": { + "": { + "name": "opentelemetry-bridge-tests", + "version": "1.0.0", + "dependencies": { + "@opentelemetry/api": "^1.4.1" + } + }, + "node_modules/@opentelemetry/api": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.4.1.tgz", + "integrity": "sha512-O2yRJce1GOc6PAy3QxFM4NzFiWzvScDC1/5ihYBL6BUEVdq0XMWN01sppE+H6bBXbaFYipjwFLEWLg5PaSOThA==", + "engines": { + "node": ">=8.0.0" + } + } + }, + "dependencies": { + "@opentelemetry/api": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.4.1.tgz", + "integrity": "sha512-O2yRJce1GOc6PAy3QxFM4NzFiWzvScDC1/5ihYBL6BUEVdq0XMWN01sppE+H6bBXbaFYipjwFLEWLg5PaSOThA==" + } + } +} diff --git a/test/opentelemetry-bridge/package.json b/test/opentelemetry-bridge/package.json new file mode 100644 index 0000000000..adff63fd3b --- /dev/null +++ b/test/opentelemetry-bridge/package.json @@ -0,0 +1,8 @@ +{ + "name": "opentelemetry-bridge-tests", + "version": "1.0.0", + "private": true, + "dependencies": { + "@opentelemetry/api": "^1.4.1" + } +} diff --git a/test/opentelemetry-metrics/fixtures.test.js b/test/opentelemetry-metrics/fixtures.test.js new file mode 100644 index 0000000000..fc846b534b --- /dev/null +++ b/test/opentelemetry-metrics/fixtures.test.js @@ -0,0 +1,298 @@ +/* + * Copyright Elasticsearch B.V. and other contributors where applicable. + * Licensed under the BSD 2-Clause License; you may not use this file except in + * compliance with the BSD 2-Clause License. + */ + +'use strict' + +// Thes tests below execute a script from "fixtures/" something like: +// +// ELASTIC_APM_METRICS_INTERVAL=500ms ELASTIC_APM_API_REQUEST_TIME=500ms \ +// node -r ../../start.js fixtures/start-span.js +// +// waits a short period to be sure metrics have been sent, stops the process, +// then asserts the mock APM server got the expected metrics data. +// +// The scripts can be run independent of the test suite. + +const util = require('util') + +const { exec, execFile } = require('child_process') +const fs = require('fs') +const os = require('os') +const path = require('path') +const semver = require('semver') +const tape = require('tape') + +const { MockAPMServer } = require('../_mock_apm_server') +const { findObjInArray, findObjsInArray, formatForTComment } = require('../_utils') + +if (!semver.satisfies(process.version, '>=14')) { + console.log(`# SKIP @opentelemetry/sdk-metrics only supports node >=14 (node ${process.version})`) + process.exit() +} + +const undici = require('undici') // import after we've excluded node <14 + +const fixturesDir = path.join(__dirname, 'fixtures') + +// ---- support functions + +async function checkEventsHaveTestMetrics (t, events, extraMetricNames = []) { + let m + + // Test the first of the metricsets with no tags/attributes. + const event = findObjInArray(events, 'metricset.samples.test_counter') + t.comment('test_counter metricset: ' + formatForTComment(util.inspect(event.metricset, { depth: 5 }))) + const agoUs = Date.now() * 1000 - event.metricset.timestamp + const limit = 10 * 1000 * 1000 // 10s ago in μs + t.ok(agoUs > 0 && agoUs < limit, `metricset.timestamp (a recent number of μs since the epoch, ${agoUs}μs ago)`) + t.deepEqual(event.metricset.tags, {}, 'metricset.tags') + m = event.metricset.samples.test_counter + t.equal(m.type, 'counter', 'test_counter.type') + t.ok(Number.isInteger(m.value) && m.value >= 0, 'test_counter.value is a positive integer') + // The expected value is between 2 and 3 because we have + // `metricsInterval=500ms` and the "fixtures/*.js" scripts are incrementing + // the counters every 200ms. + t.ok(2 <= m.value && m.value <= 3, // eslint-disable-line yoda + 'test_counter value is in [2,3] range, indicating aggregation temporality is the expected "Delta"') + + m = event.metricset.samples.test_async_counter + t.equal(m.type, 'counter', 'test_async_counter.type') + t.ok(2 <= m.value && m.value <= 3, // eslint-disable-line yoda + 'test_async_counter value is in [2,3] range, indicating aggregation temporality is the expected "Delta"') + + m = event.metricset.samples.test_async_gauge + t.ok(-1 <= m.value && m.value <= 1, // eslint-disable-line yoda + 'test_async_gauge value is in [-1,1] range, the expected sine wave range') + + m = event.metricset.samples.test_updowncounter + t.equal(m.type, 'gauge', 'test_updowncounter.type') + t.ok(-30 <= m.value && m.value <= 30, // eslint-disable-line yoda + 'test_updowncounter value is in expect [-30,30] range') + + m = event.metricset.samples.test_async_updowncounter + t.equal(m.type, 'gauge', 'test_async_updowncounter.type') + t.ok(-30 <= m.value && m.value <= 30, // eslint-disable-line yoda + 'test_async_updowncounter value is in expect [-30,30] range') + + if (extraMetricNames.includes('test_histogram_defbuckets')) { + // A histogram that we expect to have the APM agent default buckets. + m = event.metricset.samples.test_histogram_defbuckets + t.equal(m.type, 'histogram', 'test_histogram_defbuckets.type') + t.equal(m.counts.length, 3, 'test_histogram_defbuckets.counts') + // The test file recorded values of 2, 3, and 4. The expected converted values + // are the midpoints between the default bucket boundaries. For example, + // 3 is between bucket boundaries (2.82843, 4], whose midpoint is 3.414215. + t.deepEqual(m.values, [2.414215, 3.414215, 4.828425], 'test_histogram_defbuckets.values') + } + if (extraMetricNames.includes('test_histogram_viewbuckets')) { + // A histogram that we expect to have the buckets defined by a `View`. + m = event.metricset.samples.test_histogram_viewbuckets + t.equal(m.type, 'histogram', 'test_histogram_viewbuckets.type') + // The test file recorded values of 2, 3, and 4. These fall into two + // buckets in `[..., 1, 2.5, 5, ...]`. After conversion to APM server + // intake format, the values are the midpoints of those buckets. + t.equal(m.counts.length, 2, 'test_histogram_viewbuckets.counts') + t.deepEqual(m.values, [1.75, 3.75], 'test_histogram_viewbuckets.values') + } + if (extraMetricNames.includes('test_histogram_confbuckets')) { + // A histogram that we expect to have the buckets defined by the + // `custom_metrics_histogram_boundaries` config var. + m = event.metricset.samples.test_histogram_confbuckets + t.equal(m.type, 'histogram', 'test_histogram_confbuckets.type') + // The test file recorded values of 2, 3, and 4. These fall into three + // buckets in `[0, 1, 2, 3, 4, 5]`. After conversion to APM server + // intake format, the values are the midpoints of those buckets. + t.equal(m.counts.length, 3, 'test_histogram_confbuckets.counts') + t.deepEqual(m.values, [2.5, 3.5, 4.5], 'test_histogram_confbuckets.values') + } +} + +async function checkHasPrometheusMetrics (t) { + const { statusCode, body } = await undici.request('http://localhost:9464/metrics') + t.equal(statusCode, 200, 'prometheus exporter is still working') + const text = await body.text() + t.ok(text.indexOf('\ntest_counter') !== -1, 'prometheus metrics include "test_counter"') +} + +// ---- tests + +// We need to `npm install` for a first test run. +const haveNodeModules = fs.existsSync(path.join(fixturesDir, 'node_modules')) +tape.test(`setup: npm install (in ${fixturesDir})`, { skip: haveNodeModules }, t => { + const startTime = Date.now() + exec( + 'npm install', + { + cwd: fixturesDir + }, + function (err, stdout, stderr) { + t.error(err, `"npm install" succeeded (took ${(Date.now() - startTime) / 1000}s)`) + if (err) { + t.comment(`$ npm install\n-- stdout --\n${stdout}\n-- stderr --\n${stderr}\n--`) + } + t.end() + } + ) +}) + +const cases = [ + { + script: 'use-just-otel-api.js', + checkEvents: async (t, events) => { + t.ok(events[0].metadata, 'APM server got event metadata object') + await checkEventsHaveTestMetrics(t, events, + ['test_histogram_defbuckets']) + } + }, + { + script: 'use-just-otel-sdk.js', + checkEvents: async (t, events) => { + t.ok(events[0].metadata, 'APM server got event metadata object') + await checkEventsHaveTestMetrics(t, events, ['test_histogram_viewbuckets']) + await checkHasPrometheusMetrics(t) + } + }, + { + script: 'use-otel-api-with-registered-meter-provider.js', + env: { + ELASTIC_APM_CUSTOM_METRICS_HISTOGRAM_BOUNDARIES: '0,1, 2,\t3,4 ,5' + }, + checkEvents: async (t, events) => { + t.ok(events[0].metadata, 'APM server got event metadata object') + await checkEventsHaveTestMetrics(t, events, ['test_histogram_confbuckets']) + await checkHasPrometheusMetrics(t) + } + }, + { + script: 'various-attrs.js', + checkEvents: async (t, events) => { + t.ok(events[0].metadata, 'APM server got event metadata object') + + // Test that there are 3 separate metricsets for 'test_counter_attrs' + // for a given timestamp -- one for each of the expected attr sets. + const firstTimestamp = findObjInArray(events, 'metricset.samples.test_counter_attrs').metricset.timestamp + const eventsAttrs = findObjsInArray(events, 'metricset.samples.test_counter_attrs') + .filter(e => e.metricset.timestamp === firstTimestamp) + t.equal(eventsAttrs.length, 3, '3 attr sets for test_counter_attrs') + t.ok(eventsAttrs.some(e => e.metricset.tags['http.request.method'] === 'POST' && e.metricset.tags['http.response.status_code'] === '200')) + t.ok(eventsAttrs.some(e => e.metricset.tags['http.request.method'] === 'GET' && e.metricset.tags['http.response.status_code'] === '200')) + t.ok(eventsAttrs.some(e => e.metricset.tags['http.request.method'] === 'GET' && e.metricset.tags['http.response.status_code'] === '400')) + t.ok(!eventsAttrs.some(e => e.metricset.tags.array_valued_attr), 'no test_counter_attrs metricset with "array_valued_attr" label') + }, + checkOutput: async (t, stdout, _stderr) => { + const warnLines = stdout.split('\n').filter(ln => ~ln.indexOf('dropping array-valued metric attribute')) + t.equal(warnLines.length, 1, 'exactly one log.warn about dropping the array-valued metric attribute') + t.ok(warnLines[0].indexOf('test_counter_attrs'), 'log.warn mentions the metric name') + t.ok(warnLines[0].indexOf('array_valued_attr'), 'log.warn mentions the attribute name') + } + }, + { + script: 'instrumentation-scopes.js', + checkEvents: async (t, events) => { + let e + t.ok(events[0].metadata, 'APM server got event metadata object') + + // Test that there are 4 separate metricsets for 'test_counter_{a,b,c,d,e}' + // for a given timestamp -- one for each of the instrumentation scopes. + const firstTimestamp = findObjInArray(events, 'metricset.samples.test_counter_a').metricset.timestamp + const eventGroup = findObjsInArray(events, 'metricset.samples') + .filter(e => e.metricset.timestamp === firstTimestamp) + t.equal(eventGroup.length, 4, '4 instrumentation scopes test_counter_* metrics') + e = findObjInArray(eventGroup, 'metricset.samples.test_counter_a') + t.deepEqual(Object.keys(e.metricset.samples), ['test_counter_a']) + e = findObjInArray(eventGroup, 'metricset.samples.test_counter_b') + t.deepEqual(Object.keys(e.metricset.samples), ['test_counter_b']) + e = findObjInArray(eventGroup, 'metricset.samples.test_counter_c') + t.deepEqual(Object.keys(e.metricset.samples), ['test_counter_c']) + e = findObjInArray(eventGroup, 'metricset.samples.test_counter_d') + t.deepEqual(Object.keys(e.metricset.samples), ['test_counter_d', 'test_counter_e']) + } + }, + { + script: 'use-disable-metrics-conf.js', + env: { + ELASTIC_APM_DISABLE_METRICS: 'nodejs.*,system*cpu*,system.memory.actual.free,foo-counter-*' + }, + checkEvents: async (t, events) => { + t.ok(events[0].metadata, 'APM server got event metadata object') + + // Test all metricsets: + // - There should be no samples for metrics matching the above config patterns. + // - There should not be any empty metricsets (ones with no samples). + const reportedMetricNames = new Set() + events + .filter(e => !!e.metricset) + .forEach(e => { + const names = Object.keys(e.metricset.samples) + t.ok(names.length > 0, 'metricset is not empty') + const unexpectedNames = names.filter(n => + /^nodejs\..*$/.test(n) || + /^system.*cpu.*$/.test(n) || + n === 'system.memory.actual.free' || + /^foo-counter-.*$/.test(n) + ) + t.equal(unexpectedNames.length, 0, `no unexpected metric names (unexpectedNames=${JSON.stringify(unexpectedNames)})`) + names.forEach(n => reportedMetricNames.add(n)) + }) + + // Spot test that some expected metrics are being reported. + t.ok(reportedMetricNames.has('bar-counter-1'), '"bar-counter-1" metric is being reported') + t.ok(reportedMetricNames.has('system.memory.total'), '"system.memory.total" metric is being reported') + } + } +] + +cases.forEach(c => { + tape.test(`test/opentelemetry-metrics/fixtures/${c.script}`, c.testOpts || {}, t => { + const server = new MockAPMServer() + const scriptPath = path.join('fixtures', c.script) + server.start(function (serverUrl) { + const proc = execFile( + process.execPath, + ['-r', '../../start.js', scriptPath], + { + cwd: __dirname, + timeout: 10000, // guard on hang, 3s is sometimes too short for CI + env: Object.assign( + {}, + process.env, + c.env, + { + ELASTIC_APM_SERVER_URL: serverUrl, + ELASTIC_APM_METRICS_INTERVAL: '500ms', + ELASTIC_APM_API_REQUEST_TIME: '500ms', + ELASTIC_APM_CENTRAL_CONFIG: 'false', + ELASTIC_APM_CLOUD_PROVIDER: 'none', + ELASTIC_APM_LOG_UNCAUGHT_EXCEPTIONS: 'true' + } + ) + }, + async function done (_err, stdout, stderr) { + // We are terminating the process with SIGTERM, so we *expect* a + // non-zero exit. Hence checking `_err` isn't useful. If there is + // any output, then show it, in case it is useful for debugging + // test failures. + if (stdout.trim() || stderr.trim()) { + t.comment(`$ node ${scriptPath}\n-- stdout --\n|${formatForTComment(stdout)}\n-- stderr --\n|${formatForTComment(stderr)}\n--`) + } + if (c.checkOutput) { + await c.checkOutput(t, stdout, stderr) + } + server.close() + t.end() + } + ) + // Wait some time for some metrics to have been sent. + // (Attempt to avoid spurious GH Actions CI issues on Windows runners with + // a longer wait time.) + const WAIT_TIME_MS = os.platform() === 'win32' ? 4000 : 2000 + setTimeout(async () => { + await c.checkEvents(t, server.events) + proc.kill() + }, WAIT_TIME_MS) + }) + }) +}) diff --git a/test/opentelemetry-metrics/fixtures/.tav.yml b/test/opentelemetry-metrics/fixtures/.tav.yml new file mode 100644 index 0000000000..06edc24d03 --- /dev/null +++ b/test/opentelemetry-metrics/fixtures/.tav.yml @@ -0,0 +1,11 @@ +"@opentelemetry/api": + versions: '>=1.3.0 <1.5.0' + node: '>=14.0.0' + commands: + - node ../fixtures.test.js + +"@opentelemetry/sdk-metrics": + versions: '>=1.11.0 <2' + node: '>=14.0.0' + commands: + - node ../fixtures.test.js diff --git a/test/opentelemetry-metrics/fixtures/instrumentation-scopes.js b/test/opentelemetry-metrics/fixtures/instrumentation-scopes.js new file mode 100644 index 0000000000..89ca7ab252 --- /dev/null +++ b/test/opentelemetry-metrics/fixtures/instrumentation-scopes.js @@ -0,0 +1,31 @@ +/* + * Copyright Elasticsearch B.V. and other contributors where applicable. + * Licensed under the BSD 2-Clause License; you may not use this file except in + * compliance with the BSD 2-Clause License. + */ + +'use strict' + +const otel = require('@opentelemetry/api') + +const meterA = otel.metrics.getMeter('test-meter') +const counterA = meterA.createCounter('test_counter_a') +const meterB = otel.metrics.getMeter('test-meter', '1.2.3') +const counterB = meterB.createCounter('test_counter_b') +const meterC = otel.metrics.getMeter('test-meter', '1.2.4') +const counterC = meterC.createCounter('test_counter_c') +const meterD = otel.metrics.getMeter('test-meter2', '1.2.3') +const counterD = meterD.createCounter('test_counter_d') +const meterE = otel.metrics.getMeter('test-meter2', '1.2.3') +const counterE = meterE.createCounter('test_counter_e') + +setInterval(() => { + // This should result in these counters being sent in *four* separate + // metricsets, because they have different instrumentation scopes. + // `test_counter_d` and `test_counter_e` should be in the same metricset. + counterA.add(1) + counterB.add(1) + counterC.add(1) + counterD.add(1) + counterE.add(1) +}, 200) diff --git a/test/opentelemetry-metrics/fixtures/package-lock.json b/test/opentelemetry-metrics/fixtures/package-lock.json new file mode 100644 index 0000000000..5f28f24be6 --- /dev/null +++ b/test/opentelemetry-metrics/fixtures/package-lock.json @@ -0,0 +1,242 @@ +{ + "name": "otel-metrics-fixtures", + "version": "1.0.0", + "lockfileVersion": 2, + "requires": true, + "packages": { + "": { + "name": "otel-metrics-fixtures", + "version": "1.0.0", + "dependencies": { + "@opentelemetry/api": "^1.3.0", + "@opentelemetry/exporter-prometheus": "^0.37.0", + "@opentelemetry/sdk-metrics": "^1.11.0" + } + }, + "node_modules/@opentelemetry/api": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.4.1.tgz", + "integrity": "sha512-O2yRJce1GOc6PAy3QxFM4NzFiWzvScDC1/5ihYBL6BUEVdq0XMWN01sppE+H6bBXbaFYipjwFLEWLg5PaSOThA==", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@opentelemetry/core": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-1.11.0.tgz", + "integrity": "sha512-aP1wHSb+YfU0pM63UAkizYPuS4lZxzavHHw5KJfFNN2oWQ79HSm6JR3CzwFKHwKhSzHN8RE9fgP1IdVJ8zmo1w==", + "dependencies": { + "@opentelemetry/semantic-conventions": "1.11.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.5.0" + } + }, + "node_modules/@opentelemetry/exporter-prometheus": { + "version": "0.37.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-prometheus/-/exporter-prometheus-0.37.0.tgz", + "integrity": "sha512-i8smYb6zJVimRzOm6/E8rfSG8OSZZStfBkawXD6iMWSoZ8mKznDdkHBkKF09JFep4kt0+Yb24hitetPGa3Fv9g==", + "dependencies": { + "@opentelemetry/core": "1.11.0", + "@opentelemetry/resources": "1.11.0", + "@opentelemetry/sdk-metrics": "1.11.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/exporter-prometheus/node_modules/@opentelemetry/sdk-metrics": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-1.11.0.tgz", + "integrity": "sha512-knuq3pwU0+46FEMdw9Ses+alXL9cbcLUUTdYBBBsaKkqKwoVMHfhBufW7u6YCu4i+47Wg6ZZTN/eGc4LbTbK5Q==", + "dependencies": { + "@opentelemetry/core": "1.11.0", + "@opentelemetry/resources": "1.11.0", + "lodash.merge": "4.6.2" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.5.0" + } + }, + "node_modules/@opentelemetry/resources": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-1.11.0.tgz", + "integrity": "sha512-y0z2YJTqk0ag+hGT4EXbxH/qPhDe8PfwltYb4tXIEsozgEFfut/bqW7H7pDvylmCjBRMG4NjtLp57V1Ev++brA==", + "dependencies": { + "@opentelemetry/core": "1.11.0", + "@opentelemetry/semantic-conventions": "1.11.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.5.0" + } + }, + "node_modules/@opentelemetry/sdk-metrics": { + "version": "1.12.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-1.12.0.tgz", + "integrity": "sha512-zOy88Jfk88eTxqu+9ypHLs184dGydJocSWtvWMY10QKVVaxhC3SLKa0uxI/zBtD9S+x0LP65wxrTSfSoUNtCOA==", + "dependencies": { + "@opentelemetry/core": "1.12.0", + "@opentelemetry/resources": "1.12.0", + "lodash.merge": "4.6.2" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.5.0" + } + }, + "node_modules/@opentelemetry/sdk-metrics/node_modules/@opentelemetry/core": { + "version": "1.12.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-1.12.0.tgz", + "integrity": "sha512-4DWYNb3dLs2mSCGl65jY3aEgbvPWSHVQV/dmDWiYeWUrMakZQFcymqZOSUNZO0uDrEJoxMu8O5tZktX6UKFwag==", + "dependencies": { + "@opentelemetry/semantic-conventions": "1.12.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.5.0" + } + }, + "node_modules/@opentelemetry/sdk-metrics/node_modules/@opentelemetry/resources": { + "version": "1.12.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-1.12.0.tgz", + "integrity": "sha512-gunMKXG0hJrR0LXrqh7BVbziA/+iJBL3ZbXCXO64uY+SrExkwoyJkpiq9l5ismkGF/A20mDEV7tGwh+KyPw00Q==", + "dependencies": { + "@opentelemetry/core": "1.12.0", + "@opentelemetry/semantic-conventions": "1.12.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.5.0" + } + }, + "node_modules/@opentelemetry/sdk-metrics/node_modules/@opentelemetry/semantic-conventions": { + "version": "1.12.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.12.0.tgz", + "integrity": "sha512-hO+bdeGOlJwqowUBoZF5LyP3ORUFOP1G0GRv8N45W/cztXbT2ZEXaAzfokRS9Xc9FWmYrDj32mF6SzH6wuoIyA==", + "engines": { + "node": ">=14" + } + }, + "node_modules/@opentelemetry/semantic-conventions": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.11.0.tgz", + "integrity": "sha512-fG4D0AktoHyHwGhFGv+PzKrZjxbKJfckJauTJdq2A+ej5cTazmNYjJVAODXXkYyrsI10muMl+B1iO2q1R6Lp+w==", + "engines": { + "node": ">=14" + } + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==" + } + }, + "dependencies": { + "@opentelemetry/api": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.4.1.tgz", + "integrity": "sha512-O2yRJce1GOc6PAy3QxFM4NzFiWzvScDC1/5ihYBL6BUEVdq0XMWN01sppE+H6bBXbaFYipjwFLEWLg5PaSOThA==" + }, + "@opentelemetry/core": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-1.11.0.tgz", + "integrity": "sha512-aP1wHSb+YfU0pM63UAkizYPuS4lZxzavHHw5KJfFNN2oWQ79HSm6JR3CzwFKHwKhSzHN8RE9fgP1IdVJ8zmo1w==", + "requires": { + "@opentelemetry/semantic-conventions": "1.11.0" + } + }, + "@opentelemetry/exporter-prometheus": { + "version": "0.37.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-prometheus/-/exporter-prometheus-0.37.0.tgz", + "integrity": "sha512-i8smYb6zJVimRzOm6/E8rfSG8OSZZStfBkawXD6iMWSoZ8mKznDdkHBkKF09JFep4kt0+Yb24hitetPGa3Fv9g==", + "requires": { + "@opentelemetry/core": "1.11.0", + "@opentelemetry/resources": "1.11.0", + "@opentelemetry/sdk-metrics": "1.11.0" + }, + "dependencies": { + "@opentelemetry/sdk-metrics": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-1.11.0.tgz", + "integrity": "sha512-knuq3pwU0+46FEMdw9Ses+alXL9cbcLUUTdYBBBsaKkqKwoVMHfhBufW7u6YCu4i+47Wg6ZZTN/eGc4LbTbK5Q==", + "requires": { + "@opentelemetry/core": "1.11.0", + "@opentelemetry/resources": "1.11.0", + "lodash.merge": "4.6.2" + } + } + } + }, + "@opentelemetry/resources": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-1.11.0.tgz", + "integrity": "sha512-y0z2YJTqk0ag+hGT4EXbxH/qPhDe8PfwltYb4tXIEsozgEFfut/bqW7H7pDvylmCjBRMG4NjtLp57V1Ev++brA==", + "requires": { + "@opentelemetry/core": "1.11.0", + "@opentelemetry/semantic-conventions": "1.11.0" + } + }, + "@opentelemetry/sdk-metrics": { + "version": "1.12.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-1.12.0.tgz", + "integrity": "sha512-zOy88Jfk88eTxqu+9ypHLs184dGydJocSWtvWMY10QKVVaxhC3SLKa0uxI/zBtD9S+x0LP65wxrTSfSoUNtCOA==", + "requires": { + "@opentelemetry/core": "1.12.0", + "@opentelemetry/resources": "1.12.0", + "lodash.merge": "4.6.2" + }, + "dependencies": { + "@opentelemetry/core": { + "version": "1.12.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-1.12.0.tgz", + "integrity": "sha512-4DWYNb3dLs2mSCGl65jY3aEgbvPWSHVQV/dmDWiYeWUrMakZQFcymqZOSUNZO0uDrEJoxMu8O5tZktX6UKFwag==", + "requires": { + "@opentelemetry/semantic-conventions": "1.12.0" + } + }, + "@opentelemetry/resources": { + "version": "1.12.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-1.12.0.tgz", + "integrity": "sha512-gunMKXG0hJrR0LXrqh7BVbziA/+iJBL3ZbXCXO64uY+SrExkwoyJkpiq9l5ismkGF/A20mDEV7tGwh+KyPw00Q==", + "requires": { + "@opentelemetry/core": "1.12.0", + "@opentelemetry/semantic-conventions": "1.12.0" + } + }, + "@opentelemetry/semantic-conventions": { + "version": "1.12.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.12.0.tgz", + "integrity": "sha512-hO+bdeGOlJwqowUBoZF5LyP3ORUFOP1G0GRv8N45W/cztXbT2ZEXaAzfokRS9Xc9FWmYrDj32mF6SzH6wuoIyA==" + } + } + }, + "@opentelemetry/semantic-conventions": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.11.0.tgz", + "integrity": "sha512-fG4D0AktoHyHwGhFGv+PzKrZjxbKJfckJauTJdq2A+ej5cTazmNYjJVAODXXkYyrsI10muMl+B1iO2q1R6Lp+w==" + }, + "lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==" + } + } +} diff --git a/test/opentelemetry-metrics/fixtures/package.json b/test/opentelemetry-metrics/fixtures/package.json new file mode 100644 index 0000000000..097d299491 --- /dev/null +++ b/test/opentelemetry-metrics/fixtures/package.json @@ -0,0 +1,10 @@ +{ + "name": "otel-metrics-fixtures", + "version": "1.0.0", + "private": true, + "dependencies": { + "@opentelemetry/api": "^1.3.0", + "@opentelemetry/exporter-prometheus": "^0.37.0", + "@opentelemetry/sdk-metrics": "^1.11.0" + } +} diff --git a/test/opentelemetry-metrics/fixtures/use-disable-metrics-conf.js b/test/opentelemetry-metrics/fixtures/use-disable-metrics-conf.js new file mode 100644 index 0000000000..46b2005353 --- /dev/null +++ b/test/opentelemetry-metrics/fixtures/use-disable-metrics-conf.js @@ -0,0 +1,25 @@ +/* + * Copyright Elasticsearch B.V. and other contributors where applicable. + * Licensed under the BSD 2-Clause License; you may not use this file except in + * compliance with the BSD 2-Clause License. + */ + +'use strict' + +// This file will be run with test values of the `disableMetrics` config var. + +const otel = require('@opentelemetry/api') + +const meter1 = otel.metrics.getMeter('test-meter', '1') +const meter2 = otel.metrics.getMeter('test-meter', '2') +const counters = [ + meter1.createCounter('foo-counter-1'), + meter1.createCounter('bar-counter-1'), + meter2.createCounter('foo-counter-2') +] + +setInterval(() => { + counters.forEach(c => { + c.add(1) + }) +}, 200) diff --git a/test/opentelemetry-metrics/fixtures/use-just-otel-api.js b/test/opentelemetry-metrics/fixtures/use-just-otel-api.js new file mode 100644 index 0000000000..9db44dee5c --- /dev/null +++ b/test/opentelemetry-metrics/fixtures/use-just-otel-api.js @@ -0,0 +1,56 @@ +/* + * Copyright Elasticsearch B.V. and other contributors where applicable. + * Licensed under the BSD 2-Clause License; you may not use this file except in + * compliance with the BSD 2-Clause License. + */ + +'use strict' + +// When run with the APM agent: +// node -r elastic-apm-node/start use-just-otel-api.js +// we expect a MeterProvider to be implicitly provided by the agent, such that +// metrics are sent to APM server. + +const otel = require('@opentelemetry/api') + +const meter = otel.metrics.getMeter('test-meter') + +const counter = meter.createCounter('test_counter', { description: 'A test Counter' }) + +let n = 0 +const asyncCounter = meter.createObservableCounter('test_async_counter', { description: 'A test Asynchronous Counter' }) +asyncCounter.addCallback(observableResult => { + observableResult.observe(n) +}) + +const asyncGauge = meter.createObservableGauge('test_async_gauge', { description: 'A test Asynchronous Gauge' }) +asyncGauge.addCallback(observableResult => { + // A sine wave with a 5 minute period, to have a recognizable pattern. + observableResult.observe(Math.sin(Date.now() / 1000 / 60 / 5 * (2 * Math.PI))) +}) + +const upDownCounter = meter.createUpDownCounter('test_updowncounter', { description: 'A test UpDownCounter' }) + +let c = 0 +const asyncUpDownCounter = meter.createObservableUpDownCounter('test_async_updowncounter', { description: 'A test Asynchronous UpDownCounter' }) +asyncUpDownCounter.addCallback(observableResult => { + observableResult.observe(c) +}) + +// We expect to get the APM agent's default buckets boundaries. +const histo = meter.createHistogram('test_histogram_defbuckets') + +setInterval(() => { + n++ + counter.add(1) + if (new Date().getUTCSeconds() < 30) { + c++ + upDownCounter.add(1) + } else { + c-- + upDownCounter.add(-1) + } + histo.record(2) + histo.record(3) + histo.record(4) +}, 200) diff --git a/test/opentelemetry-metrics/fixtures/use-just-otel-sdk.js b/test/opentelemetry-metrics/fixtures/use-just-otel-sdk.js new file mode 100644 index 0000000000..995a383c63 --- /dev/null +++ b/test/opentelemetry-metrics/fixtures/use-just-otel-sdk.js @@ -0,0 +1,73 @@ +/* + * Copyright Elasticsearch B.V. and other contributors where applicable. + * Licensed under the BSD 2-Clause License; you may not use this file except in + * compliance with the BSD 2-Clause License. + */ + +'use strict' + +// Run without the APM agent this script will export metrics via a Prometheus +// endpoint: +// curl -i http://localhost:9464/metrics +// +// With the APM agent running we also expect periodic metricsets sent to APM +// server, because the agent will add its MetricReader to the created +// MeterProvider. + +const { MeterProvider, View, ExplicitBucketHistogramAggregation } = require('@opentelemetry/sdk-metrics') +const { PrometheusExporter } = require('@opentelemetry/exporter-prometheus') + +const exporter = new PrometheusExporter({ host: 'localhost' }) +const meterProvider = new MeterProvider({ + views: [ + new View({ + instrumentName: 'test_histogram_viewbuckets', + aggregation: new ExplicitBucketHistogramAggregation( + // Use the same default buckets as in `prom-client`. + [0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10]) + }) + ] +}) +meterProvider.addMetricReader(exporter) + +const meter = meterProvider.getMeter('test-meter') + +const counter = meter.createCounter('test_counter', { description: 'A test Counter' }) + +let n = 0 +const asyncCounter = meter.createObservableCounter('test_async_counter', { description: 'A test Asynchronous Counter' }) +asyncCounter.addCallback(observableResult => { + observableResult.observe(n) +}) + +const asyncGauge = meter.createObservableGauge('test_async_gauge', { description: 'A test Asynchronous Gauge' }) +asyncGauge.addCallback(observableResult => { + // A sine wave with a 5 minute period, to have a recognizable pattern. + observableResult.observe(Math.sin(Date.now() / 1000 / 60 / 5 * (2 * Math.PI))) +}) + +const upDownCounter = meter.createUpDownCounter('test_updowncounter', { description: 'A test UpDownCounter' }) + +let c = 0 +const asyncUpDownCounter = meter.createObservableUpDownCounter('test_async_updowncounter', { description: 'A test Asynchronous UpDownCounter' }) +asyncUpDownCounter.addCallback(observableResult => { + observableResult.observe(c) +}) + +// We expect this to get the bucket boundaries from the View above. +const histo = meter.createHistogram('test_histogram_viewbuckets') + +setInterval(() => { + n++ + counter.add(1) + if (new Date().getUTCSeconds() < 30) { + c++ + upDownCounter.add(1) + } else { + c-- + upDownCounter.add(-1) + } + histo.record(2) + histo.record(3) + histo.record(4) +}, 200) diff --git a/test/opentelemetry-metrics/fixtures/use-otel-api-with-registered-meter-provider.js b/test/opentelemetry-metrics/fixtures/use-otel-api-with-registered-meter-provider.js new file mode 100644 index 0000000000..513d4999d7 --- /dev/null +++ b/test/opentelemetry-metrics/fixtures/use-otel-api-with-registered-meter-provider.js @@ -0,0 +1,71 @@ +/* + * Copyright Elasticsearch B.V. and other contributors where applicable. + * Licensed under the BSD 2-Clause License; you may not use this file except in + * compliance with the BSD 2-Clause License. + */ + +'use strict' + +// Run without the APM agent this script will export metrics via a Prometheus +// endpoint: +// curl -i http://localhost:9464/metrics +// +// With the APM agent running we also expect periodic metricsets sent to APM +// server, because the agent will add its MetricReader to the created +// MeterProvider. +// +// This test case checks that the APM agent does *not* interfere with the +// `.setGlobalMeterProvider()` usage. + +const otel = require('@opentelemetry/api') + +const { MeterProvider } = require('@opentelemetry/sdk-metrics') +const { PrometheusExporter } = require('@opentelemetry/exporter-prometheus') + +const exporter = new PrometheusExporter({ host: 'localhost' }) +const meterProvider = new MeterProvider() +meterProvider.addMetricReader(exporter) +otel.metrics.setGlobalMeterProvider(meterProvider) + +const meter = otel.metrics.getMeter('test-meter') + +const counter = meter.createCounter('test_counter', { description: 'A test Counter' }) + +let n = 0 +const asyncCounter = meter.createObservableCounter('test_async_counter', { description: 'A test Asynchronous Counter' }) +asyncCounter.addCallback(observableResult => { + observableResult.observe(n) +}) + +const asyncGauge = meter.createObservableGauge('test_async_gauge', { description: 'A test Asynchronous Gauge' }) +asyncGauge.addCallback(observableResult => { + // A sine wave with a 5 minute period, to have a recognizable pattern. + observableResult.observe(Math.sin(Date.now() / 1000 / 60 / 5 * (2 * Math.PI))) +}) + +const upDownCounter = meter.createUpDownCounter('test_updowncounter', { description: 'A test UpDownCounter' }) + +let c = 0 +const asyncUpDownCounter = meter.createObservableUpDownCounter('test_async_updowncounter', { description: 'A test Asynchronous UpDownCounter' }) +asyncUpDownCounter.addCallback(observableResult => { + observableResult.observe(c) +}) + +// We expect this to get the bucket boundaries from the +// `custom_metrics_histogram_boundaries` config. +const histo = meter.createHistogram('test_histogram_confbuckets') + +setInterval(() => { + n++ + counter.add(1) + if (new Date().getUTCSeconds() < 30) { + c++ + upDownCounter.add(1) + } else { + c-- + upDownCounter.add(-1) + } + histo.record(2) + histo.record(3) + histo.record(4) +}, 200) diff --git a/test/opentelemetry-metrics/fixtures/various-attrs.js b/test/opentelemetry-metrics/fixtures/various-attrs.js new file mode 100644 index 0000000000..8a376e4505 --- /dev/null +++ b/test/opentelemetry-metrics/fixtures/various-attrs.js @@ -0,0 +1,23 @@ +/* + * Copyright Elasticsearch B.V. and other contributors where applicable. + * Licensed under the BSD 2-Clause License; you may not use this file except in + * compliance with the BSD 2-Clause License. + */ + +'use strict' + +const otel = require('@opentelemetry/api') + +const meter = otel.metrics.getMeter('test-meter') +const counterAttrs = meter.createCounter('test_counter_attrs') + +setInterval(() => { + // Testing attrs: + // - This should result in *3* metricsets. + // - The array-valued attribute should be dropped and there should be a + // *single* logged warning about it. + counterAttrs.add(1, { 'http.request.method': 'POST', 'http.response.status_code': '200' }) + counterAttrs.add(1, { 'http.request.method': 'GET', 'http.response.status_code': '200' }) + counterAttrs.add(1, { 'http.request.method': 'GET', 'http.response.status_code': '400' }) + counterAttrs.add(1, { 'http.request.method': 'GET', 'http.response.status_code': '200', array_valued_attr: ['foo', 'bar'] }) +}, 200)