diff --git a/.env b/.env index b6709fccfe7..8a802248b9e 100644 --- a/.env +++ b/.env @@ -3,6 +3,8 @@ DD_AGENT_HOST=testagent DD_API_KEY=00000000000000000000000000000000 DD_METRIC_AGENT_PORT=8125 DD_TRACE_AGENT_PORT=9126 +OTLP_HTTP_PORT=4318 +OTLP_GRPC_PORT=4317 # Values are used for proxying from APM Test Agent to real Datadog Agent DD_REAL_AGENT_HOST=ddagent diff --git a/.github/workflows/_unit_test.yml b/.github/workflows/_unit_test.yml index 6b3fbad2342..afbbb70a5aa 100644 --- a/.github/workflows/_unit_test.yml +++ b/.github/workflows/_unit_test.yml @@ -131,6 +131,8 @@ jobs: TEST_MYSQL_HOST: mysql DD_AGENT_HOST: agent DD_TRACE_AGENT_PORT: '9126' + OTLP_GRPC_PORT: '4317' + OTLP_HTTP_PORT: '4318' DATADOG_GEM_CI: 'true' TEST_DATADOG_INTEGRATION: '1' JRUBY_OPTS: "--dev" # Faster JVM startup: https://github.com/jruby/jruby/wiki/Improving-startup-time#use-the---dev-flag @@ -141,6 +143,8 @@ jobs: LOG_LEVEL: DEBUG TRACE_LANGUAGE: ruby PORT: '9126' + OTLP_GRPC_PORT: '4317' + OTLP_HTTP_PORT: '4318' DD_POOL_TRACE_CHECK_FAILURES: 'true' DD_DISABLE_ERROR_RESPONSES: 'true' ENABLED_CHECKS: trace_content_length,trace_stall,meta_tracer_version_header,trace_count_header,trace_peer_service,trace_dd_service @@ -211,6 +215,8 @@ jobs: LOG_LEVEL: DEBUG TRACE_LANGUAGE: ruby PORT: '9126' + OTLP_GRPC_PORT: '4317' + OTLP_HTTP_PORT: '4318' DD_POOL_TRACE_CHECK_FAILURES: 'true' DD_DISABLE_ERROR_RESPONSES: 'true' ENABLED_CHECKS: trace_content_length,trace_stall,meta_tracer_version_header,trace_count_header,trace_peer_service,trace_dd_service diff --git a/appraisal/ruby-3.1.rb b/appraisal/ruby-3.1.rb index e4b87215d55..6a6bde1aa50 100644 --- a/appraisal/ruby-3.1.rb +++ b/appraisal/ruby-3.1.rb @@ -169,6 +169,8 @@ appraise 'opentelemetry' do gem 'opentelemetry-sdk', '~> 1.1' + gem 'opentelemetry-metrics-sdk', '>= 0.8' + gem 'opentelemetry-exporter-otlp-metrics', '>= 0.4' end appraise 'opentelemetry_otlp' do diff --git a/appraisal/ruby-3.2.rb b/appraisal/ruby-3.2.rb index 4f301e7e366..4b3390c20ed 100644 --- a/appraisal/ruby-3.2.rb +++ b/appraisal/ruby-3.2.rb @@ -214,6 +214,8 @@ appraise 'opentelemetry' do gem 'opentelemetry-sdk', '~> 1.1' + gem 'opentelemetry-metrics-sdk', '>= 0.8' + gem 'opentelemetry-exporter-otlp-metrics', '>= 0.4' end appraise 'opentelemetry_otlp' do diff --git a/appraisal/ruby-3.3.rb b/appraisal/ruby-3.3.rb index 4b3c0e2f485..50d4560a92b 100644 --- a/appraisal/ruby-3.3.rb +++ b/appraisal/ruby-3.3.rb @@ -216,6 +216,8 @@ appraise 'opentelemetry' do gem 'opentelemetry-sdk', '~> 1.1' + gem 'opentelemetry-metrics-sdk', '>= 0.8' + gem 'opentelemetry-exporter-otlp-metrics', '>= 0.4' end appraise 'opentelemetry_otlp' do diff --git a/appraisal/ruby-3.4.rb b/appraisal/ruby-3.4.rb index ef1f141c93f..628c768b0a6 100644 --- a/appraisal/ruby-3.4.rb +++ b/appraisal/ruby-3.4.rb @@ -229,6 +229,8 @@ appraise 'opentelemetry' do gem 'opentelemetry-sdk', '~> 1.1' + gem 'opentelemetry-metrics-sdk', '>= 0.8' + gem 'opentelemetry-exporter-otlp-metrics', '>= 0.4' end appraise 'opentelemetry_otlp' do diff --git a/appraisal/ruby-4.0.rb b/appraisal/ruby-4.0.rb index 08bb9c44b36..750c5956c14 100644 --- a/appraisal/ruby-4.0.rb +++ b/appraisal/ruby-4.0.rb @@ -175,6 +175,10 @@ appraise 'opentelemetry' do gem 'opentelemetry-sdk', '~> 1.1' + gem 'opentelemetry-metrics-sdk', '>= 0.8' + gem 'opentelemetry-exporter-otlp-metrics', '>= 0.4' + # opentelemetry-metrics-sdk 0.11+ requires opentelemetry-common >= 0.23.0 (for time_in_nanoseconds) + gem "opentelemetry-common", ">= 0.23.0" end appraise 'opentelemetry_otlp' do diff --git a/docker-compose.yml b/docker-compose.yml index da1af4a2783..59f112b71f1 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -263,6 +263,8 @@ services: image: ghcr.io/datadog/dd-apm-test-agent/ddapm-test-agent:v1.37.0 ports: - "127.0.0.1:${DD_TRACE_AGENT_PORT}:9126" + - "127.0.0.1:${OTLP_GRPC_PORT}:4317" + - "127.0.0.1:${OTLP_HTTP_PORT}:4318" depends_on: - ddagent env_file: ./.env @@ -271,6 +273,8 @@ services: - TRACE_LANGUAGE=ruby - DD_TRACE_AGENT_URL=http://${DD_REAL_AGENT_HOST}:${DD_REAL_AGENT_PORT} - PORT=${DD_TRACE_AGENT_PORT} + - OTLP_GRPC_PORT=${OTLP_GRPC_PORT} + - OTLP_HTTP_PORT=${OTLP_HTTP_PORT} - DD_POOL_TRACE_CHECK_FAILURES=true - DD_DISABLE_ERROR_RESPONSES=true - ENABLED_CHECKS=trace_content_length,trace_stall,meta_tracer_version_header,trace_count_header,trace_peer_service,trace_dd_service diff --git a/docs/OpenTelemetry.md b/docs/OpenTelemetry.md index e47aaaa4c1d..3c68adc5b7d 100644 --- a/docs/OpenTelemetry.md +++ b/docs/OpenTelemetry.md @@ -1,12 +1,14 @@ # OpenTelemetry -**Supported tracing frameworks**: +**Supported OpenTelemetry features**: | Type | Documentation | datadog version | Gem version support | | ------------- | ---------------------------------------------------- | --------------- | ------------------- | -| OpenTelemetry | https://github.com/open-telemetry/opentelemetry-ruby | 1.9.0+ | >= 1.1.0 | +| Tracing | https://github.com/open-telemetry/opentelemetry-ruby | 1.9.0+ | >= 1.1.0 | +| Metrics SDK | https://rubygems.org/gems/opentelemetry-metrics-sdk | 2.23.0+ | >= 0.8 | +| OTLP Metrics Exporter | https://rubygems.org/gems/opentelemetry-exporter-otlp-metrics | 2.23.0+ | >= 0.4 | -## Configuring OpenTelemetry +## Configuring OpenTelemetry Tracing 1. Add the `datadog` gem to your Gemfile: @@ -44,6 +46,53 @@ [Integration instrumentations](#integration-instrumentation) and OpenTelemetry [Automatic instrumentations](https://opentelemetry.io/docs/instrumentation/ruby/automatic/) are also supported. +## Configuring OpenTelemetry Metrics + +1. Add the required gems to your Gemfile: + + ```ruby + gem 'datadog' + gem 'opentelemetry-metrics-sdk', '~> 0.8' + gem 'opentelemetry-exporter-otlp-metrics', '~> 0.4' + ``` + +1. Install gems with `bundle install` + +1. Enable metrics export: + + ```ruby + # Set environment variable before initializing metrics support + ENV['DD_METRICS_OTEL_ENABLED'] = 'true' + require 'opentelemetry/sdk' + require 'datadog/opentelemetry' + + Datadog.configure do |c| + # Configure Datadog settings here + end + + # Call after Datadog.configure to initialize metrics. + # Can be called multiple times to pick up configuration changes. + # Requires: opentelemetry/exporter/otlp_metrics and opentelemetry/exporter/otlp_metrics + OpenTelemetry::SDK.configure + ``` + +1. Use the [OpenTelemetry Metrics API](https://opentelemetry.io/docs/languages/ruby/instrumentation/#metrics) to create and record metrics. + +**Note:** Call `OpenTelemetry::SDK.configure` after `Datadog.configure` and call it again whenever Datadog configuration changes to update the meter provider. + +**Configuration Options:** + +- `DD_METRICS_OTEL_ENABLED` - Enable metrics export (default: false) +- `OTEL_EXPORTER_OTLP_METRICS_PROTOCOL` - Protocol: `http/protobuf` (default); `grpc` and `http/json` are not yet supported. +- `OTEL_EXPORTER_OTLP_METRICS_ENDPOINT` - Custom endpoint (defaults to the Datadog agent otlp endpoint) +- `OTEL_EXPORTER_OTLP_METRICS_TEMPORALITY_PREFERENCE` - `delta` (default) or `cumulative` +- `OTEL_METRIC_EXPORT_INTERVAL` - Export interval in milliseconds (default: 10000) + +[General OTLP settings](https://opentelemetry.io/docs/languages/sdk-configuration/otlp-exporter/) (`OTEL_EXPORTER_OTLP_*`) serve as defaults if metrics-specific settings are not provided. + +**Note:** Minimum `opentelemetry-metrics-sdk` is v0.8.0 (contains critical bug fixes). Minimum `opentelemetry-exporter-otlp-metrics` is v0.4.0. Use the latest versions for best support. If you spot any issue with the OpenTelemetry API affecting the `datadog` gem, [please do open a GitHub issue](https://github.com/DataDog/dd-trace-rb/issues). + + ## Limitations There are a few limitations to OpenTelemetry Tracing when the APM integration is activated: diff --git a/gemfiles/ruby_3.1_opentelemetry.gemfile b/gemfiles/ruby_3.1_opentelemetry.gemfile index eda578f4081..d3bee733c14 100644 --- a/gemfiles/ruby_3.1_opentelemetry.gemfile +++ b/gemfiles/ruby_3.1_opentelemetry.gemfile @@ -26,6 +26,8 @@ gem "warning", "~> 1" gem "webmock", ">= 3.10.0" gem "webrick", ">= 1.7.0" gem "opentelemetry-sdk", "~> 1.1" +gem "opentelemetry-metrics-sdk", ">= 0.8" +gem "opentelemetry-exporter-otlp-metrics", ">= 0.4" group :check do diff --git a/gemfiles/ruby_3.1_opentelemetry.gemfile.lock b/gemfiles/ruby_3.1_opentelemetry.gemfile.lock index 5d72e85f597..5c72a0d311f 100644 --- a/gemfiles/ruby_3.1_opentelemetry.gemfile.lock +++ b/gemfiles/ruby_3.1_opentelemetry.gemfile.lock @@ -37,6 +37,8 @@ GEM google-protobuf (3.22.0) google-protobuf (3.22.0-x86_64-darwin) google-protobuf (3.22.0-x86_64-linux) + googleapis-common-protos-types (1.20.0) + google-protobuf (>= 3.18, < 5.a) hashdiff (1.0.1) io-console (0.8.0) irb (1.15.1) @@ -59,13 +61,28 @@ GEM method_source (1.0.0) msgpack (1.8.0) opentelemetry-api (1.1.0) - opentelemetry-common (0.19.6) + opentelemetry-common (0.23.0) opentelemetry-api (~> 1.0) + opentelemetry-exporter-otlp-metrics (0.6.1) + google-protobuf (>= 3.18, < 5.0) + googleapis-common-protos-types (~> 1.3) + opentelemetry-api (~> 1.1) + opentelemetry-common (~> 0.20) + opentelemetry-metrics-api (~> 0.2) + opentelemetry-metrics-sdk (~> 0.5) + opentelemetry-sdk (~> 1.2) + opentelemetry-semantic_conventions + opentelemetry-metrics-api (0.4.0) + opentelemetry-api (~> 1.0) + opentelemetry-metrics-sdk (0.11.1) + opentelemetry-api (~> 1.1) + opentelemetry-metrics-api (~> 0.2) + opentelemetry-sdk (~> 1.2) opentelemetry-registry (0.2.0) opentelemetry-api (~> 1.1) - opentelemetry-sdk (1.2.0) + opentelemetry-sdk (1.10.0) opentelemetry-api (~> 1.1) - opentelemetry-common (~> 0.19.3) + opentelemetry-common (~> 0.20) opentelemetry-registry (~> 0.2) opentelemetry-semantic_conventions opentelemetry-semantic_conventions (1.8.0) @@ -141,6 +158,8 @@ DEPENDENCIES google-protobuf (~> 3.0, != 3.7.1, != 3.7.0) json-schema (< 3) memory_profiler (~> 0.9) + opentelemetry-exporter-otlp-metrics (>= 0.4) + opentelemetry-metrics-sdk (>= 0.8) opentelemetry-sdk (~> 1.1) os (~> 1.1) pry diff --git a/gemfiles/ruby_3.2_opentelemetry.gemfile b/gemfiles/ruby_3.2_opentelemetry.gemfile index eda578f4081..d3bee733c14 100644 --- a/gemfiles/ruby_3.2_opentelemetry.gemfile +++ b/gemfiles/ruby_3.2_opentelemetry.gemfile @@ -26,6 +26,8 @@ gem "warning", "~> 1" gem "webmock", ">= 3.10.0" gem "webrick", ">= 1.7.0" gem "opentelemetry-sdk", "~> 1.1" +gem "opentelemetry-metrics-sdk", ">= 0.8" +gem "opentelemetry-exporter-otlp-metrics", ">= 0.4" group :check do diff --git a/gemfiles/ruby_3.2_opentelemetry.gemfile.lock b/gemfiles/ruby_3.2_opentelemetry.gemfile.lock index 85ab0690a4e..1ffae16f5df 100644 --- a/gemfiles/ruby_3.2_opentelemetry.gemfile.lock +++ b/gemfiles/ruby_3.2_opentelemetry.gemfile.lock @@ -32,9 +32,13 @@ GEM docile (1.4.1) dogstatsd-ruby (5.5.0) ffi (1.17.2-aarch64-linux-gnu) + ffi (1.17.2-arm64-darwin) ffi (1.17.2-x86_64-linux-gnu) google-protobuf (3.22.0) + google-protobuf (3.22.0-arm64-darwin) google-protobuf (3.22.0-x86_64-linux) + googleapis-common-protos-types (1.20.0) + google-protobuf (>= 3.18, < 5.a) hashdiff (1.0.1) io-console (0.8.0) irb (1.15.1) @@ -47,6 +51,8 @@ GEM libdatadog (24.0.1.1.0-x86_64-linux) libddwaf (1.30.0.0.0-aarch64-linux) ffi (~> 1.0) + libddwaf (1.30.0.0.0-arm64-darwin) + ffi (~> 1.0) libddwaf (1.30.0.0.0-x86_64-linux) ffi (~> 1.0) logger (1.7.0) @@ -54,13 +60,28 @@ GEM method_source (1.0.0) msgpack (1.8.0) opentelemetry-api (1.1.0) - opentelemetry-common (0.19.6) + opentelemetry-common (0.23.0) opentelemetry-api (~> 1.0) + opentelemetry-exporter-otlp-metrics (0.6.1) + google-protobuf (>= 3.18, < 5.0) + googleapis-common-protos-types (~> 1.3) + opentelemetry-api (~> 1.1) + opentelemetry-common (~> 0.20) + opentelemetry-metrics-api (~> 0.2) + opentelemetry-metrics-sdk (~> 0.5) + opentelemetry-sdk (~> 1.2) + opentelemetry-semantic_conventions + opentelemetry-metrics-api (0.4.0) + opentelemetry-api (~> 1.0) + opentelemetry-metrics-sdk (0.11.1) + opentelemetry-api (~> 1.1) + opentelemetry-metrics-api (~> 0.2) + opentelemetry-sdk (~> 1.2) opentelemetry-registry (0.2.0) opentelemetry-api (~> 1.1) - opentelemetry-sdk (1.2.0) + opentelemetry-sdk (1.10.0) opentelemetry-api (~> 1.1) - opentelemetry-common (~> 0.19.3) + opentelemetry-common (~> 0.20) opentelemetry-registry (~> 0.2) opentelemetry-semantic_conventions opentelemetry-semantic_conventions (1.8.0) @@ -121,6 +142,7 @@ GEM PLATFORMS aarch64-linux + arm64-darwin-24 x86_64-linux DEPENDENCIES @@ -135,6 +157,8 @@ DEPENDENCIES google-protobuf (~> 3.0, != 3.7.1, != 3.7.0) json-schema (< 3) memory_profiler (~> 0.9) + opentelemetry-exporter-otlp-metrics (>= 0.4) + opentelemetry-metrics-sdk (>= 0.8) opentelemetry-sdk (~> 1.1) os (~> 1.1) pry diff --git a/gemfiles/ruby_3.3_opentelemetry.gemfile b/gemfiles/ruby_3.3_opentelemetry.gemfile index eda578f4081..d3bee733c14 100644 --- a/gemfiles/ruby_3.3_opentelemetry.gemfile +++ b/gemfiles/ruby_3.3_opentelemetry.gemfile @@ -26,6 +26,8 @@ gem "warning", "~> 1" gem "webmock", ">= 3.10.0" gem "webrick", ">= 1.7.0" gem "opentelemetry-sdk", "~> 1.1" +gem "opentelemetry-metrics-sdk", ">= 0.8" +gem "opentelemetry-exporter-otlp-metrics", ">= 0.4" group :check do diff --git a/gemfiles/ruby_3.3_opentelemetry.gemfile.lock b/gemfiles/ruby_3.3_opentelemetry.gemfile.lock index cd513eb6b1e..04afac0c76f 100644 --- a/gemfiles/ruby_3.3_opentelemetry.gemfile.lock +++ b/gemfiles/ruby_3.3_opentelemetry.gemfile.lock @@ -34,6 +34,8 @@ GEM ffi (1.17.2-aarch64-linux-gnu) ffi (1.17.2-x86_64-linux-gnu) google-protobuf (3.23.1) + googleapis-common-protos-types (1.20.0) + google-protobuf (>= 3.18, < 5.a) hashdiff (1.0.1) io-console (0.8.0) irb (1.15.1) @@ -53,13 +55,28 @@ GEM method_source (1.0.0) msgpack (1.8.0) opentelemetry-api (1.1.0) - opentelemetry-common (0.19.6) + opentelemetry-common (0.23.0) opentelemetry-api (~> 1.0) + opentelemetry-exporter-otlp-metrics (0.6.1) + google-protobuf (>= 3.18, < 5.0) + googleapis-common-protos-types (~> 1.3) + opentelemetry-api (~> 1.1) + opentelemetry-common (~> 0.20) + opentelemetry-metrics-api (~> 0.2) + opentelemetry-metrics-sdk (~> 0.5) + opentelemetry-sdk (~> 1.2) + opentelemetry-semantic_conventions + opentelemetry-metrics-api (0.4.0) + opentelemetry-api (~> 1.0) + opentelemetry-metrics-sdk (0.11.1) + opentelemetry-api (~> 1.1) + opentelemetry-metrics-api (~> 0.2) + opentelemetry-sdk (~> 1.2) opentelemetry-registry (0.2.0) opentelemetry-api (~> 1.1) - opentelemetry-sdk (1.2.0) + opentelemetry-sdk (1.10.0) opentelemetry-api (~> 1.1) - opentelemetry-common (~> 0.19.3) + opentelemetry-common (~> 0.20) opentelemetry-registry (~> 0.2) opentelemetry-semantic_conventions opentelemetry-semantic_conventions (1.8.0) @@ -134,6 +151,8 @@ DEPENDENCIES google-protobuf (~> 3.0, != 3.7.1, != 3.7.0) json-schema (< 3) memory_profiler (~> 0.9) + opentelemetry-exporter-otlp-metrics (>= 0.4) + opentelemetry-metrics-sdk (>= 0.8) opentelemetry-sdk (~> 1.1) os (~> 1.1) pry diff --git a/gemfiles/ruby_3.4_opentelemetry.gemfile b/gemfiles/ruby_3.4_opentelemetry.gemfile index 842a65ff974..7ef9fcc939b 100644 --- a/gemfiles/ruby_3.4_opentelemetry.gemfile +++ b/gemfiles/ruby_3.4_opentelemetry.gemfile @@ -29,6 +29,8 @@ gem "warning", "~> 1" gem "webmock", ">= 3.10.0" gem "webrick", ">= 1.8.2" gem "opentelemetry-sdk", "~> 1.1" +gem "opentelemetry-metrics-sdk", ">= 0.8" +gem "opentelemetry-exporter-otlp-metrics", ">= 0.4" group :check do diff --git a/gemfiles/ruby_3.4_opentelemetry.gemfile.lock b/gemfiles/ruby_3.4_opentelemetry.gemfile.lock index f58dc099e14..19a7a547336 100644 --- a/gemfiles/ruby_3.4_opentelemetry.gemfile.lock +++ b/gemfiles/ruby_3.4_opentelemetry.gemfile.lock @@ -38,6 +38,9 @@ GEM ffi (1.17.2-aarch64-linux-gnu) ffi (1.17.2-x86_64-linux-gnu) google-protobuf (3.25.3) + google-protobuf (3.25.3-arm64-darwin) + googleapis-common-protos-types (1.20.0) + google-protobuf (>= 3.18, < 5.a) hashdiff (1.1.0) io-console (0.8.0) irb (1.15.1) @@ -57,9 +60,24 @@ GEM method_source (1.1.0) msgpack (1.8.0) mutex_m (0.2.0) - opentelemetry-api (1.2.5) - opentelemetry-common (0.21.0) + opentelemetry-api (1.7.0) + opentelemetry-common (0.23.0) opentelemetry-api (~> 1.0) + opentelemetry-exporter-otlp-metrics (0.6.1) + google-protobuf (>= 3.18, < 5.0) + googleapis-common-protos-types (~> 1.3) + opentelemetry-api (~> 1.1) + opentelemetry-common (~> 0.20) + opentelemetry-metrics-api (~> 0.2) + opentelemetry-metrics-sdk (~> 0.5) + opentelemetry-sdk (~> 1.2) + opentelemetry-semantic_conventions + opentelemetry-metrics-api (0.4.0) + opentelemetry-api (~> 1.0) + opentelemetry-metrics-sdk (0.11.1) + opentelemetry-api (~> 1.1) + opentelemetry-metrics-api (~> 0.2) + opentelemetry-sdk (~> 1.2) opentelemetry-registry (0.3.1) opentelemetry-api (~> 1.1) opentelemetry-sdk (1.4.1) @@ -127,6 +145,7 @@ GEM PLATFORMS aarch64-linux + arm64-darwin-24 x86_64-linux DEPENDENCIES @@ -144,6 +163,8 @@ DEPENDENCIES json-schema (< 3) memory_profiler (~> 0.9) mutex_m + opentelemetry-exporter-otlp-metrics (>= 0.4) + opentelemetry-metrics-sdk (>= 0.8) opentelemetry-sdk (~> 1.1) os (~> 1.1) ostruct diff --git a/gemfiles/ruby_4.0_opentelemetry.gemfile b/gemfiles/ruby_4.0_opentelemetry.gemfile index 392f9c4d5df..24f013cf95f 100644 --- a/gemfiles/ruby_4.0_opentelemetry.gemfile +++ b/gemfiles/ruby_4.0_opentelemetry.gemfile @@ -32,6 +32,9 @@ gem "webmock", ">= 3.10.0" gem "webrick", ">= 1.8.2" gem "datadog-ruby_core_source", github: "DataDog/datadog-ruby_core_source", ref: "7b95302a593c42b16d4f1e97c993de001a18a3b5" gem "opentelemetry-sdk", "~> 1.1" +gem "opentelemetry-metrics-sdk", ">= 0.8" +gem "opentelemetry-exporter-otlp-metrics", ">= 0.4" +gem "opentelemetry-common", ">= 0.23.0" group :check do diff --git a/gemfiles/ruby_4.0_opentelemetry.gemfile.lock b/gemfiles/ruby_4.0_opentelemetry.gemfile.lock index 2af974c6205..6423d86cec5 100644 --- a/gemfiles/ruby_4.0_opentelemetry.gemfile.lock +++ b/gemfiles/ruby_4.0_opentelemetry.gemfile.lock @@ -22,25 +22,42 @@ GEM addressable (2.8.7) public_suffix (>= 2.0.2, < 7.0) base64 (0.3.0) - benchmark (0.4.1) + benchmark (0.5.0) benchmark-ips (2.14.0) benchmark-memory (0.1.2) memory_profiler (~> 0.9) - bigdecimal (3.2.3) + bigdecimal (3.3.1) byebug (12.0.0) cgi (0.5.0) climate_control (1.2.0) coderay (1.1.3) concurrent-ruby (1.3.5) - crack (1.0.0) + crack (1.0.1) bigdecimal rexml diff-lcs (1.6.2) docile (1.4.1) dogstatsd-ruby (5.7.1) ffi (1.17.2) + ffi (1.17.2-aarch64-linux-gnu) + ffi (1.17.2-aarch64-linux-musl) + ffi (1.17.2-arm-linux-gnu) + ffi (1.17.2-arm-linux-musl) + ffi (1.17.2-arm64-darwin) + ffi (1.17.2-x86-linux-gnu) + ffi (1.17.2-x86-linux-musl) + ffi (1.17.2-x86_64-darwin) + ffi (1.17.2-x86_64-linux-gnu) + ffi (1.17.2-x86_64-linux-musl) fiddle (1.1.8) google-protobuf (3.25.8) + google-protobuf (3.25.8-aarch64-linux) + google-protobuf (3.25.8-arm64-darwin) + google-protobuf (3.25.8-x86-linux) + google-protobuf (3.25.8-x86_64-darwin) + google-protobuf (3.25.8-x86_64-linux) + googleapis-common-protos-types (1.20.0) + google-protobuf (>= 3.18, < 5.a) hashdiff (1.2.1) json-schema (2.8.1) addressable (>= 2.4) @@ -51,6 +68,10 @@ GEM ffi (~> 1.0) libddwaf (1.30.0.0.0-aarch64-linux) ffi (~> 1.0) + libddwaf (1.30.0.0.0-arm64-darwin) + ffi (~> 1.0) + libddwaf (1.30.0.0.0-x86_64-darwin) + ffi (~> 1.0) libddwaf (1.30.0.0.0-x86_64-linux) ffi (~> 1.0) logger (1.7.0) @@ -59,11 +80,26 @@ GEM msgpack (1.8.0) mutex_m (0.3.0) opentelemetry-api (1.7.0) - opentelemetry-common (0.22.0) + opentelemetry-common (0.23.0) opentelemetry-api (~> 1.0) + opentelemetry-exporter-otlp-metrics (0.6.1) + google-protobuf (>= 3.18, < 5.0) + googleapis-common-protos-types (~> 1.3) + opentelemetry-api (~> 1.1) + opentelemetry-common (~> 0.20) + opentelemetry-metrics-api (~> 0.2) + opentelemetry-metrics-sdk (~> 0.5) + opentelemetry-sdk (~> 1.2) + opentelemetry-semantic_conventions + opentelemetry-metrics-api (0.4.0) + opentelemetry-api (~> 1.0) + opentelemetry-metrics-sdk (0.11.1) + opentelemetry-api (~> 1.1) + opentelemetry-metrics-api (~> 0.2) + opentelemetry-sdk (~> 1.2) opentelemetry-registry (0.4.0) opentelemetry-api (~> 1.1) - opentelemetry-sdk (1.9.0) + opentelemetry-sdk (1.10.0) opentelemetry-api (~> 1.1) opentelemetry-common (~> 0.20) opentelemetry-registry (~> 0.2) @@ -76,22 +112,22 @@ GEM coderay (~> 1.1) method_source (~> 1.0) public_suffix (6.0.2) - rake (13.3.0) + rake (13.3.1) rake-compiler (1.3.0) rake rexml (3.4.4) - rspec (3.13.1) + rspec (3.13.2) rspec-core (~> 3.13.0) rspec-expectations (~> 3.13.0) rspec-mocks (~> 3.13.0) rspec-collection_matchers (1.2.1) rspec-expectations (>= 2.99.0.beta1) - rspec-core (3.13.5) + rspec-core (3.13.6) rspec-support (~> 3.13.0) rspec-expectations (3.13.5) diff-lcs (>= 1.2.0, < 2.0) rspec-support (~> 3.13.0) - rspec-mocks (3.13.5) + rspec-mocks (3.13.7) diff-lcs (>= 1.2.0, < 2.0) rspec-support (~> 3.13.0) rspec-support (3.13.6) @@ -106,7 +142,7 @@ GEM simplecov-html (0.13.2) simplecov_json_formatter (0.1.4) warning (1.5.0) - webmock (3.25.1) + webmock (3.26.1) addressable (>= 2.8.0) crack (>= 0.3.2) hashdiff (>= 0.4.0, < 2.0.0) @@ -115,8 +151,19 @@ GEM PLATFORMS aarch64-linux + aarch64-linux-gnu + aarch64-linux-musl + arm-linux-gnu + arm-linux-musl + arm64-darwin ruby + x86-linux + x86-linux-gnu + x86-linux-musl + x86_64-darwin x86_64-linux + x86_64-linux-gnu + x86_64-linux-musl DEPENDENCIES base64 @@ -135,6 +182,9 @@ DEPENDENCIES json-schema (< 3) memory_profiler (~> 0.9) mutex_m + opentelemetry-common (>= 0.23.0) + opentelemetry-exporter-otlp-metrics (>= 0.4) + opentelemetry-metrics-sdk (>= 0.8) opentelemetry-sdk (~> 1.1) os (~> 1.1) ostruct diff --git a/gemfiles/ruby_4.0_opentelemetry_otlp.gemfile.lock b/gemfiles/ruby_4.0_opentelemetry_otlp.gemfile.lock index b90cffd054c..ad2554f9452 100644 --- a/gemfiles/ruby_4.0_opentelemetry_otlp.gemfile.lock +++ b/gemfiles/ruby_4.0_opentelemetry_otlp.gemfile.lock @@ -61,7 +61,7 @@ GEM msgpack (1.8.0) mutex_m (0.3.0) opentelemetry-api (1.4.0) - opentelemetry-common (0.22.0) + opentelemetry-common (0.23.0) opentelemetry-api (~> 1.0) opentelemetry-exporter-otlp (0.30.0) google-protobuf (>= 3.18) diff --git a/gemfiles/ruby_4.0_opentelemetry_otlp_1_5.gemfile.lock b/gemfiles/ruby_4.0_opentelemetry_otlp_1_5.gemfile.lock index ab317048e69..3e4dea19023 100644 --- a/gemfiles/ruby_4.0_opentelemetry_otlp_1_5.gemfile.lock +++ b/gemfiles/ruby_4.0_opentelemetry_otlp_1_5.gemfile.lock @@ -61,7 +61,7 @@ GEM msgpack (1.8.0) mutex_m (0.3.0) opentelemetry-api (1.7.0) - opentelemetry-common (0.22.0) + opentelemetry-common (0.23.0) opentelemetry-api (~> 1.0) opentelemetry-exporter-otlp (0.30.0) google-protobuf (>= 3.18) diff --git a/lib/datadog/core/configuration/settings.rb b/lib/datadog/core/configuration/settings.rb index ad3249ccc55..8dca5ead520 100644 --- a/lib/datadog/core/configuration/settings.rb +++ b/lib/datadog/core/configuration/settings.rb @@ -12,6 +12,7 @@ require_relative '../../profiling/ext' require_relative '../../tracing/configuration/settings' +require_relative '../../opentelemetry/configuration/settings' module Datadog module Core @@ -1030,6 +1031,8 @@ def initialize(*_) # TODO: Tracing should manage its own settings. # Keep this extension here for now to keep things working. extend Datadog::Tracing::Configuration::Settings + + extend Datadog::OpenTelemetry::Configuration::Settings end # standard:enable Metrics/BlockLength end diff --git a/lib/datadog/core/configuration/supported_configurations.rb b/lib/datadog/core/configuration/supported_configurations.rb index a7aa85387cb..ab2f8a2f578 100644 --- a/lib/datadog/core/configuration/supported_configurations.rb +++ b/lib/datadog/core/configuration/supported_configurations.rb @@ -56,6 +56,7 @@ module Configuration "DD_INTEGRATION_SERVICE" => {version: ["A"]}, "DD_LOGS_INJECTION" => {version: ["A"]}, "DD_METRIC_AGENT_PORT" => {version: ["A"]}, + "DD_METRICS_OTEL_ENABLED" => {version: ["A"]}, "DD_PROFILING_ALLOCATION_ENABLED" => {version: ["A"]}, "DD_PROFILING_DIR_INTERRUPTION_WORKAROUND_ENABLED" => {version: ["A"]}, "DD_PROFILING_ENABLED" => {version: ["A"]}, @@ -313,6 +314,18 @@ module Configuration "DD_TRACE_WATERDROP_ENABLED" => {version: ["A"]}, "DD_TRACE_X_DATADOG_TAGS_MAX_LENGTH" => {version: ["A"]}, "DD_VERSION" => {version: ["A"]}, + "OTEL_EXPORTER_OTLP_ENDPOINT" => {version: ["A"]}, + "OTEL_EXPORTER_OTLP_HEADERS" => {version: ["A"]}, + "OTEL_EXPORTER_OTLP_METRICS_ENDPOINT" => {version: ["A"]}, + "OTEL_EXPORTER_OTLP_METRICS_HEADERS" => {version: ["A"]}, + "OTEL_EXPORTER_OTLP_METRICS_PROTOCOL" => {version: ["A"]}, + "OTEL_EXPORTER_OTLP_METRICS_TEMPORALITY_PREFERENCE" => {version: ["A"]}, + "OTEL_EXPORTER_OTLP_METRICS_TIMEOUT" => {version: ["A"]}, + "OTEL_EXPORTER_OTLP_PROTOCOL" => {version: ["A"]}, + "OTEL_EXPORTER_OTLP_TIMEOUT" => {version: ["A"]}, + "OTEL_METRIC_EXPORT_INTERVAL" => {version: ["A"]}, + "OTEL_METRIC_EXPORT_TIMEOUT" => {version: ["A"]}, + "OTEL_METRICS_EXPORTER" => {version: ["A"]}, "OTEL_TRACES_SAMPLER_ARG" => {version: ["A"]}}.freeze ALIASES = diff --git a/lib/datadog/opentelemetry.rb b/lib/datadog/opentelemetry.rb index 11760ab9751..db69e655420 100644 --- a/lib/datadog/opentelemetry.rb +++ b/lib/datadog/opentelemetry.rb @@ -22,6 +22,8 @@ require_relative 'opentelemetry/sdk/configurator' if defined?(OpenTelemetry::SDK) require_relative 'opentelemetry/sdk/trace/span' if defined?(OpenTelemetry::SDK) +require_relative 'opentelemetry/metrics' if defined?(OpenTelemetry::SDK::Metrics) + module Datadog # Datadog OpenTelemetry integration. module OpenTelemetry @@ -47,6 +49,7 @@ def logger # Currently, this closely translates to Datadog's partial flushing. # # @see OpenTelemetry::SDK::Trace::SpanProcessor#on_finish + Datadog.configure do |c| c.tracing.partial_flush.enabled = true end diff --git a/lib/datadog/opentelemetry/configuration/settings.rb b/lib/datadog/opentelemetry/configuration/settings.rb new file mode 100644 index 00000000000..03ba5570beb --- /dev/null +++ b/lib/datadog/opentelemetry/configuration/settings.rb @@ -0,0 +1,159 @@ +# frozen_string_literal: true + +require_relative '../../core/configuration/ext' + +module Datadog + module OpenTelemetry + module Configuration + module Settings + def self.extended(base) + base = base.singleton_class unless base.is_a?(Class) + add_settings!(base) + end + + def self.normalize_temporality_preference(env_var_name) + proc do |value| + if value && value.to_s.downcase != 'delta' && value.to_s.downcase != 'cumulative' + Datadog.logger.warn("#{env_var_name}=#{value} is not supported. Using delta instead.") + 'delta' + else + value + end + end + end + + def self.normalize_protocol(env_var_name) + proc do |value| + if value && value.to_s.downcase != 'http/protobuf' + Datadog.logger.warn("#{env_var_name}=#{value} is not supported. Using http/protobuf instead.") + end + 'http/protobuf' + end + end + + def self.headers_parser(env_var_name) + lambda do |value| + return {} if value.nil? || value.empty? + + headers = {} + header_items = value.split(',') + header_items.each do |key_value| + key, header_value = key_value.split('=', 2) + # If header is malformed, return an empty hash + if key.nil? || header_value.nil? + Datadog.logger.warn("#{env_var_name} has malformed header: #{key_value.inspect}") + return {} + end + + key.strip! + header_value.strip! + if key.empty? || header_value.empty? + Datadog.logger.warn("#{env_var_name} has empty key or value in: #{key_value.inspect}") + return {} + end + + headers[key] = header_value + end + headers + end + end + + def self.add_settings!(base) + base.class_eval do + settings :opentelemetry do + settings :exporter do + option :protocol do |o| + o.type :string + o.setter(&Settings.normalize_protocol('OTEL_EXPORTER_OTLP_PROTOCOL')) + o.env 'OTEL_EXPORTER_OTLP_PROTOCOL' + o.default 'http/protobuf' + end + + option :timeout_millis do |o| + o.type :int + o.env 'OTEL_EXPORTER_OTLP_TIMEOUT' + o.default 10_000 + end + + option :headers do |o| + o.type :hash + o.env 'OTEL_EXPORTER_OTLP_HEADERS' + o.default { {} } + o.env_parser(&Settings.headers_parser('OTEL_EXPORTER_OTLP_HEADERS')) + end + + option :endpoint do |o| + o.type :string, nilable: true + o.env 'OTEL_EXPORTER_OTLP_ENDPOINT' + o.default nil + end + end + + settings :metrics do + # Metrics-specific options default to nil to detect unset state. + # If a metrics-specific env var (e.g., OTEL_EXPORTER_OTLP_METRICS_TIMEOUT) is not set, + # we fall back to the general OTLP env var (e.g., OTEL_EXPORTER_OTLP_TIMEOUT) per OpenTelemetry spec. + option :enabled do |o| + o.type :bool + o.env 'DD_METRICS_OTEL_ENABLED' + o.default false + end + + option :exporter do |o| + o.type :string + o.env 'OTEL_METRICS_EXPORTER' + o.default 'otlp' + end + + option :export_interval_millis do |o| + o.type :int + o.env 'OTEL_METRIC_EXPORT_INTERVAL' + o.default 10_000 + end + + option :export_timeout_millis do |o| + o.type :int + o.env 'OTEL_METRIC_EXPORT_TIMEOUT' + o.default 7_500 + end + + option :temporality_preference do |o| + o.type :string + o.env 'OTEL_EXPORTER_OTLP_METRICS_TEMPORALITY_PREFERENCE' + o.default 'delta' + o.setter(&Settings.normalize_temporality_preference('OTEL_EXPORTER_OTLP_METRICS_TEMPORALITY_PREFERENCE')) + end + + option :endpoint do |o| + o.type :string, nilable: true + o.env 'OTEL_EXPORTER_OTLP_METRICS_ENDPOINT' + o.default nil + end + + option :headers do |o| + o.type :hash, nilable: true + o.env 'OTEL_EXPORTER_OTLP_METRICS_HEADERS' + o.default nil + o.env_parser(&Settings.headers_parser('OTEL_EXPORTER_OTLP_METRICS_HEADERS')) + end + + option :timeout_millis do |o| + o.type :int, nilable: true + o.env 'OTEL_EXPORTER_OTLP_METRICS_TIMEOUT' + o.default nil + end + + option :protocol do |o| + o.type :string, nilable: true + o.env 'OTEL_EXPORTER_OTLP_METRICS_PROTOCOL' + o.default nil + o.setter(&Settings.normalize_protocol('OTEL_EXPORTER_OTLP_METRICS_PROTOCOL')) + end + end + end + end + end + end + end + end +end diff --git a/lib/datadog/opentelemetry/metrics.rb b/lib/datadog/opentelemetry/metrics.rb new file mode 100644 index 00000000000..3c6b00ed14f --- /dev/null +++ b/lib/datadog/opentelemetry/metrics.rb @@ -0,0 +1,110 @@ +# frozen_string_literal: true + +require_relative '../core/configuration/ext' + +module Datadog + module OpenTelemetry + class Metrics + EXPORTER_NONE = 'none' + + def self.initialize!(components) + new(components).configure_metrics_sdk + true + rescue => exc + components.logger.error("Failed to initialize OpenTelemetry metrics: #{exc.class}: #{exc}: #{exc.backtrace.join("\n")}") + false + end + + def initialize(components) + @logger = components.logger + @settings = components.settings + @agent_host = components.agent_settings.hostname + @agent_ssl = components.agent_settings.ssl + end + + def configure_metrics_sdk + provider = ::OpenTelemetry.meter_provider + provider.shutdown if provider.is_a?(::OpenTelemetry::SDK::Metrics::MeterProvider) + + # The OpenTelemetry SDK defaults to cumulative temporality, but Datadog prefers delta temporality. + # Here is an example of how this config is applied: https://github.com/open-telemetry/opentelemetry-ruby/blob/1933d4c18e5f5e45c53fa9e902e58aa91e85cc38/metrics_sdk/lib/opentelemetry/sdk/metrics/aggregation/sum.rb#L14 + if DATADOG_ENV['OTEL_EXPORTER_OTLP_METRICS_TEMPORALITY_PREFERENCE'].nil? + ENV['OTEL_EXPORTER_OTLP_METRICS_TEMPORALITY_PREFERENCE'] = 'delta' # rubocop:disable CustomCops/EnvUsageCop + end + + resource = create_resource + provider = ::OpenTelemetry::SDK::Metrics::MeterProvider.new(resource: resource) + configure_metric_reader(provider) + ::OpenTelemetry.meter_provider = provider + end + + private + + def create_resource + resource_attributes = {} + resource_attributes['host.name'] = Datadog::Core::Environment::Socket.hostname if @settings.tracing.report_hostname + + @settings.tags&.each do |key, value| + otel_key = case key + when 'service' then 'service.name' + when 'env' then 'deployment.environment' + when 'version' then 'service.version' + else key + end + resource_attributes[otel_key] = value + end + + resource_attributes['service.name'] = @settings.service_without_fallback || resource_attributes['service.name'] || Datadog::Core::Environment::Ext::FALLBACK_SERVICE_NAME + resource_attributes['deployment.environment'] = @settings.env if @settings.env + resource_attributes['service.version'] = @settings.version if @settings.version + + ::OpenTelemetry::SDK::Resources::Resource.create(resource_attributes) + end + + def configure_metric_reader(provider) + exporter_name = @settings.opentelemetry.metrics.exporter + return if exporter_name == EXPORTER_NONE + + configure_otlp_exporter(provider) + rescue => e + @logger.warn("Failed to configure OTLP metrics exporter: #{e.class}: #{e}") + end + + def resolve_metrics_endpoint + metrics_config = @settings.opentelemetry.metrics + exporter_config = @settings.opentelemetry.exporter + + return metrics_config.endpoint if metrics_config.endpoint + return exporter_config.endpoint if exporter_config.endpoint + "#{@agent_ssl ? "https" : "http"}://#{@agent_host}:4318/v1/metrics" + end + + def configure_otlp_exporter(provider) + require 'opentelemetry/exporter/otlp_metrics' + require_relative 'sdk/metrics_exporter' + + metrics_config = @settings.opentelemetry.metrics + exporter_config = @settings.opentelemetry.exporter + timeout = metrics_config.timeout_millis || exporter_config.timeout_millis + headers = metrics_config.headers || exporter_config.headers || {} + + protocol = metrics_config.protocol || exporter_config.protocol + exporter = Datadog::OpenTelemetry::SDK::MetricsExporter.new( + endpoint: resolve_metrics_endpoint, + timeout: timeout / 1000.0, + headers: headers, + protocol: protocol + ) + + reader = ::OpenTelemetry::SDK::Metrics::Export::PeriodicMetricReader.new( + exporter: exporter, + export_interval_millis: metrics_config.export_interval_millis, + export_timeout_millis: metrics_config.export_timeout_millis + ) + provider.add_metric_reader(reader) + rescue LoadError => e + @logger.warn("Could not load OTLP metrics exporter: #{e.class}: #{e}") + end + end + end +end diff --git a/lib/datadog/opentelemetry/sdk/configurator.rb b/lib/datadog/opentelemetry/sdk/configurator.rb index 7426bfb825f..414a521bd5d 100644 --- a/lib/datadog/opentelemetry/sdk/configurator.rb +++ b/lib/datadog/opentelemetry/sdk/configurator.rb @@ -30,7 +30,31 @@ def wrapped_exporters_from_env [SpanProcessor.new] end - ::OpenTelemetry::SDK::Configurator.prepend(self) + def metrics_configuration_hook + components = Datadog.send(:components) + return super unless components.settings.opentelemetry.metrics.enabled + + begin + require 'opentelemetry-metrics-sdk' + rescue LoadError => exc + components.logger.warn("Failed to load OpenTelemetry metrics gems: #{exc.class}: #{exc}") + return super + end + + success = Datadog::OpenTelemetry::Metrics.initialize!(components) + super unless success + end + + # Prepend to ConfiguratorPatch (not Configurator) so our hook runs first. + begin + require 'opentelemetry-metrics-sdk' if defined?(OpenTelemetry::SDK) && !defined?(OpenTelemetry::SDK::Metrics::ConfiguratorPatch) + rescue LoadError + end + + if defined?(::OpenTelemetry::SDK::Metrics::ConfiguratorPatch) + ::OpenTelemetry::SDK::Metrics::ConfiguratorPatch.prepend(self) unless ::OpenTelemetry::SDK::Metrics::ConfiguratorPatch.ancestors.include?(self) + end + ::OpenTelemetry::SDK::Configurator.prepend(self) unless ::OpenTelemetry::SDK::Configurator.ancestors.include?(self) end end end diff --git a/lib/datadog/opentelemetry/sdk/metrics_exporter.rb b/lib/datadog/opentelemetry/sdk/metrics_exporter.rb new file mode 100644 index 00000000000..e8b445d8658 --- /dev/null +++ b/lib/datadog/opentelemetry/sdk/metrics_exporter.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +require 'opentelemetry/exporter/otlp_metrics' + +module Datadog + module OpenTelemetry + module SDK + class MetricsExporter < ::OpenTelemetry::Exporter::OTLP::Metrics::MetricsExporter + METRIC_EXPORT_ATTEMPTS = 'otel.metrics_export_attempts' + METRIC_EXPORT_SUCCESSES = 'otel.metrics_export_successes' + METRIC_EXPORT_FAILURES = 'otel.metrics_export_failures' + + def initialize(endpoint:, timeout:, headers:, protocol:) + super(endpoint: endpoint, timeout: timeout, headers: headers) + @telemetry_tags = {'protocol' => protocol, 'encoding' => 'protobuf'} + end + + def export(metrics, timeout: nil) + telemetry&.inc('tracers', METRIC_EXPORT_ATTEMPTS, 1, tags: @telemetry_tags) + result = super + metric_name = (result == 0) ? METRIC_EXPORT_SUCCESSES : METRIC_EXPORT_FAILURES + telemetry&.inc('tracers', metric_name, 1, tags: @telemetry_tags) + result + rescue => e + Datadog.logger.error("Failed to export OpenTelemetry Metrics: #{e.class}: #{e}") + telemetry&.inc('tracers', METRIC_EXPORT_FAILURES, 1, tags: @telemetry_tags) + raise + end + + private + + def telemetry + Datadog.send(:components).telemetry + end + end + end + end +end diff --git a/sig/datadog/core/configuration/settings.rbs b/sig/datadog/core/configuration/settings.rbs index 46a8f69942a..573d8ee3feb 100644 --- a/sig/datadog/core/configuration/settings.rbs +++ b/sig/datadog/core/configuration/settings.rbs @@ -147,6 +147,8 @@ module Datadog def open_feature: () -> _OpenFeature def tracing: () -> untyped + + def opentelemetry: () -> OpenTelemetry::Configuration::Settings::_OpenTelemetry end end end diff --git a/sig/datadog/opentelemetry/configuration/settings.rbs b/sig/datadog/opentelemetry/configuration/settings.rbs new file mode 100644 index 00000000000..bba6b8fa91b --- /dev/null +++ b/sig/datadog/opentelemetry/configuration/settings.rbs @@ -0,0 +1,58 @@ +module Datadog + module OpenTelemetry + module Configuration + module Settings + def self.extended: (::Class | ::Module base) -> void + + def self.add_settings!: (untyped base) -> void + + def self.normalize_temporality_preference: (::String env_var_name) -> untyped + + def self.normalize_protocol: (::String env_var_name) -> untyped + + def self.headers_parser: (::String env_var_name) -> untyped + + # DSL methods dynamically added via class_eval + def self.settings: (untyped name) ?{ (untyped) -> void } -> untyped + + def self.option: (untyped name) ?{ (untyped) -> void } -> untyped + + interface _OpenTelemetry + def exporter: () -> _Exporter + + def metrics: () -> _Metrics + end + + interface _Exporter + def protocol: () -> ::String + + def timeout: () -> ::Integer + + def headers: () -> ::Hash[untyped, untyped] + + def endpoint: () -> ::String? + end + + interface _Metrics + def enabled: () -> bool + + def exporter: () -> ::String + + def export_interval: () -> ::Integer + + def export_timeout: () -> ::Integer + + def temporality_preference: () -> ::String + + def endpoint: () -> ::String? + + def headers: () -> ::Hash[untyped, untyped]? + + def timeout: () -> ::Integer? + + def protocol: () -> ::String? + end + end + end + end +end diff --git a/sig/datadog/opentelemetry/metrics.rbs b/sig/datadog/opentelemetry/metrics.rbs new file mode 100644 index 00000000000..4de85a89b71 --- /dev/null +++ b/sig/datadog/opentelemetry/metrics.rbs @@ -0,0 +1,23 @@ +module Datadog + module OpenTelemetry + class Metrics + EXPORTER_NONE: ::String + + def self.initialize!: (Core::Configuration::Components components) -> bool + + def initialize: (Core::Configuration::Components components) -> void + + def configure_metrics_sdk: () -> void + + private + + def create_resource: () -> ::OpenTelemetry::SDK::Resources::Resource + + def configure_metric_reader: (::OpenTelemetry::SDK::Metrics::MeterProvider provider) -> void + + def resolve_metrics_endpoint: () -> ::String + + def configure_otlp_exporter: (::OpenTelemetry::SDK::Metrics::MeterProvider provider) -> void + end + end +end diff --git a/sig/datadog/opentelemetry/sdk/metrics_exporter.rbs b/sig/datadog/opentelemetry/sdk/metrics_exporter.rbs new file mode 100644 index 00000000000..f3eef0ea89b --- /dev/null +++ b/sig/datadog/opentelemetry/sdk/metrics_exporter.rbs @@ -0,0 +1,24 @@ +module Datadog + module OpenTelemetry + module SDK + class MetricsExporter < ::OpenTelemetry::Exporter::OTLP::Metrics::MetricsExporter + @telemetry_tags: ::Hash[::String, ::String] + + METRIC_EXPORT_ATTEMPTS: "otel.metrics_export_attempts" + + METRIC_EXPORT_SUCCESSES: "otel.metrics_export_successes" + + METRIC_EXPORT_FAILURES: "otel.metrics_export_failures" + + def initialize: (endpoint: ::String, timeout: ::Float, headers: ::Hash[::String, any], protocol: ::String) -> void + + def export: (untyped metrics, ?timeout: ::Float?) -> Integer + + private + + def telemetry: () -> Core::Telemetry::Component? + end + end + end +end + diff --git a/spec/datadog/opentelemetry/metrics_spec.rb b/spec/datadog/opentelemetry/metrics_spec.rb new file mode 100644 index 00000000000..fb14560fb91 --- /dev/null +++ b/spec/datadog/opentelemetry/metrics_spec.rb @@ -0,0 +1,365 @@ +# frozen_string_literal: true + +require 'spec_helper' + +# OpenTelemetry metrics SDK requires Ruby >= 3.1 +if Gem::Version.new(RUBY_VERSION) >= Gem::Version.new('3.1') + require 'opentelemetry/sdk' + require 'opentelemetry-metrics-sdk' + require 'opentelemetry/exporter/otlp_metrics' +end + +require 'datadog/opentelemetry' +require 'datadog/core/configuration/settings' +require 'net/http' +require 'json' + +RSpec.describe 'OpenTelemetry Metrics Integration', ruby: '>= 3.1' do + let(:default_otlp_http_port) { 4318 } + let(:provider) { ::OpenTelemetry.meter_provider } + let(:reader) { provider.metric_readers.first } + let(:exporter) { reader.instance_variable_get(:@exporter) } + let(:resource) { provider.instance_variable_get(:@resource) } + let(:attributes) { resource.attribute_enumerator.to_h } + let(:metrics_settings) { Datadog.configuration.opentelemetry.metrics } + + before do + clear_testagent_metrics + end + + after do + # Ensures background threads collecting metrics are shutdown. + provider.shutdown if provider.is_a?(::OpenTelemetry::SDK::Metrics::MeterProvider) + end + + def agent_host + Datadog.send(:components).agent_settings.hostname + end + + def clear_testagent_metrics + uri = URI("http://#{agent_host}:#{default_otlp_http_port}/test/session/clear") + Net::HTTP.post_form(uri, {}) + rescue => e + raise "Error clearing testagent metrics: #{e.class}: #{e}" + end + + def get_testagent_metrics + uri = URI("http://#{agent_host}:#{default_otlp_http_port}/test/session/metrics") + + try_wait_until(seconds: 2) do + response = Net::HTTP.get_response(uri) + next unless response.code == '200' + + parsed = JSON.parse(response.body, symbolize_names: false) + next parsed if parsed.is_a?(Array) && !parsed.empty? + end + end + + def find_metric(name) + get_testagent_metrics.each do |payload| + payload['resource_metrics']&.each do |rm| + rm['scope_metrics']&.each do |sm| + sm['metrics']&.each do |metric| + return metric if metric['name'] == name + end + end + end + end + nil + end + + def find_attribute_by_key(attributes, key) + attr = attributes&.find { |a| a['key'] == key } + attr&.dig('value', 'string_value') || attr&.dig('value', 'int_value') || attr&.dig('value', 'double_value') + end + + def setup_metrics(env_overrides = {}, &config_block) + ClimateControl.modify({ + 'DD_METRICS_OTEL_ENABLED' => 'true', + 'DD_AGENT_HOST' => agent_host, + }.merge(env_overrides)) do + # Reset Datadog to ensure components are reinitialized with the new environment variables + Datadog.send(:reset!) + # Set programmatic configurations from tests + Datadog.configure do |c| + config_block&.call(c) + end + # Enable OpenTelemetry SDK support (which will use the Datadog metrics hook if enabled) + OpenTelemetry::SDK.configure + end + end + + describe 'Basic Functionality' do + it 'exports counter metrics' do + setup_metrics + provider.meter('app').create_counter('requests_myapp').add(5) + provider.force_flush + + metric = find_metric('requests_myapp') + expect(metric['sum']['data_points'].first['as_int'].to_i).to eq(5) + end + + it 'exports histogram metrics' do + setup_metrics + provider.meter('app').create_histogram('duration').record(100) + provider.force_flush + + metric = find_metric('duration') + expect(metric['histogram']['data_points'].first['sum']).to eq(100.0) + end + + it 'exports gauge metrics' do + setup_metrics('OTEL_EXPORTER_OTLP_METRICS_TEMPORALITY_PREFERENCE' => 'cumulative') + gauge = provider.meter('app').create_gauge('temperature') + + gauge.record(72) + gauge.record(72) + provider.force_flush + + metric = find_metric('temperature') + expect(metric['gauge']['data_points'].length).to eq(1) + value = metric['gauge']['data_points'].first['as_int']&.to_i + expect(value).to eq(72) + end + + it 'exports updowncounter metrics' do + setup_metrics + provider.meter('app').create_up_down_counter('queue').add(10) + provider.force_flush + + metric = find_metric('queue') + expect(metric['sum']['data_points'].first['as_int'].to_i).to eq(10) + end + + it 'handles multiple metric types' do + setup_metrics + meter = provider.meter('app') + meter.create_histogram('size').record(100) + meter.create_counter('requests.monkey').add(10) + meter.create_gauge('memory').record(100) + provider.force_flush + + size_metric = find_metric('size') + expect(size_metric['histogram']['data_points'].first['sum']).to eq(100.0) + + requests_metric = find_metric('requests.monkey') + expect(requests_metric['sum']['data_points'].first['as_int'].to_i).to eq(10) + + memory_metric = find_metric('memory') + expect(memory_metric['gauge']['data_points'].length).to eq(1) + + gauge_value = memory_metric['gauge']['data_points'].first['as_int']&.to_i + expect(gauge_value).to eq(100) + end + end + + describe 'Resource Attributes' do + it 'includes service name, version, and environment from Datadog config' do + setup_metrics( + 'DD_SERVICE' => 'custom-service', + 'DD_VERSION' => '2.0.0', + 'DD_ENV' => 'production', + 'DD_TRACE_REPORT_HOSTNAME' => 'true', + ) + + expect(attributes['service.name']).to eq('custom-service') + expect(attributes['service.version']).to eq('2.0.0') + expect(attributes['deployment.environment']).to eq('production') + expect(attributes['host.name']).to eq(Datadog::Core::Environment::Socket.hostname) + end + + it 'includes custom tags as resource attributes' do + setup_metrics('DD_SERVICE' => 'unused-name', 'DD_VERSION' => 'x.y.z', 'DD_ENV' => 'unused-env', "DD_TAGS" => "host.name:unused-hostname") do |c| + c.service = 'test-service' + c.version = '1.0.0' + c.env = 'test' + c.tags = {'team' => 'backend', 'region' => 'us-east-1', 'host.name' => 'myhost'} + c.tracing.report_hostname = true + end + + expect(attributes['service.name']).to eq('test-service') + expect(attributes['service.version']).to eq('1.0.0') + expect(attributes['deployment.environment']).to eq('test') + expect(attributes['host.name']).to eq("myhost") + expect(attributes['team']).to eq('backend') + expect(attributes['region']).to eq('us-east-1') + end + + it 'applies fallback service name when neither DD_SERVICE nor service tag is set' do + setup_metrics + + expect(attributes['service.name']).to eq(Datadog::Core::Environment::Ext::FALLBACK_SERVICE_NAME) + end + end + + describe 'Configuration' do + let(:settings) { Datadog::Core::Configuration::Settings.new } + + describe 'default values' do + before { setup_metrics } + + it 'uses default endpoint' do + expect(exporter.instance_variable_get(:@uri).to_s).to eq("http://#{agent_host}:4318/v1/metrics") + end + + it 'uses default timeout' do + expect(exporter.instance_variable_get(:@timeout)).to eq(10.0) + end + + it 'uses default export interval' do + expect(reader.instance_variable_get(:@export_interval)).to eq(10.0) + end + + it 'uses default export timeout' do + expect(reader.instance_variable_get(:@export_timeout)).to eq(7.5) + end + end + + describe 'configuration priority' do + let(:env_vars) do + { + 'OTEL_EXPORTER_OTLP_ENDPOINT' => 'http://general:4317', + 'OTEL_EXPORTER_OTLP_PROTOCOL' => 'http/protobuf', + 'OTEL_EXPORTER_OTLP_TIMEOUT' => '8000', + 'OTEL_EXPORTER_OTLP_HEADERS' => 'general=value' + } + end + + before { setup_metrics(env_vars) } + + it 'uses the general OTLP endpoint' do + expect(exporter.instance_variable_get(:@uri).to_s).to eq('http://general:4317/v1/metrics') + end + + it 'uses the general OTLP timeout' do + expect(exporter.instance_variable_get(:@timeout)).to eq(8.0) + end + + it 'uses the general OTLP headers' do + expect(exporter.instance_variable_get(:@headers)['general']).to eq('value') + end + + context 'when metrics-specific configs are provided' do + let(:env_vars) do + super().merge( + 'DD_METRICS_OTEL_ENABLED' => 'true', + 'OTEL_EXPORTER_OTLP_METRICS_ENDPOINT' => 'http://metrics:4318/v1/metrics', + 'OTEL_EXPORTER_OTLP_METRICS_PROTOCOL' => 'http/protobuf', + 'OTEL_EXPORTER_OTLP_METRICS_TIMEOUT' => '5000', + 'OTEL_EXPORTER_OTLP_METRICS_HEADERS' => 'metrics=value', + 'OTEL_METRIC_EXPORT_INTERVAL' => '4000', + 'OTEL_METRIC_EXPORT_TIMEOUT' => '3000', + 'OTEL_EXPORTER_OTLP_PROTOCOL' => 'grpc', + ) + end + + it 'uses metrics-specific endpoint' do + expect(exporter.instance_variable_get(:@uri).to_s).to eq('http://metrics:4318/v1/metrics') + end + + it 'uses metrics-specific timeout' do + expect(exporter.instance_variable_get(:@timeout)).to eq(5.0) + end + + it 'uses metrics-specific headers' do + expect(exporter.instance_variable_get(:@headers)['metrics']).to eq('value') + end + + it 'uses metrics-specific export interval' do + expect(reader.instance_variable_get(:@export_interval)).to eq(4.0) + end + + it 'uses metrics-specific export timeout' do + expect(reader.instance_variable_get(:@export_timeout)).to eq(3.0) + end + end + end + + it 'parses multiple headers correctly' do + setup_metrics( + 'OTEL_EXPORTER_OTLP_HEADERS' => 'api-key=secret123,other-config-value=test-value' + ) + headers = exporter.instance_variable_get(:@headers) + expect(headers['api-key']).to eq('secret123') + expect(headers['other-config-value']).to eq('test-value') + end + + it 'returns empty hash when headers are malformed' do + setup_metrics( + 'OTEL_EXPORTER_OTLP_METRICS_HEADERS' => 'api-key=secret123,malformed' + ) + expect(metrics_settings.headers).to eq({}) + end + + it 'returns empty hash when header has empty key or value' do + setup_metrics( + 'OTEL_EXPORTER_OTLP_METRICS_HEADERS' => 'api-key=secret123,=value' + ) + expect(metrics_settings.headers).to eq({}) + end + + it 'uses OTLP exporter when configured' do + setup_metrics + exporter = provider.metric_readers.first.instance_variable_get(:@exporter) + expect(exporter).to be_a(::OpenTelemetry::Exporter::OTLP::Metrics::MetricsExporter) + + provider.meter('app').create_counter('test').add(1) + provider.force_flush + metric = find_metric('test') + expect(metric['sum']['data_points'].first['as_int'].to_i).to eq(1) + end + + it 'defaults to HTTP when protocol is set to grpc' do + setup_metrics( + 'OTEL_EXPORTER_OTLP_METRICS_PROTOCOL' => 'grpc' + ) + expect(metrics_settings.protocol).to eq('http/protobuf') + # Should use HTTP port (4318) and path (/v1/metrics) even though grpc was specified + expect(exporter.instance_variable_get(:@uri).to_s).to eq("http://#{agent_host}:4318/v1/metrics") + end + + it 'defaults to delta when temporality preference is invalid' do + setup_metrics( + 'OTEL_EXPORTER_OTLP_METRICS_TEMPORALITY_PREFERENCE' => 'invalid' + ) + expect(metrics_settings.temporality_preference).to eq('delta') + end + + it 'does not initialize when DD_METRICS_OTEL_ENABLED is false' do + setup_metrics('DD_METRICS_OTEL_ENABLED' => 'false', 'DD_SERVICE' => 'dd-service') + expect(attributes['service.name']).not_to eq('dd-service') + end + end + + describe 'Multiple Data Points' do + it 'supports multiple attributes and data points' do + setup_metrics + counter = provider.meter('app').create_counter('api') + counter.add(10, attributes: {'method' => 'GET'}) + counter.add(5, attributes: {'method' => 'POST'}) + provider.force_flush + + metric = find_metric('api') + data_points = metric['sum']['data_points'] + + get_point = data_points.find { |dp| find_attribute_by_key(dp['attributes'], 'method') == 'GET' } + post_point = data_points.find { |dp| find_attribute_by_key(dp['attributes'], 'method') == 'POST' } + expect(get_point['as_int'].to_i).to eq(10) + expect(post_point['as_int'].to_i).to eq(5) + end + end + + describe 'Lifecycle' do + it 'handles shutdown gracefully' do + setup_metrics + expect { provider.shutdown }.not_to raise_error + expect { provider.shutdown }.not_to raise_error + end + + it 'handles force_flush' do + setup_metrics + provider.meter('app').create_counter('test').add(1) + expect { provider.force_flush }.not_to raise_error + end + end +end diff --git a/supported-configurations.json b/supported-configurations.json index 5888fd7b377..db33a62d609 100644 --- a/supported-configurations.json +++ b/supported-configurations.json @@ -148,6 +148,9 @@ "DD_METRIC_AGENT_PORT": { "version": ["A"] }, + "DD_METRICS_OTEL_ENABLED": { + "version": ["A"] + }, "DD_PROFILING_ALLOCATION_ENABLED": { "version": ["A"] }, @@ -919,6 +922,42 @@ "DD_VERSION": { "version": ["A"] }, + "OTEL_EXPORTER_OTLP_ENDPOINT": { + "version": ["A"] + }, + "OTEL_EXPORTER_OTLP_HEADERS": { + "version": ["A"] + }, + "OTEL_EXPORTER_OTLP_METRICS_ENDPOINT": { + "version": ["A"] + }, + "OTEL_EXPORTER_OTLP_METRICS_HEADERS": { + "version": ["A"] + }, + "OTEL_EXPORTER_OTLP_METRICS_PROTOCOL": { + "version": ["A"] + }, + "OTEL_EXPORTER_OTLP_METRICS_TEMPORALITY_PREFERENCE": { + "version": ["A"] + }, + "OTEL_EXPORTER_OTLP_METRICS_TIMEOUT": { + "version": ["A"] + }, + "OTEL_EXPORTER_OTLP_PROTOCOL": { + "version": ["A"] + }, + "OTEL_EXPORTER_OTLP_TIMEOUT": { + "version": ["A"] + }, + "OTEL_METRIC_EXPORT_INTERVAL": { + "version": ["A"] + }, + "OTEL_METRIC_EXPORT_TIMEOUT": { + "version": ["A"] + }, + "OTEL_METRICS_EXPORTER": { + "version": ["A"] + }, "OTEL_TRACES_SAMPLER_ARG": { "version": ["A"] } diff --git a/vendor/rbs/opentelemetry/0/opentelemetry.rbs b/vendor/rbs/opentelemetry/0/opentelemetry.rbs new file mode 100644 index 00000000000..a01bed7fc83 --- /dev/null +++ b/vendor/rbs/opentelemetry/0/opentelemetry.rbs @@ -0,0 +1,41 @@ +# External OpenTelemetry types not available in RBS +module OpenTelemetry + def self.meter_provider: () -> untyped + + def self.meter_provider=: (untyped provider) -> void + + module SDK + module Resources + class Resource + def self.create: (::Hash[::String, untyped] attributes) -> untyped + end + end + + module Metrics + class MeterProvider + def initialize: (?resource: untyped) -> void + + def add_metric_reader: (untyped reader) -> void + + def shutdown: () -> void + end + + module Export + class PeriodicMetricReader + def initialize: (exporter: untyped, export_interval_millis: ::Integer, export_timeout_millis: ::Integer) -> void + end + end + end + end + + module Exporter + module OTLP + module Metrics + class MetricsExporter + def initialize: (endpoint: ::String, timeout: ::Float, headers: ::Hash[untyped, untyped]) -> void + end + end + end + end +end +