diff --git a/CHANGELOG.md b/CHANGELOG.md index df8598e..180d77c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,14 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [Unreleased] +## [Unreleased] TBD + +### Added +- Introduce new `:open_telemetry` target + +### Changed +- Removed dependency for `dogstatsd-ruby` gem. Added `dogstatsd-ruby` and `opentelemetry-*` gems to development/test dependencies for integration tests. + - Consumers are expected to explicitly define these dependencies in their projects. ## [1.1.4] diff --git a/Gemfile b/Gemfile index 0f12708..0808e90 100644 --- a/Gemfile +++ b/Gemfile @@ -6,7 +6,10 @@ source 'https://rubygems.org' gemspec gem 'dogstatsd-ruby' - +gem 'opentelemetry-exporter-otlp' +gem 'opentelemetry-exporter-otlp-metrics', '~> 0.3' +gem 'opentelemetry-metrics-sdk', '~> 0.5' +gem 'opentelemetry-sdk' gem 'rack' gem 'rake', '~> 13.0' gem 'rspec', '~> 3.0' diff --git a/Gemfile.lock b/Gemfile.lock index e0256f4..5274d7c 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -8,10 +8,56 @@ GEM remote: https://rubygems.org/ specs: ast (2.4.2) + bigdecimal (3.1.8) diff-lcs (1.5.1) dogstatsd-ruby (5.6.1) + google-protobuf (4.29.1) + bigdecimal + rake (>= 13) + google-protobuf (4.29.1-arm64-darwin) + bigdecimal + rake (>= 13) + google-protobuf (4.29.1-x86_64-linux) + bigdecimal + rake (>= 13) + googleapis-common-protos-types (1.16.0) + google-protobuf (>= 3.18, < 5.a) json (2.7.2) nio4r (2.7.1) + opentelemetry-api (1.4.0) + opentelemetry-common (0.21.0) + opentelemetry-api (~> 1.0) + opentelemetry-exporter-otlp (0.29.1) + google-protobuf (>= 3.18) + googleapis-common-protos-types (~> 1.3) + opentelemetry-api (~> 1.1) + opentelemetry-common (~> 0.20) + opentelemetry-sdk (~> 1.2) + opentelemetry-semantic_conventions + opentelemetry-exporter-otlp-metrics (0.3.0) + 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.2.0) + opentelemetry-api (~> 1.0) + opentelemetry-metrics-sdk (0.5.0) + 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.6.0) + opentelemetry-api (~> 1.1) + opentelemetry-common (~> 0.20) + opentelemetry-registry (~> 0.2) + opentelemetry-semantic_conventions + opentelemetry-semantic_conventions (1.10.1) + opentelemetry-api (~> 1.0) parallel (1.24.0) parser (3.3.1.0) ast (~> 2.4.1) @@ -64,6 +110,10 @@ PLATFORMS DEPENDENCIES dogstatsd-ruby + opentelemetry-exporter-otlp + opentelemetry-exporter-otlp-metrics (~> 0.3) + opentelemetry-metrics-sdk (~> 0.5) + opentelemetry-sdk puma-plugin-telemetry! rack rake (~> 13.0) diff --git a/README.md b/README.md index d0b6f66..8ddbfdc 100644 --- a/README.md +++ b/README.md @@ -53,14 +53,27 @@ Output telemetry as JSON to `STDOUT` Given gem provides built in target for Datadog StatsD client, that uses batch operation to publish metrics. -**NOTE** Be sure to have `dogstatsd` gem installed. - ```ruby config.add_target :dogstatsd, client: Datadog::Statsd.new ``` You can provide all the tags, namespaces, and other configuration options as always to `Datadog::Statsd.new` method. +### OpenTelemetry target + +Given gem provides built in target for OpenTelemetry Metrics SDK, that uses batch operations to publish metrics. + +```ruby + config.add_target :open_telemetry, meter_provider: OpenTelemetry.meter_provider +``` + +This target supports the following options: +| Option | Description | Default | Required | | +|------------|-----------------------------------------------------------------|---------|----------|---| +| prefix | Metric name prefix.
ex) prefix: 'puma' => 'puma.workers.booted' | nil | No | | +| suffix | Metric name suffix.
ex) suffix: 'v1' => 'workers.booted.v1' | nil | No | | +| attributes | Attributes to be included with the metric | {} | No | | + ### All available options For detailed documentation checkout [`Puma::Plugin::Telemetry::Config`](./lib/puma/plugin/telemetry/config.rb) class. diff --git a/lib/puma/plugin/telemetry.rb b/lib/puma/plugin/telemetry.rb index 235ecb2..4e0d764 100644 --- a/lib/puma/plugin/telemetry.rb +++ b/lib/puma/plugin/telemetry.rb @@ -7,6 +7,7 @@ require 'puma/plugin/telemetry/data' require 'puma/plugin/telemetry/targets/datadog_statsd_target' require 'puma/plugin/telemetry/targets/io_target' +require 'puma/plugin/telemetry/targets/open_telemetry_target' require 'puma/plugin/telemetry/config' module Puma diff --git a/lib/puma/plugin/telemetry/config.rb b/lib/puma/plugin/telemetry/config.rb index 1f26718..5efcbdf 100644 --- a/lib/puma/plugin/telemetry/config.rb +++ b/lib/puma/plugin/telemetry/config.rb @@ -32,7 +32,8 @@ class Config TARGETS = { dogstatsd: Telemetry::Targets::DatadogStatsdTarget, - io: Telemetry::Targets::IOTarget + io: Telemetry::Targets::IOTarget, + open_telemetry: Telemetry::Targets::OpenTelemetryTarget }.freeze # Whenever telemetry should run with puma diff --git a/lib/puma/plugin/telemetry/targets/open_telemetry_target.rb b/lib/puma/plugin/telemetry/targets/open_telemetry_target.rb new file mode 100644 index 0000000..cdea1ae --- /dev/null +++ b/lib/puma/plugin/telemetry/targets/open_telemetry_target.rb @@ -0,0 +1,53 @@ +# frozen_string_literal: true + +module Puma + class Plugin + module Telemetry + module Targets + # Target wrapping OpenTelemetry Metrics client. + # + # ## Example + # + # require 'opentelemetry-metrics-sdk' + # + # OpenTelemetryTarget.new(meter_provider: OpenTelemetry.meter_provider, prefix: 'puma') + # + class OpenTelemetryTarget + def initialize(meter_provider:, prefix: nil, suffix: nil, force_flush: false, attributes: {}) + @meter_provider = meter_provider + @meter = meter_provider.meter('puma.telemetry') + @prefix = prefix + @suffix = suffix + @force_flush = force_flush + @attributes = attributes + @instruments = {} + end + + # We are using `gauge` metric type, which means that only the last value will get exported + # since the OpenTelemetry exporter aggregates metrics before sending them. + # + # This means that we could publish metrics from here several times + # before they get flushed from the aggregation thread, and when they + # do, only the last values will get sent. + # + # That's why we provide the option to explicitly call force_flush here, in order to persist + # all metrics, and not only the most recent ones. + # + # Note: Force flushing metrics every time can significantly impact performance + # + def call(telemetry) + telemetry.each do |metric, value| + instrument(metric).record(value, attributes: @attributes) + end + + @meter_provider.force_flush if @force_flush + end + + def instrument(metric) + @instruments[metric] ||= @meter.create_gauge([@prefix, metric, @suffix].compact.join('.')) + end + end + end + end + end +end diff --git a/spec/fixtures/open_telemetry.rb b/spec/fixtures/open_telemetry.rb new file mode 100644 index 0000000..f276244 --- /dev/null +++ b/spec/fixtures/open_telemetry.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +require 'opentelemetry/sdk' +require 'opentelemetry-metrics-sdk' + +# Custom console exporter to support flushing metrics. Console _pull_ exporter doesn't have a force_flush method like the typical exporters +class ConsoleMetricExporter < OpenTelemetry::SDK::Metrics::Export::ConsoleMetricPullExporter + def force_flush(timeout: nil) + pull + end +end + +OpenTelemetry::SDK.configure + +console_metric_exporter = ConsoleMetricExporter.new +OpenTelemetry.meter_provider.add_metric_reader(console_metric_exporter) + +app { |_env| [200, {}, ['embedded app']] } +lowlevel_error_handler { |_err| [500, {}, ['error page']] } + +threads 1, 1 + +bind "unix://#{ENV.fetch('BIND_PATH', nil)}" + +plugin 'telemetry' + +Puma::Plugin::Telemetry.configure do |config| + config.add_target :open_telemetry, meter_provider: OpenTelemetry.meter_provider + config.frequency = 0.2 + config.enabled = true + config.initial_delay = 2 +end diff --git a/spec/integration/plugin_spec.rb b/spec/integration/plugin_spec.rb index 81628c3..d70a058 100644 --- a/spec/integration/plugin_spec.rb +++ b/spec/integration/plugin_spec.rb @@ -95,6 +95,40 @@ class Plugin end end + context 'when open_telemetry target' do + let(:config) { 'open_telemetry' } + let(:expected_telemetry) do + { + 'workers.booted' => 1, + 'workers.total' => 1, + 'workers.spawned_threads' => 1, + 'workers.max_threads' => 1, + 'workers.requests_count' => 0, + 'queue.backlog' => 0, + 'queue.capacity' => 1, + } + end + + it "doesn't crash" do + total_metrics = 0 + matched_telemetry = {} + + while (line = @server.next_line) do + if line.include?('OpenTelemetry::SDK::Metrics::State::MetricData') + name = @server.next_line.match(/name="(.*)"/)[1] + + true until (line = @server.next_line).include?('value=') + value = line.match(/value=(.*)/)[1].to_i + + matched_telemetry[name] = value + + break if matched_telemetry.keys.size == expected_telemetry.keys.size + end + end + expect(matched_telemetry).to eq(expected_telemetry) + end + end + context 'when sockets telemetry' do let(:config) { 'sockets' } diff --git a/spec/puma/plugin/telemetry/config_spec.rb b/spec/puma/plugin/telemetry/config_spec.rb index 5c5a50d..06aaf91 100644 --- a/spec/puma/plugin/telemetry/config_spec.rb +++ b/spec/puma/plugin/telemetry/config_spec.rb @@ -45,6 +45,21 @@ module Telemetry end end + context 'when built in: Open Telemetry' do + let(:meter_provider) { double('otel meter provider', meter: double('otel meter')) } + + it 'adds new target' do + expect do + config.add_target(:open_telemetry, meter_provider: meter_provider) + end.to change(config.targets, :size).by(1) + end + + it 'adds new Open Telemetry Target' do + config.add_target(:open_telemetry, meter_provider: meter_provider) + expect(config.targets.first).to be_a(Telemetry::Targets::OpenTelemetryTarget) + end + end + context 'when custom' do let(:target) { proc { |telemetry| puts telemetry.inspect } }