diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 6d143411..97c796d3 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -20,6 +20,7 @@ jobs: runs-on: ubuntu-22.04 outputs: gem_version: ${{ steps.build_gem.outputs.gem_version }} + otel_gem_version: ${{ steps.build_otel_gem.outputs.otel_gem_version }} steps: - uses: actions/checkout@v4 with: @@ -30,7 +31,7 @@ jobs: with: ruby-version: 3.3 bundler-cache: true - - name: Build + - name: Build couchbase gem id: build_gem run: | COMMITS_SINCE_LAST_TAG=$(git describe --tags --always --long | awk -F '-' '{print $2}') @@ -38,7 +39,14 @@ jobs: GEM_VERSION=$(ruby -r ./lib/couchbase/version.rb -e "puts Couchbase::VERSION[:sdk]") echo "gem_version=${GEM_VERSION}" >> "$GITHUB_OUTPUT" bundle exec rake build - - name: RDoc + - name: Build couchbase-opentelemetry gem + id: build_otel_gem + run: | + cd couchbase-opentelemetry + OTEL_GEM_VERSION=$(ruby -r ./lib/couchbase/opentelemetry/version.rb -e "puts Couchbase::OpenTelemetry::VERSION") + echo "otel_gem_version=${OTEL_GEM_VERSION}" >> "$GITHUB_OUTPUT" + bundle exec rake build + - name: Generate documentation for the couchbase gem run: | cat > patch-readme.rb < patch-readme.rb <:/v1/traces") + ) +) + +# Initialize the Couchbase OpenTelemetry Request Tracer +tracer = Couchbase::OpenTelemetry::RequestTracer.new(tracer_provider) + +# Initialize a meter provider +meter_provider = OpenTelemetry::SDK::Metrics::MeterProvider.new +meter_provider.add_metric_reader( + OpenTelemetry::SDK::Metrics::Export::PeriodicMetricReader.new( + exporter: OpenTelemetry::Exporter::OTLP::Metrics::MetricsExporter.new( + endpoint: "https://:/v1/metrics" + ) + ) +) + +# Initialize the Couchbase OpenTelemetry Meter +meter = Couchbase::OpenTelemetry::Meter.new(meter_provider) + +# Configure tracer and meter in cluster options +options = Couchbase::Options::Cluster.new( + authenticator: Couchbase::PasswordAuthenticator.new("Administrator", "password") + tracer: tracer, + meter: meter +) + +# Initialize cluster instance +cluster = Cluster.connect("couchbase://127.0.0.1", options) +``` + +## License + +The gem is available as open source under the terms of the [Apache 2.0 License](https://opensource.org/licenses/Apache-2.0). + + Copyright 2025-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. diff --git a/couchbase-opentelemetry/Rakefile b/couchbase-opentelemetry/Rakefile new file mode 100644 index 00000000..b0800ce8 --- /dev/null +++ b/couchbase-opentelemetry/Rakefile @@ -0,0 +1,38 @@ +# 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 "bundler/gem_tasks" +require "rubocop/rake_task" + +desc "Generate YARD documentation" +task :doc do + require "couchbase/opentelemetry/version" + input_dir = File.join(__dir__, "lib") + output_dir = File.join(__dir__, "doc", "couchbase-ruby-client-opentelemetry-#{Couchbase::OpenTelemetry::VERSION}") + rm_rf output_dir + sh "bundle exec yard doc --no-progress --hide-api private --output-dir #{output_dir} #{input_dir} --main README.md" + puts "#{File.realpath(output_dir)}/index.html" +end + +desc "An alias for documentation generation task" +task :docs => :doc + +desc "Display stats on undocumented things" +task :undocumented => :doc do + sh "yard stats --list-undoc --compact" +end + +RuboCop::RakeTask.new diff --git a/couchbase-opentelemetry/couchbase-opentelemetry.gemspec b/couchbase-opentelemetry/couchbase-opentelemetry.gemspec new file mode 100644 index 00000000..71111448 --- /dev/null +++ b/couchbase-opentelemetry/couchbase-opentelemetry.gemspec @@ -0,0 +1,53 @@ +# 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) +require "couchbase/opentelemetry/version" + +Gem::Specification.new do |spec| + spec.name = "couchbase-opentelemetry" + spec.version = Couchbase::OpenTelemetry::VERSION + 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.2" + + 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/opentelemetry.rb b/couchbase-opentelemetry/lib/couchbase/opentelemetry.rb new file mode 100644 index 00000000..bdb02c6e --- /dev/null +++ b/couchbase-opentelemetry/lib/couchbase/opentelemetry.rb @@ -0,0 +1,19 @@ +# 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 "opentelemetry/request_tracer" +require_relative "opentelemetry/meter" +require_relative "opentelemetry/version" diff --git a/couchbase-opentelemetry/lib/couchbase/opentelemetry/meter.rb b/couchbase-opentelemetry/lib/couchbase/opentelemetry/meter.rb new file mode 100644 index 00000000..8c7d08d0 --- /dev/null +++ b/couchbase-opentelemetry/lib/couchbase/opentelemetry/meter.rb @@ -0,0 +1,82 @@ +# 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 "value_recorder" + +require "opentelemetry-metrics-api" + +module Couchbase + module OpenTelemetry + # @couchbase.stability + # **Uncommitted:** This API may change in the future, as Metrics in OpenTelemetry Ruby are currently in development. + # + # See the {https://opentelemetry.io/docs/languages/ruby/#status-and-releases OpenTelemetry Ruby documentation} for more information. + class Meter < ::Couchbase::Metrics::Meter + # Initializes a Couchbase OpenTelemetry Meter + # + # @param [::OpenTelemetry::Metrics::MeterProvider] meter_provider The OpenTelemetry meter provider + # + # @raise [Couchbase::Error::MeterError] if the meter cannot be created for any reason + # + # @example Initializing a Couchbase OpenTelemetry Meter with an OTLP Exporter + # require "opentelemetry-metrics-sdk" + # + # # Initialize a meter provider + # meter_provider = ::OpenTelemetry::SDK::Metrics::MeterProvider.new + # meter_provider.add_metric_reader( + # ::OpenTelemetry::SDK::Metrics::Export::PeriodicMetricReader.new( + # exporter: ::OpenTelemetry::Exporter::OTLP::Metrics::MetricsExporter.new( + # endpoint: "https://:/v1/metrics" + # ) + # ) + # ) + # # Initialize the Couchbase OpenTelemetry Meter + # meter = Couchbase::OpenTelemetry::Meter.new(meter_provider) + # + # # Set the meter in the cluster options + # options = Couchbase::Options::Cluster.new( + # authenticator: Couchbase::PasswordAuthenticator.new("Administrator", "password") + # meter: meter + # ) + # + # @see https://www.rubydoc.info/gems/opentelemetry-metrics-sdk/OpenTelemetry/SDK/Metrics/MeterProvider + # opentelemetry-metrics-sdk API Reference + 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 + + ValueRecorder.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/opentelemetry/request_span.rb b/couchbase-opentelemetry/lib/couchbase/opentelemetry/request_span.rb new file mode 100644 index 00000000..1ffcf69d --- /dev/null +++ b/couchbase-opentelemetry/lib/couchbase/opentelemetry/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 OpenTelemetry + class RequestSpan < ::Couchbase::Tracing::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/opentelemetry/request_tracer.rb b/couchbase-opentelemetry/lib/couchbase/opentelemetry/request_tracer.rb new file mode 100644 index 00000000..d18f6d4a --- /dev/null +++ b/couchbase-opentelemetry/lib/couchbase/opentelemetry/request_tracer.rb @@ -0,0 +1,79 @@ +# 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 "request_span" + +module Couchbase + module OpenTelemetry + class RequestTracer < ::Couchbase::Tracing::RequestTracer + # Initializes a Couchbase OpenTelemetry Request Tracer + # + # @param [::OpenTelemetry::Trace::TracerProvider] tracer_provider The OpenTelemetry tracer provider + # + # @raise [Couchbase::Error::TracerError] if the tracer cannot be created for any reason + # + # @example Initializing a Couchbase OpenTelemetry Request Tracer with an OTLP Exporter + # require "opentelemetry-sdk" + # + # # Initialize a tracer provider + # tracer_provider = ::OpenTelemetry::SDK::Trace::TracerProvider.new + # tracer_provider.add_span_processor( + # ::OpenTelemetry::SDK::Trace::Export::BatchSpanProcessor.new( + # exporter: ::OpenTelemetry::Exporter::OTLP::Exporter.new( + # endpoint: "https://:/v1/traces" + # ) + # ) + # ) + # # Initialize the Couchbase OpenTelemetry Request Tracer + # tracer = Couchbase::OpenTelemetry::RequestTracer.new(tracer_provider) + # + # # Set the tracer in the cluster options + # options = Couchbase::Options::Cluster.new( + # authenticator: Couchbase::PasswordAuthenticator.new("Administrator", "password") + # tracer: tracer + # ) + # + # @see https://www.rubydoc.info/gems/opentelemetry-sdk/OpenTelemetry/SDK/Trace/TracerProvider + # opentelemetry-sdk API Reference + 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)) + RequestSpan.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/couchbase-opentelemetry/lib/couchbase/opentelemetry/value_recorder.rb b/couchbase-opentelemetry/lib/couchbase/opentelemetry/value_recorder.rb new file mode 100644 index 00000000..13571e9f --- /dev/null +++ b/couchbase-opentelemetry/lib/couchbase/opentelemetry/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 OpenTelemetry + class ValueRecorder < ::Couchbase::Metrics::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/opentelemetry/version.rb b/couchbase-opentelemetry/lib/couchbase/opentelemetry/version.rb new file mode 100644 index 00000000..d5a16cd8 --- /dev/null +++ b/couchbase-opentelemetry/lib/couchbase/opentelemetry/version.rb @@ -0,0 +1,22 @@ +# 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. + +module Couchbase + module OpenTelemetry + # Version of the Couchbase OpenTelemetry integration gem + VERSION = "0.1.0" + 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.rb b/lib/couchbase.rb index 7aea5fcf..5b09db63 100644 --- a/lib/couchbase.rb +++ b/lib/couchbase.rb @@ -25,8 +25,8 @@ # @!macro uncommitted # @couchbase.stability -# Uncommitted: This API may change in the future. +# **Uncommitted:** This API may change in the future. # # @!macro volatile # @couchbase.stability -# Volatile: This API is subject to change at any time. +# **Volatile:** This API is subject to change at any time. 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..dc3e3fea --- /dev/null +++ b/test/opentelemetry_test.rb @@ -0,0 +1,207 @@ +# 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/opentelemetry" + +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::OpenTelemetry::RequestTracer.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::OpenTelemetry::Meter.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, + ) + + expected_attributes = { + "db.system.name" => "couchbase", + "db.operation.name" => "upsert", + "db.namespace" => @bucket.name, + "couchbase.scope.name" => "_default", + "couchbase.collection.name" => "_default", + "couchbase.retries" => nil, + } + if env.server_version.supports_cluster_labels? + expected_attributes["couchbase.cluster.name"] = env.cluster_name + expected_attributes["couchbase.cluster.uuid"] = env.cluster_uuid + end + + assert_otel_span( + spans[1], + "upsert", + attributes: expected_attributes, + parent_span_id: spans[0].span_id, + status_code: ::OpenTelemetry::Trace::Status::OK, + ) + + expected_attributes = { + "db.system.name" => "couchbase", + } + if env.server_version.supports_cluster_labels? + expected_attributes["couchbase.cluster.name"] = env.cluster_name + expected_attributes["couchbase.cluster.uuid"] = env.cluster_uuid + end + + assert_otel_span( + spans[2], + "request_encoding", + attributes: expected_attributes, + parent_span_id: spans[1].span_id, + ) + + expected_attributes = { + "db.system.name" => "couchbase", + "network.peer.address" => nil, + "network.peer.port" => nil, + "network.transport" => "tcp", + "server.address" => nil, + "server.port" => nil, + "couchbase.local_id" => nil, + } + if env.server_version.supports_cluster_labels? + expected_attributes["couchbase.cluster.name"] = env.cluster_name + expected_attributes["couchbase.cluster.uuid"] = env.cluster_uuid + end + + assert_otel_span( + spans[3], + "dispatch_to_server", + attributes: expected_attributes, + 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"] + + if env.server_version.supports_cluster_labels? + assert_equal env.cluster_name, p.attributes["couchbase.cluster.name"] + assert_equal env.cluster_uuid, p.attributes["couchbase.cluster.uuid"] + else + assert_nil p.attributes["couchbase.cluster.name"] + assert_nil p.attributes["couchbase.cluster.uuid"] + end + + 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/metrics.rb b/test/utils/metrics.rb index b0d8909b..ab84ce3d 100644 --- a/test/utils/metrics.rb +++ b/test/utils/metrics.rb @@ -30,6 +30,7 @@ def assert_operation_metrics( attributes = { "db.system.name" => "couchbase", "db.operation.name" => operation_name, + "__unit" => "s", } if env.server_version.supports_cluster_labels? @@ -55,5 +56,5 @@ def assert_operation_metrics( end assert_equal count, values.size, - "Expected exactly #{count} value for meter db.client.operation.duration and attributes #{attributes.inspect}" + "Expected exactly #{count} value(s) for meter db.client.operation.duration and attributes #{attributes.inspect}" 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