diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 6d143411..ccd9eae1 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -471,7 +471,7 @@ jobs: COUCHBASE_GEM_PATH=$(realpath couchbase-*.gem) UNPACKED_GEM_PATH=$(gem unpack ${COUCHBASE_GEM_PATH} | grep "Unpacked gem" | cut -d "'" -f 2) gem unpack --spec --target ${UNPACKED_GEM_PATH} ${COUCHBASE_GEM_PATH} - ruby -i.bak -pe "gsub(/gemspec/, 'gem \"couchbase\", path: \"${UNPACKED_GEM_PATH}\"')" Gemfile + ruby -i.bak -pe "gsub(/gemspec$/, 'gem \"couchbase\", path: \"${UNPACKED_GEM_PATH}\"')" Gemfile bundle install bundle exec ruby -r bundler/setup -r couchbase -e 'pp Couchbase::VERSION, Couchbase::BUILD_INFO' - name: Test diff --git a/Gemfile b/Gemfile index fb7d7fa1..26b3f6f2 100644 --- a/Gemfile +++ b/Gemfile @@ -18,6 +18,7 @@ source "https://rubygems.org" git_source(:github) { |repo_name| "https://github.com/#{repo_name}" } gemspec +gemspec path: "couchbase-opentelemetry" gem "rake" @@ -35,6 +36,8 @@ group :development do gem "minitest", "< 6.0" gem "minitest-reporters" gem "mutex_m" + gem "opentelemetry-metrics-sdk" + gem "opentelemetry-sdk" gem "rack" gem "reek" gem "rubocop", require: false diff --git a/couchbase-opentelemetry/couchbase-opentelemetry.gemspec b/couchbase-opentelemetry/couchbase-opentelemetry.gemspec new file mode 100644 index 00000000..3dc74d82 --- /dev/null +++ b/couchbase-opentelemetry/couchbase-opentelemetry.gemspec @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +# Copyright 2026-Present Couchbase, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +lib = File.expand_path("lib", __dir__) +$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) + +Gem::Specification.new do |spec| + spec.name = "couchbase-opentelemetry" + spec.version = "0.0.1" + spec.authors = ["Sergey Avseyev"] + spec.email = ["sergey.avseyev@gmail.com"] + spec.summary = "OpenTelemetry integration for the Couchbase Ruby Client" + spec.description = "OpenTelemetry integration for the Couchbase Ruby Client" + spec.homepage = "https://www.couchbase.com" + spec.license = "Apache-2.0" + spec.required_ruby_version = "> 3.1" + + spec.metadata = { + "homepage_uri" => "https://docs.couchbase.com/ruby-sdk/current/hello-world/start-using-sdk.html", + "bug_tracker_uri" => "https://jira.issues.couchbase.com/browse/RCBC", + "mailing_list_uri" => "https://www.couchbase.com/forums/c/ruby-sdk", + "source_code_uri" => "https://github.com/couchbase/couchbase-ruby-client/tree/#{spec.version}", + "changelog_uri" => "https://github.com/couchbase/couchbase-ruby-client/releases/tag/#{spec.version}", + "documentation_uri" => "https://docs.couchbase.com/sdk-api/couchbase-ruby-client-#{spec.version}/index.html", + "github_repo" => "https://github.com/couchbase/couchbase-ruby-client", + "rubygems_mfa_required" => "true", + } + + spec.files = Dir.glob([ + "lib/**/*.rb", + ], File::FNM_DOTMATCH).select { |path| File.file?(path) } + spec.bindir = "exe" + spec.executables = spec.files.grep(/^exe\//) { |f| File.basename(f) } + spec.require_paths = ["lib"] + + spec.add_dependency "concurrent-ruby", "~> 1.3" + spec.add_dependency "opentelemetry-api", "~> 1.7" + spec.add_dependency "opentelemetry-metrics-api", "~> 0.4.0" +end diff --git a/couchbase-opentelemetry/lib/couchbase/metrics/open_telemetry_meter.rb b/couchbase-opentelemetry/lib/couchbase/metrics/open_telemetry_meter.rb new file mode 100644 index 00000000..82b5525d --- /dev/null +++ b/couchbase-opentelemetry/lib/couchbase/metrics/open_telemetry_meter.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +# Copyright 2026-Present Couchbase, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +require "couchbase/metrics/meter" +require "couchbase/errors" +require_relative "open_telemetry_value_recorder" + +require "opentelemetry-metrics-api" + +module Couchbase + module Metrics + class OpenTelemetryMeter < Meter + def initialize(meter_provider) + super() + @histogram_cache = Concurrent::Map.new + begin + @wrapped = meter_provider.meter("com.couchbase.client/ruby") + rescue StandardError => e + raise Error::MeterError.new("Failed to create OpenTelemetry Meter: #{e.message}", nil, e) + end + end + + def value_recorder(name, tags) + unit = tags.delete("__unit") + + otel_histogram = @histogram_cache.compute_if_absent(name) do + @wrapped.create_histogram(name, unit: unit) + end + + OpenTelemetryValueRecorder.new(otel_histogram, tags, unit: unit) + rescue StandardError => e + raise Error::MeterError.new("Failed to create OpenTelemetry Histogram: #{e.message}", nil, e) + end + end + end +end diff --git a/couchbase-opentelemetry/lib/couchbase/metrics/open_telemetry_value_recorder.rb b/couchbase-opentelemetry/lib/couchbase/metrics/open_telemetry_value_recorder.rb new file mode 100644 index 00000000..28bbfd69 --- /dev/null +++ b/couchbase-opentelemetry/lib/couchbase/metrics/open_telemetry_value_recorder.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +# Copyright 2026-Present Couchbase, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +require "couchbase/metrics/value_recorder" + +module Couchbase + module Metrics + class OpenTelemetryValueRecorder < ValueRecorder + def initialize(recorder, tags, unit: nil) + super() + @wrapped = recorder + @tags = tags + @unit = unit + end + + def record_value(value) + value = + case @unit + when "s" + value / 1_000_000.0 + else + value + end + + @wrapped.record(value, attributes: @tags) + end + end + end +end diff --git a/couchbase-opentelemetry/lib/couchbase/tracing/open_telemetry_request_span.rb b/couchbase-opentelemetry/lib/couchbase/tracing/open_telemetry_request_span.rb new file mode 100644 index 00000000..af17b6d6 --- /dev/null +++ b/couchbase-opentelemetry/lib/couchbase/tracing/open_telemetry_request_span.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +# Copyright 2026-Present Couchbase, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +require "couchbase/tracing/request_span" + +require "opentelemetry-api" + +module Couchbase + module Tracing + class OpenTelemetryRequestSpan < RequestSpan + def initialize(span) + super() + + @wrapped = span + end + + def set_attribute(key, value) + @wrapped.set_attribute(key, value) + end + + def status=(status_code) + @wrapped.status = if status_code == :ok + ::OpenTelemetry::Trace::Status.ok + elsif status_code == :error + ::OpenTelemetry::Trace::Status.error + else + ::OpenTelemetry::Trace::Status.unset + end + end + + def finish(end_timestamp: nil) + @wrapped.finish(end_timestamp: end_timestamp) + end + end + end +end diff --git a/couchbase-opentelemetry/lib/couchbase/tracing/open_telemetry_request_tracer.rb b/couchbase-opentelemetry/lib/couchbase/tracing/open_telemetry_request_tracer.rb new file mode 100644 index 00000000..776f7e70 --- /dev/null +++ b/couchbase-opentelemetry/lib/couchbase/tracing/open_telemetry_request_tracer.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +# Copyright 2026-Present Couchbase, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +require "opentelemetry-api" + +require "couchbase/tracing/request_tracer" +require "couchbase/errors" +require_relative "open_telemetry_request_span" + +module Couchbase + module Tracing + class OpenTelemetryRequestTracer < RequestTracer + def initialize(tracer_provider) + super() + begin + @wrapped = tracer_provider.tracer("com.couchbase.client/ruby") + rescue StandardError => e + raise Error::TracerError.new("Failed to create OpenTelemetry tracer: #{e.message}", nil, e) + end + end + + def request_span(name, parent: nil, start_timestamp: nil) + parent_context = parent.nil? ? nil : ::OpenTelemetry::Trace.context_with_span(parent.instance_variable_get(:@wrapped)) + OpenTelemetryRequestSpan.new( + @wrapped.start_span( + name, + with_parent: parent_context, + start_timestamp: start_timestamp, + kind: :client, + ), + ) + rescue StandardError => e + raise Error::TracerError.new("Failed to create OpenTelemetry span: #{e.message}", nil, e) + end + end + end +end diff --git a/ext/rcb_query.cxx b/ext/rcb_query.cxx index 72fcf063..d14be73c 100644 --- a/ext/rcb_query.cxx +++ b/ext/rcb_query.cxx @@ -536,8 +536,8 @@ cb_Backend_query_index_build_deferred(VALUE self, cb_throw_error( resp.ctx, fmt::format(R"(unable to build deferred indexes on the bucket "{}" ({}: {}))", + req.bucket_name, first_error.code, - first_error.message)); } else { cb_throw_error( diff --git a/lib/couchbase/errors.rb b/lib/couchbase/errors.rb index c1bc7170..61008b94 100644 --- a/lib/couchbase/errors.rb +++ b/lib/couchbase/errors.rb @@ -410,5 +410,11 @@ class NoEnvironment < CouchbaseError class ClusterClosed < CouchbaseError end + + class TracerError < CouchbaseError + end + + class MeterError < CouchbaseError + end end end diff --git a/lib/couchbase/tracing/noop_span.rb b/lib/couchbase/tracing/noop_span.rb index be598b50..9c3b1c3a 100644 --- a/lib/couchbase/tracing/noop_span.rb +++ b/lib/couchbase/tracing/noop_span.rb @@ -19,13 +19,11 @@ module Couchbase module Tracing class NoopSpan < RequestSpan - def set_attribute(*) - nil - end + def set_attribute(*); end - def finish(*) - nil - end + def status=(*); end + + def finish(*); end end end end diff --git a/lib/couchbase/tracing/request_span.rb b/lib/couchbase/tracing/request_span.rb index 8e7c42ff..d70f3814 100644 --- a/lib/couchbase/tracing/request_span.rb +++ b/lib/couchbase/tracing/request_span.rb @@ -22,6 +22,10 @@ def set_attribute(key, value) raise NotImplementedError, "The RequestSpan does not implement #set_attribute" end + def status=(status_code) + raise NotImplementedError, "The RequestSpan does not implement #status=" + end + def finish(end_timestamp: nil) raise NotImplementedError, "The RequestSpan does not implement #finish" end diff --git a/lib/couchbase/tracing/threshold_logging_span.rb b/lib/couchbase/tracing/threshold_logging_span.rb index 8ef94513..36cf474c 100644 --- a/lib/couchbase/tracing/threshold_logging_span.rb +++ b/lib/couchbase/tracing/threshold_logging_span.rb @@ -63,6 +63,8 @@ def set_attribute(key, value) end end + def status=(*); end + def finish(end_timestamp: nil) duration_us = (((end_timestamp || Time.now) - @start_timestamp) * 1_000_000).round case name diff --git a/lib/couchbase/utils/observability.rb b/lib/couchbase/utils/observability.rb index 8bfdfe12..5deaf452 100644 --- a/lib/couchbase/utils/observability.rb +++ b/lib/couchbase/utils/observability.rb @@ -47,6 +47,8 @@ def record_operation(op_name, parent_span, receiver, service = nil) rescue StandardError => e handler.add_error(e) raise e + else + handler.set_success ensure handler.finish end @@ -144,7 +146,12 @@ def add_retries(retries) @op_span.set_attribute(ATTR_RETRIES, retries.to_i) end + def set_success + @op_span.status = :ok + end + def add_error(error) + @op_span.status = :error @meter_attributes[ATTR_ERROR_TYPE] = if error.is_a?(Couchbase::Error::CouchbaseError) || error.is_a?(Couchbase::Error::InvalidArgument) error.class.name.split("::").last @@ -201,6 +208,7 @@ def convert_backend_timestamp(backend_timestamp) def create_meter_attributes attrs = { ATTR_SYSTEM_NAME => ATTR_VALUE_SYSTEM_NAME, + ATTR_RESERVED_UNIT => ATTR_VALUE_RESERVED_UNIT_SECONDS, } attrs[ATTR_CLUSTER_NAME] = @cluster_name unless @cluster_name.nil? attrs[ATTR_CLUSTER_UUID] = @cluster_uuid unless @cluster_uuid.nil? diff --git a/lib/couchbase/utils/observability_constants.rb b/lib/couchbase/utils/observability_constants.rb index 611a7d48..c4ca87a7 100644 --- a/lib/couchbase/utils/observability_constants.rb +++ b/lib/couchbase/utils/observability_constants.rb @@ -177,6 +177,9 @@ module Observability # rubocop:disable Metrics/ModuleLength ATTR_PEER_PORT = "network.peer.port" ATTR_SERVER_DURATION = "couchbase.server_duration" + # Reserved attributes + ATTR_RESERVED_UNIT = "__unit" + ATTR_VALUE_SYSTEM_NAME = "couchbase" ATTR_VALUE_DURABILITY_MAJORITY = "majority" @@ -190,6 +193,8 @@ module Observability # rubocop:disable Metrics/ModuleLength ATTR_VALUE_SERVICE_ANALYTICS = "analytics" ATTR_VALUE_SERVICE_MANAGEMENT = "management" + ATTR_VALUE_RESERVED_UNIT_SECONDS = "s" + METER_NAME_OPERATION_DURATION = "db.client.operation.duration" end end diff --git a/test/opentelemetry_test.rb b/test/opentelemetry_test.rb new file mode 100644 index 00000000..c92fcba4 --- /dev/null +++ b/test/opentelemetry_test.rb @@ -0,0 +1,189 @@ +# frozen_string_literal: true + +# Copyright 2026-Present Couchbase, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +require_relative "test_helper" + +require "couchbase/tracing/open_telemetry_request_tracer" +require "couchbase/metrics/open_telemetry_meter" + +require "opentelemetry-sdk" +require "opentelemetry-metrics-sdk" + +module Couchbase + class OpenTelemetryTest < Minitest::Test + include TestUtilities + + def setup + @span_exporter = ::OpenTelemetry::SDK::Trace::Export::InMemorySpanExporter.new + @tracer_provider = + begin + tracer_provider = ::OpenTelemetry::SDK::Trace::TracerProvider.new + tracer_provider.add_span_processor( + ::OpenTelemetry::SDK::Trace::Export::SimpleSpanProcessor.new(@span_exporter), + ) + tracer_provider + end + @tracer = Couchbase::Tracing::OpenTelemetryRequestTracer.new(@tracer_provider) + + @metric_exporter = ::OpenTelemetry::SDK::Metrics::Export::InMemoryMetricPullExporter.new + @metric_reader = ::OpenTelemetry::SDK::Metrics::Export::PeriodicMetricReader.new(exporter: @metric_exporter) + @meter_provider = + begin + meter_provider = ::OpenTelemetry::SDK::Metrics::MeterProvider.new + meter_provider.add_metric_reader(@metric_reader) + meter_provider + end + @meter = Couchbase::Metrics::OpenTelemetryMeter.new(@meter_provider) + + connect(Options::Cluster.new(tracer: @tracer, meter: @meter)) + @bucket = @cluster.bucket(env.bucket) + @collection = @bucket.default_collection + @parent_span = @tracer.request_span("parent_span") + end + + def teardown + disconnect + @tracer_provider.shutdown + @meter_provider.shutdown + end + + def assert_otel_span( + span_data, + name, + attributes: {}, + parent_span_id: nil, + status_code: OpenTelemetry::Trace::Status::UNSET + ) + assert_equal name, span_data.name + assert_equal :client, span_data.kind + assert_equal status_code, span_data.status.code + + if parent_span_id.nil? + assert_predicate span_data.parent_span_id.hex, :zero? + else + assert_equal parent_span_id, span_data.parent_span_id + end + + attributes.each do |key, value| + if value.nil? + assert span_data.attributes.key?(key), "Expected attribute #{key} to be present" + else + assert_equal value, span_data.attributes[key], "Expected attribute #{key} to have value #{value}" + end + end + end + + def test_opentelemetry_tracer + res = @collection.upsert(uniq_id(:otel_test), {foo: "bar"}, Options::Upsert.new(parent_span: @parent_span)) + + assert_predicate res.cas, :positive? + + @parent_span.finish + spans = @span_exporter.finished_spans.sort_by(&:start_timestamp) + + assert_otel_span( + spans[0], + "parent_span", + attributes: {}, + parent_span_id: nil, + ) + + assert_otel_span( + spans[1], + "upsert", + attributes: { + "db.system.name" => "couchbase", + "couchbase.cluster.name" => env.cluster_name, + "couchbase.cluster.uuid" => env.cluster_uuid, + "db.operation.name" => "upsert", + "db.namespace" => @bucket.name, + "couchbase.scope.name" => "_default", + "couchbase.collection.name" => "_default", + "couchbase.retries" => nil, + }, + parent_span_id: spans[0].span_id, + status_code: OpenTelemetry::Trace::Status::OK, + ) + + assert_otel_span( + spans[2], + "request_encoding", + attributes: { + "db.system.name" => "couchbase", + "couchbase.cluster.name" => env.cluster_name, + "couchbase.cluster.uuid" => env.cluster_uuid, + }, + parent_span_id: spans[1].span_id, + ) + + assert_otel_span( + spans[3], + "dispatch_to_server", + attributes: { + "db.system.name" => "couchbase", + "couchbase.cluster.name" => env.cluster_name, + "couchbase.cluster.uuid" => env.cluster_uuid, + "network.peer.address" => nil, + "network.peer.port" => nil, + "network.transport" => "tcp", + "server.address" => nil, + "server.port" => nil, + "couchbase.local_id" => nil, + }, + parent_span_id: spans[1].span_id, + ) + end + + def test_opentelemetry_meter + assert_raises(Error::DocumentNotFound) do + @collection.get(uniq_id(:does_not_exist)) + end + + @collection.insert(uniq_id(:otel_test), {foo: "bar"}) + + @metric_reader.force_flush + snapshots = @metric_exporter.metric_snapshots + + assert_equal 1, snapshots.size + + snapshot = snapshots[0] + + assert_equal "db.client.operation.duration", snapshot.name + assert_equal "s", snapshot.unit + assert_equal :histogram, snapshot.instrument_kind + assert_equal 2, snapshot.data_points.size + + snapshot.data_points.each_with_index do |p, idx| + assert_equal "couchbase", p.attributes["db.system.name"] + assert_equal env.cluster_name, p.attributes["couchbase.cluster.name"] + assert_equal env.cluster_uuid, p.attributes["couchbase.cluster.uuid"] + assert_equal env.bucket, p.attributes["db.namespace"] + assert_equal "_default", p.attributes["couchbase.scope.name"] + assert_equal "_default", p.attributes["couchbase.collection.name"] + assert_equal "kv", p.attributes["couchbase.service"] + + case idx + when 0 + assert_equal "get", p.attributes["db.operation.name"] + assert_equal "DocumentNotFound", p.attributes["error.type"] + when 1 + assert_equal "insert", p.attributes["db.operation.name"] + assert_nil p.attributes["error.type"] + end + end + end + end +end diff --git a/test/utils/tracing/test_span.rb b/test/utils/tracing/test_span.rb index 17a1c803..3d27eae9 100644 --- a/test/utils/tracing/test_span.rb +++ b/test/utils/tracing/test_span.rb @@ -24,6 +24,7 @@ class TestSpan < Couchbase::Tracing::RequestSpan attr_accessor :attributes attr_accessor :parent attr_accessor :children + attr_accessor :status_code def initialize(name, parent: nil, start_timestamp: nil) super() @@ -38,6 +39,10 @@ def set_attribute(key, value) @attributes[key] = value end + def status=(status_code) + @status_code = status_code + end + def finish(end_timestamp: nil) @end_time = end_timestamp.nil? ? Time.now : end_timestamp end