diff --git a/Rakefile b/Rakefile index efc248d9..0594c795 100644 --- a/Rakefile +++ b/Rakefile @@ -110,6 +110,7 @@ task :cache_cxx_dependencies do "-DCOUCHBASE_CXX_CLIENT_BUILD_TOOLS=OFF", "-DCOUCHBASE_CXX_CLIENT_BUILD_DOCS=OFF", "-DCOUCHBASE_CXX_CLIENT_STATIC_BORINGSSL=ON", + "-DCOUCHBASE_CXX_CLIENT_BUILD_OPENTELEMETRY=OFF", "-DCPM_DOWNLOAD_ALL=ON", "-DCPM_USE_NAMED_CACHE_DIRECTORIES=ON", "-DCPM_USE_LOCAL_PACKAGES=OFF", @@ -226,6 +227,7 @@ task :cache_cxx_dependencies do "-DCPM_USE_LOCAL_PACKAGES=OFF", "-DCPM_SOURCE_CACHE=#{cpm_cache_dir}", "-DCOUCHBASE_CXX_CLIENT_EMBED_MOZILLA_CA_BUNDLE_ROOT=#{cpm_cache_dir}", + "-DCOUCHBASE_CXX_CLIENT_BUILD_OPENTELEMETRY=OFF", ] cmake_flags << "-DCMAKE_C_COMPILER=#{cc}" if cc cmake_flags << "-DCMAKE_CXX_COMPILER=#{cxx}" if cxx diff --git a/couchbase.gemspec b/couchbase.gemspec index 95f686f2..1eef7c2d 100644 --- a/couchbase.gemspec +++ b/couchbase.gemspec @@ -67,5 +67,6 @@ Gem::Specification.new do |spec| spec.extensions = ["ext/extconf.rb"] spec.rdoc_options << "--exclude" << "ext/" + spec.add_dependency "concurrent-ruby", "~> 1.3" spec.add_dependency "grpc", "~> 1.59" end diff --git a/ext/CMakeLists.txt b/ext/CMakeLists.txt index 42a859f8..95b0ad0f 100644 --- a/ext/CMakeLists.txt +++ b/ext/CMakeLists.txt @@ -60,7 +60,8 @@ add_library( rcb_utils.cxx rcb_version.cxx rcb_views.cxx - rcb_observability.cxx) + rcb_observability.cxx + rcb_hdr_histogram.cxx) target_include_directories(couchbase PRIVATE ${PROJECT_BINARY_DIR}/generated) target_include_directories( couchbase @@ -77,6 +78,7 @@ target_link_libraries( asio taocpp::json spdlog::spdlog + hdr_histogram_static snappy) if(RUBY_LIBRUBY) target_link_directories(couchbase PRIVATE "${RUBY_LIBRARY_DIR}") diff --git a/ext/couchbase b/ext/couchbase index 3a383490..d713bb64 160000 --- a/ext/couchbase +++ b/ext/couchbase @@ -1 +1 @@ -Subproject commit 3a383490d3e61ddaf078316b6e28c4c948f91dd2 +Subproject commit d713bb6482421ae1f34d37c26097fcecd6fa6bb0 diff --git a/ext/couchbase.cxx b/ext/couchbase.cxx index aa8ca867..765b41f6 100644 --- a/ext/couchbase.cxx +++ b/ext/couchbase.cxx @@ -25,8 +25,10 @@ #include "rcb_diagnostics.hxx" #include "rcb_exceptions.hxx" #include "rcb_extras.hxx" +#include "rcb_hdr_histogram.hxx" #include "rcb_logger.hxx" #include "rcb_multi.hxx" +#include "rcb_observability.hxx" #include "rcb_query.hxx" #include "rcb_range_scan.hxx" #include "rcb_search.hxx" @@ -64,5 +66,7 @@ Init_libcouchbase(void) couchbase::ruby::init_diagnostics(cBackend); couchbase::ruby::init_extras(cBackend); couchbase::ruby::init_logger_methods(cBackend); + couchbase::ruby::init_hdr_histogram(mCouchbase); + couchbase::ruby::init_observability(cBackend); } } diff --git a/ext/extconf.rb b/ext/extconf.rb index 183a78a3..c57089ed 100644 --- a/ext/extconf.rb +++ b/ext/extconf.rb @@ -97,6 +97,7 @@ def sys(*cmd) "-DCOUCHBASE_CXX_CLIENT_BUILD_TOOLS=OFF", "-DCOUCHBASE_CXX_CLIENT_BUILD_EXAMPLES=OFF", "-DCOUCHBASE_CXX_CLIENT_INSTALL=OFF", + "-DCOUCHBASE_CXX_CLIENT_BUILD_OPENTELEMETRY=OFF", ] if version.start_with?("4") diff --git a/ext/rcb_backend.cxx b/ext/rcb_backend.cxx index 70a168d3..63136feb 100644 --- a/ext/rcb_backend.cxx +++ b/ext/rcb_backend.cxx @@ -426,15 +426,7 @@ initialize_cluster_options(const core::utils::connection_string& connstr, cluster_options.tracing().tracer( std::make_shared()); - static const auto sym_enable_metrics = rb_id2sym(rb_intern("enable_metrics")); - if (auto param = options::get_bool(options, sym_enable_metrics); param) { - cluster_options.metrics().enable(param.value()); - } - - static const auto sym_metrics_emit_interval = rb_id2sym(rb_intern("metrics_emit_interval")); - if (auto param = options::get_milliseconds(options, sym_metrics_emit_interval); param) { - cluster_options.metrics().emit_interval(param.value()); - } + cluster_options.metrics().enable(false); // Metrics are handled on the wrapper-side static const auto sym_app_telemetry = rb_id2sym(rb_intern("application_telemetry")); if (auto app_telemetry_options = options::get_hash(options, sym_app_telemetry); diff --git a/ext/rcb_hdr_histogram.cxx b/ext/rcb_hdr_histogram.cxx new file mode 100644 index 00000000..128705ea --- /dev/null +++ b/ext/rcb_hdr_histogram.cxx @@ -0,0 +1,219 @@ +/* -*- Mode: C++; tab-width: 4; c-basic-offset: 4; indent-tabs-mode: nil -*- */ +/* + * 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. + */ + +#include "rcb_hdr_histogram.hxx" +#include "rcb_exceptions.hxx" + +#include +#include + +#include +#include +#include + +namespace couchbase::ruby +{ +namespace +{ +struct cb_hdr_histogram_data { + hdr_histogram* histogram{ nullptr }; + std::shared_mutex mutex{}; +}; + +void +cb_hdr_histogram_close(cb_hdr_histogram_data* hdr_histogram_data) +{ + if (hdr_histogram_data->histogram != nullptr) { + hdr_close(hdr_histogram_data->histogram); + hdr_histogram_data->histogram = nullptr; + } +} + +void +cb_HdrHistogramC_mark(void* /* ptr */) +{ + /* no embedded ruby objects -- no mark */ +} + +void +cb_HdrHistogramC_free(void* ptr) +{ + auto* hdr_histogram_data = static_cast(ptr); + cb_hdr_histogram_close(hdr_histogram_data); + hdr_histogram_data->~cb_hdr_histogram_data(); + ruby_xfree(hdr_histogram_data); +} + +std::size_t +cb_HdrHistogramC_memsize(const void* ptr) +{ + const auto* hdr_histogram_data = static_cast(ptr); + return sizeof(*hdr_histogram_data); +} + +const rb_data_type_t cb_hdr_histogram_type{ + "Couchbase/Utils/HdrHistogramC", + { + cb_HdrHistogramC_mark, + cb_HdrHistogramC_free, + cb_HdrHistogramC_memsize, +// only one reserved field when GC.compact implemented +#ifdef T_MOVED + nullptr, +#endif + {}, + }, +#ifdef RUBY_TYPED_FREE_IMMEDIATELY + nullptr, + nullptr, + RUBY_TYPED_FREE_IMMEDIATELY, +#endif +}; + +VALUE +cb_HdrHistogramC_allocate(VALUE klass) +{ + cb_hdr_histogram_data* hdr_histogram = nullptr; + VALUE obj = + TypedData_Make_Struct(klass, cb_hdr_histogram_data, &cb_hdr_histogram_type, hdr_histogram); + new (hdr_histogram) cb_hdr_histogram_data(); + return obj; +} + +VALUE +cb_HdrHistogramC_initialize(VALUE self, + VALUE lowest_discernible_value, + VALUE highest_trackable_value, + VALUE significant_figures) +{ + Check_Type(lowest_discernible_value, T_FIXNUM); + Check_Type(highest_trackable_value, T_FIXNUM); + Check_Type(significant_figures, T_FIXNUM); + + std::int64_t lowest = NUM2LL(lowest_discernible_value); + std::int64_t highest = NUM2LL(highest_trackable_value); + int sigfigs = NUM2INT(significant_figures); + + cb_hdr_histogram_data* hdr_histogram; + TypedData_Get_Struct(self, cb_hdr_histogram_data, &cb_hdr_histogram_type, hdr_histogram); + + int res; + { + const std::unique_lock lock(hdr_histogram->mutex); + res = hdr_init(lowest, highest, sigfigs, &hdr_histogram->histogram); + } + if (res != 0) { + rb_raise(exc_couchbase_error(), "failed to initialize HDR histogram"); + return self; + } + + return self; +} + +VALUE +cb_HdrHistogramC_close(VALUE self) +{ + cb_hdr_histogram_data* hdr_histogram; + TypedData_Get_Struct(self, cb_hdr_histogram_data, &cb_hdr_histogram_type, hdr_histogram); + { + const std::unique_lock lock(hdr_histogram->mutex); + cb_hdr_histogram_close(hdr_histogram); + } + return Qnil; +} + +VALUE +cb_HdrHistogramC_record_value(VALUE self, VALUE value) +{ + Check_Type(value, T_FIXNUM); + + std::int64_t val = NUM2LL(value); + + cb_hdr_histogram_data* hdr_histogram; + TypedData_Get_Struct(self, cb_hdr_histogram_data, &cb_hdr_histogram_type, hdr_histogram); + + { + const std::shared_lock lock(hdr_histogram->mutex); + hdr_record_value_atomic(hdr_histogram->histogram, val); + } + return Qnil; +} + +VALUE +cb_HdrHistogramC_get_percentiles_and_reset(VALUE self, VALUE percentiles) +{ + Check_Type(percentiles, T_ARRAY); + + cb_hdr_histogram_data* hdr_histogram; + TypedData_Get_Struct(self, cb_hdr_histogram_data, &cb_hdr_histogram_type, hdr_histogram); + + std::vector percentile_values{}; + std::int64_t total_count; + { + const std::unique_lock lock(hdr_histogram->mutex); + total_count = hdr_histogram->histogram->total_count; + for (std::size_t i = 0; i < static_cast(RARRAY_LEN(percentiles)); ++i) { + VALUE entry = rb_ary_entry(percentiles, static_cast(i)); + Check_Type(entry, T_FLOAT); + double perc = NUM2DBL(entry); + std::int64_t value_at_perc = hdr_value_at_percentile(hdr_histogram->histogram, perc); + percentile_values.push_back(value_at_perc); + } + hdr_reset(hdr_histogram->histogram); + } + + static const VALUE sym_total_count = rb_id2sym(rb_intern("total_count")); + static const VALUE sym_percentiles = rb_id2sym(rb_intern("percentiles")); + VALUE res = rb_hash_new(); + rb_hash_aset(res, sym_total_count, LL2NUM(total_count)); + VALUE perc_array = rb_ary_new_capa(static_cast(percentile_values.size())); + for (const auto& val : percentile_values) { + rb_ary_push(perc_array, LL2NUM(val)); + } + rb_hash_aset(res, sym_percentiles, perc_array); + return res; +} + +VALUE +cb_HdrHistogramC_bin_count(VALUE self) +{ + cb_hdr_histogram_data* hdr_histogram; + TypedData_Get_Struct(self, cb_hdr_histogram_data, &cb_hdr_histogram_type, hdr_histogram); + + std::int32_t bin_count; + { + const std::unique_lock lock(hdr_histogram->mutex); + bin_count = hdr_histogram->histogram->bucket_count; + } + return LONG2NUM(bin_count); +} +} // namespace + +void +init_hdr_histogram(VALUE mCouchbase) +{ + VALUE mUtils = rb_define_module_under(mCouchbase, "Utils"); + VALUE cHdrHistogramC = rb_define_class_under(mUtils, "HdrHistogramC", rb_cObject); + rb_define_alloc_func(cHdrHistogramC, cb_HdrHistogramC_allocate); + rb_define_method(cHdrHistogramC, "initialize", cb_HdrHistogramC_initialize, 3); + rb_define_method(cHdrHistogramC, "close", cb_HdrHistogramC_close, 0); + rb_define_method(cHdrHistogramC, "record_value", cb_HdrHistogramC_record_value, 1); + rb_define_method(cHdrHistogramC, "bin_count", cb_HdrHistogramC_bin_count, 0); + rb_define_method( + cHdrHistogramC, "get_percentiles_and_reset", cb_HdrHistogramC_get_percentiles_and_reset, 1); +} +} // namespace couchbase::ruby diff --git a/ext/rcb_hdr_histogram.hxx b/ext/rcb_hdr_histogram.hxx new file mode 100644 index 00000000..6030c5fb --- /dev/null +++ b/ext/rcb_hdr_histogram.hxx @@ -0,0 +1,28 @@ +/* -*- Mode: C++; tab-width: 4; c-basic-offset: 4; indent-tabs-mode: nil -*- */ +/* + * 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. + */ + +#ifndef COUCHBASE_RUBY_RCB_HDR_HISTOGRAM_HXX +#define COUCHBASE_RUBY_RCB_HDR_HISTOGRAM_HXX + +#include + +namespace couchbase::ruby +{ +void +init_hdr_histogram(VALUE mCouchbase); +} // namespace couchbase::ruby +#endif // COUCHBASE_RUBY_RCB_HDR_HISTOGRAM_HXX diff --git a/ext/rcb_observability.cxx b/ext/rcb_observability.cxx index 6cff7c42..476414d9 100644 --- a/ext/rcb_observability.cxx +++ b/ext/rcb_observability.cxx @@ -15,8 +15,11 @@ * limitations under the License. */ +#include "rcb_backend.hxx" #include "rcb_utils.hxx" +#include +#include #include #include @@ -76,4 +79,38 @@ cb_add_core_spans(VALUE observability_handler, rb_funcall(observability_handler, add_retries_func, ULONG2NUM(retry_attempts)); } } + +namespace +{ +VALUE +cb_Backend_cluster_labels(VALUE self) +{ + VALUE res = rb_hash_new(); + { + auto cluster = cb_backend_to_core_api_cluster(self); + auto labels = cluster.cluster_label_listener()->cluster_labels(); + + static const auto sym_cluster_name = rb_id2sym(rb_intern("cluster_name")); + static const auto sym_cluster_uuid = rb_id2sym(rb_intern("cluster_uuid")); + + if (labels.cluster_name.has_value()) { + rb_hash_aset(res, sym_cluster_name, cb_str_new(labels.cluster_name.value())); + } else { + rb_hash_aset(res, sym_cluster_name, Qnil); + } + if (labels.cluster_uuid.has_value()) { + rb_hash_aset(res, sym_cluster_uuid, cb_str_new(labels.cluster_uuid.value())); + } else { + rb_hash_aset(res, sym_cluster_uuid, Qnil); + } + } + return res; +} +} // namespace + +void +init_observability(VALUE cBackend) +{ + rb_define_method(cBackend, "cluster_labels", cb_Backend_cluster_labels, 0); +} } // namespace couchbase::ruby diff --git a/ext/rcb_observability.hxx b/ext/rcb_observability.hxx index 7572ed35..260c77ac 100644 --- a/ext/rcb_observability.hxx +++ b/ext/rcb_observability.hxx @@ -36,4 +36,7 @@ void cb_add_core_spans(VALUE observability_handler, std::shared_ptr parent_span, std::size_t retry_attempts); + +void +init_observability(VALUE cBackend); } // namespace couchbase::ruby diff --git a/lib/couchbase/cluster.rb b/lib/couchbase/cluster.rb index e4fd142a..38468a77 100644 --- a/lib/couchbase/cluster.rb +++ b/lib/couchbase/cluster.rb @@ -29,8 +29,11 @@ require "couchbase/protostellar" require "couchbase/utils/observability" + require "couchbase/tracing/threshold_logging_tracer" require "couchbase/tracing/noop_tracer" +require "couchbase/metrics/noop_meter" +require "couchbase/metrics/logging_meter" module Couchbase # The main entry point when connecting to a Couchbase cluster. @@ -374,6 +377,7 @@ def initialize(connection_string, *args) raise ArgumentError, "missing password" unless credentials[:password] when Options::Cluster tracer = options.tracer + meter = options.meter open_options = options.to_backend || {} authenticator = options.authenticator case authenticator @@ -400,8 +404,10 @@ def initialize(connection_string, *args) end end + @backend = Backend.new + @observability = Observability::Wrapper.new do |w| - w.tracer = if !(open_options[:enable_tracing].nil? && !open_options[:enable_tracing]) + w.tracer = if !open_options[:enable_tracing].nil? && !open_options[:enable_tracing] Tracing::NoopTracer.new elsif tracer.nil? Tracing::ThresholdLoggingTracer.new( @@ -417,9 +423,17 @@ def initialize(connection_string, *args) else tracer end + w.meter = if !open_options[:enable_metrics].nil? && !open_options[:enable_metrics] + Metrics::NoopMeter.new + elsif meter.nil? + Metrics::LoggingMeter.new( + emit_interval: open_options[:metrics_emit_interval], + ) + else + meter + end end - @backend = Backend.new @backend.open(connection_string, credentials, open_options) end diff --git a/lib/couchbase/metrics/logging_meter.rb b/lib/couchbase/metrics/logging_meter.rb new file mode 100644 index 00000000..6f7cbba6 --- /dev/null +++ b/lib/couchbase/metrics/logging_meter.rb @@ -0,0 +1,108 @@ +# frozen_string_literal: true + +# 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. + +require_relative "logging_value_recorder" +require_relative "meter" +require_relative "noop_meter" +require "couchbase/utils/observability_constants" +require "couchbase/utils/stdlib_logger_adapter" +require "couchbase/logger" + +require "concurrent/map" +require "concurrent/timer_task" + +module Couchbase + module Metrics + class LoggingMeter < Meter + # @api private + DEFAULT_EMIT_INTERVAL = 600_000 # milliseconds + + def initialize(emit_interval: nil) + super() + @emit_interval = emit_interval || DEFAULT_EMIT_INTERVAL + @value_recorders = { + Observability::ATTR_VALUE_SERVICE_KV => Concurrent::Map.new, + Observability::ATTR_VALUE_SERVICE_QUERY => Concurrent::Map.new, + Observability::ATTR_VALUE_SERVICE_VIEWS => Concurrent::Map.new, + Observability::ATTR_VALUE_SERVICE_SEARCH => Concurrent::Map.new, + Observability::ATTR_VALUE_SERVICE_ANALYTICS => Concurrent::Map.new, + Observability::ATTR_VALUE_SERVICE_MANAGEMENT => Concurrent::Map.new, + } + + # TODO(DC): Find better solution for logging + @logger = Couchbase.logger || Logger.new($stdout, Utils::StdlibLoggerAdapter.map_spdlog_level(Couchbase.log_level)) + @task = Concurrent::TimerTask.new(execution_interval: @emit_interval / 1_000.0) do + report = create_report + return if report.empty? + + @logger.info("Metrics: #{report.to_json}") + rescue StandardError => e + @logger.debug("Failed to log metrics: #{e.message}") + end + end + + def value_recorder(name, tags) + return NoopMeter::VALUE_RECORDER_INSTANCE unless name == Observability::METER_NAME_OPERATION_DURATION + + operation_name = tags[Observability::ATTR_OPERATION_NAME] + service = tags[Observability::ATTR_SERVICE] + + return NoopMeter::VALUE_RECORDER_INSTANCE if operation_name.nil? || service.nil? + + @value_recorders[service].put_if_absent( + operation_name, + LoggingValueRecorder.new( + operation_name: operation_name, + service: service, + ), + ) + + @value_recorders[service][operation_name] + end + + def create_report + operations = {} + @value_recorders.each do |service, recorders| + recorders.each_key do |operation_name| + recorder = recorders[operation_name] + operation_report = recorder.report_and_reset + if operation_report + operations[service] ||= {} + operations[service][operation_name] = operation_report + end + end + end + if operations.empty? + {} + else + { + meta: { + emit_interval_ms: @emit_interval, + }, + operations: operations, + } + end + end + + def close + @task.shutdown + @value_recorders.each_value do |operation_map| + operation_map.each_value(&:close) + end + end + end + end +end diff --git a/lib/couchbase/metrics/logging_value_recorder.rb b/lib/couchbase/metrics/logging_value_recorder.rb new file mode 100644 index 00000000..918378de --- /dev/null +++ b/lib/couchbase/metrics/logging_value_recorder.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +# 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. + +require_relative "value_recorder" +require "couchbase/utils/hdr_histogram" + +module Couchbase + module Metrics + class LoggingValueRecorder < ValueRecorder + attr_reader :operation_name + attr_reader :service + + def initialize(operation_name:, service:) + super() + @operation_name = operation_name + @service = service + @histogram = Utils::HdrHistogram.new( + lowest_discernible_value: 1, # 1 microsecond + highest_trackable_value: 30_000_000, # 30 seconds + significant_figures: 3, + ) + end + + def record_value(value) + @histogram.record_value(value) + end + + def report_and_reset + @histogram.report_and_reset + end + + def close + @histogram.close + end + end + end +end diff --git a/lib/couchbase/metrics/meter.rb b/lib/couchbase/metrics/meter.rb new file mode 100644 index 00000000..020f8b4a --- /dev/null +++ b/lib/couchbase/metrics/meter.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +# 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. + +module Couchbase + module Metrics + class Meter + def value_recorder(name, tags) + raise NotImplementedError, "The meter does not implement #value_recorder" + end + + def close; end + end + end +end diff --git a/lib/couchbase/metrics/noop_meter.rb b/lib/couchbase/metrics/noop_meter.rb new file mode 100644 index 00000000..c41ea101 --- /dev/null +++ b/lib/couchbase/metrics/noop_meter.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +# 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. + +require_relative 'meter' +require_relative 'noop_value_recorder' + +module Couchbase + module Metrics + class NoopMeter < Meter + def value_recorder(_name, _tags) + VALUE_RECORDER_INSTANCE + end + + VALUE_RECORDER_INSTANCE = NoopValueRecorder.new.freeze + end + end +end diff --git a/lib/couchbase/metrics/noop_value_recorder.rb b/lib/couchbase/metrics/noop_value_recorder.rb new file mode 100644 index 00000000..d6be8152 --- /dev/null +++ b/lib/couchbase/metrics/noop_value_recorder.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +# 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. + +require_relative 'value_recorder' + +module Couchbase + module Metrics + class NoopValueRecorder < ValueRecorder + def record_value(_value) + # Do nothing + end + end + end +end diff --git a/lib/couchbase/metrics/value_recorder.rb b/lib/couchbase/metrics/value_recorder.rb new file mode 100644 index 00000000..c5ee2386 --- /dev/null +++ b/lib/couchbase/metrics/value_recorder.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +# 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. + +module Couchbase + module Metrics + class ValueRecorder + def record_value(value) + raise NotImplementedError, "The value recorder does not implement #record_value" + end + end + end +end diff --git a/lib/couchbase/options.rb b/lib/couchbase/options.rb index 06b09c37..43f69df0 100644 --- a/lib/couchbase/options.rb +++ b/lib/couchbase/options.rb @@ -1711,6 +1711,7 @@ class Cluster attr_accessor :application_telemetry attr_accessor :tracer # @return [nil, Tracing::RequestTracer] + attr_accessor :meter # @return [nil, Metrics::Meter] # Creates an instance of options for {Couchbase::Cluster.connect} # @@ -1757,6 +1758,7 @@ def initialize(authenticator: nil, # rubocop:disable Metrics/ParameterLists config_idle_redial_timeout: nil, idle_http_connection_timeout: nil, tracer: nil, + meter: nil, application_telemetry: ApplicationTelemetry.new) @authenticator = authenticator @preferred_server_group = preferred_server_group @@ -1789,6 +1791,7 @@ def initialize(authenticator: nil, # rubocop:disable Metrics/ParameterLists @config_idle_redial_timeout = config_idle_redial_timeout @idle_http_connection_timeout = idle_http_connection_timeout @tracer = tracer + @meter = meter @application_telemetry = application_telemetry yield self if block_given? diff --git a/lib/couchbase/tracing/threshold_logging_tracer.rb b/lib/couchbase/tracing/threshold_logging_tracer.rb index 43e6e98b..dc5f3190 100644 --- a/lib/couchbase/tracing/threshold_logging_tracer.rb +++ b/lib/couchbase/tracing/threshold_logging_tracer.rb @@ -116,7 +116,7 @@ def start_reporting_thread next if report.empty? begin - @logger.debug("Threshold Logging Report: #{report.to_json}") + @logger.info("Threshold Logging Report: #{report.to_json}") rescue StandardError => e @logger.debug("Failed to log threshold logging report: #{e.message}") end diff --git a/lib/couchbase/utils/hdr_histogram.rb b/lib/couchbase/utils/hdr_histogram.rb new file mode 100644 index 00000000..0ec3322a --- /dev/null +++ b/lib/couchbase/utils/hdr_histogram.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +# 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. + +module Couchbase + module Utils + class HdrHistogram + def initialize( + lowest_discernible_value:, + highest_trackable_value:, + significant_figures:, + percentiles: nil + ) + @histogram_backend = HdrHistogramC.new(lowest_discernible_value, highest_trackable_value, significant_figures) + @percentiles = percentiles || [50.0, 90.0, 99.0, 99.9, 100.0] + end + + def record_value(value) + @histogram_backend.record_value(value) + end + + def close + @histogram_backend.close + end + + def report_and_reset + backend_report = @histogram_backend.get_percentiles_and_reset(@percentiles) + total_count = backend_report[:total_count] + + return nil if total_count.zero? + + report = { + total_count: total_count, + percentiles_us: {}, + } + @percentiles.zip(backend_report[:percentiles]).each do |percentile, percentile_value| + report[:percentiles_us][percentile.to_s] = percentile_value + end + report + end + end + end +end diff --git a/lib/couchbase/utils/observability.rb b/lib/couchbase/utils/observability.rb index 8db852a6..c52c3042 100644 --- a/lib/couchbase/utils/observability.rb +++ b/lib/couchbase/utils/observability.rb @@ -30,15 +30,22 @@ class Wrapper attr_accessor :tracer attr_accessor :meter - def initialize + def initialize(backend:, tracer: nil, meter: nil) + @backend = backend + @tracer = tracer + @meter = meter + yield self if block_given? end def record_operation(op_name, parent_span, receiver, service = nil) - handler = Handler.new(op_name, parent_span, receiver, @tracer, @meter) + handler = Handler.new(@backend, op_name, parent_span, receiver, @tracer, @meter) handler.add_service(service) unless service.nil? begin res = yield(handler) + rescue StandardError => e + handler.add_error(e) + raise e ensure handler.finish end @@ -47,21 +54,30 @@ def record_operation(op_name, parent_span, receiver, service = nil) def close @tracer&.close + @meter&.close end end class Handler attr_reader :op_span - def initialize(op_name, parent_span, receiver, tracer, meter) + def initialize(backend, op_name, parent_span, receiver, tracer, meter) @tracer = tracer @meter = meter + + cluster_labels = backend.cluster_labels + @cluster_name = cluster_labels[:cluster_name] + @cluster_uuid = cluster_labels[:cluster_uuid] + @op_span = create_span(op_name, parent_span) + @meter_attributes = create_meter_attributes + @start_time = Time.now + add_operation_name(op_name) add_receiver_attributes(receiver) end def with_request_encoding_span - span = create_span(Observability::STEP_REQUEST_ENCODING, @op_span) + span = create_span(STEP_REQUEST_ENCODING, @op_span) begin res = yield ensure @@ -87,18 +103,27 @@ def add_service(service) ATTR_VALUE_SERVICE_VIEWS end @op_span.set_attribute(ATTR_SERVICE, service_str) unless service_str.nil? + @meter_attributes[ATTR_SERVICE] = service_str unless service_str.nil? + end + + def add_operation_name(name) + @op_span.set_attribute(ATTR_OPERATION_NAME, name) + @meter_attributes[ATTR_OPERATION_NAME] = name end def add_bucket_name(name) @op_span.set_attribute(ATTR_BUCKET_NAME, name) + @meter_attributes[ATTR_BUCKET_NAME] = name end def add_scope_name(name) @op_span.set_attribute(ATTR_SCOPE_NAME, name) + @meter_attributes[ATTR_SCOPE_NAME] = name end def add_collection_name(name) @op_span.set_attribute(ATTR_COLLECTION_NAME, name) + @meter_attributes[ATTR_COLLECTION_NAME] = name end def add_durability_level(level) @@ -120,6 +145,15 @@ def add_retries(retries) @op_span.set_attribute(ATTR_RETRIES, retries.to_i) end + def add_error(error) + @meter_attributes[ATTR_ERROR_TYPE] = + if error.is_a?(Couchbase::Error::CouchbaseError) || error.is_a?(Couchbase::Error::InvalidArgument) + error.class.name.split("::").last + else + "_OTHER" + end + end + def add_query_statement(statement, options) pos_params = options.instance_variable_get(:@positional_parameters) named_params = options.instance_variable_get(:@named_parameters) @@ -142,6 +176,8 @@ def add_spans_from_backend(backend_spans) def finish @op_span.finish + duration_us = ((Time.now - @start_time) * 1_000_000).round + @meter.value_recorder(METER_NAME_OPERATION_DURATION, @meter_attributes).record_value(duration_us) end private @@ -167,9 +203,20 @@ def convert_backend_timestamp(backend_timestamp) Time.at(backend_timestamp / (10**6), backend_timestamp % (10**6)) end + def create_meter_attributes + attrs = { + ATTR_SYSTEM_NAME => ATTR_VALUE_SYSTEM_NAME, + } + attrs[ATTR_CLUSTER_NAME] = @cluster_name unless @cluster_name.nil? + attrs[ATTR_CLUSTER_UUID] = @cluster_uuid unless @cluster_uuid.nil? + attrs + end + def create_span(name, parent, start_timestamp: nil) span = @tracer.request_span(name, parent: parent, start_timestamp: start_timestamp) span.set_attribute(ATTR_SYSTEM_NAME, ATTR_VALUE_SYSTEM_NAME) + span.set_attribute(ATTR_CLUSTER_NAME, @cluster_name) unless @cluster_name.nil? + span.set_attribute(ATTR_CLUSTER_UUID, @cluster_uuid) unless @cluster_uuid.nil? span end diff --git a/lib/couchbase/utils/observability_constants.rb b/lib/couchbase/utils/observability_constants.rb index cfd85ac6..611a7d48 100644 --- a/lib/couchbase/utils/observability_constants.rb +++ b/lib/couchbase/utils/observability_constants.rb @@ -189,5 +189,7 @@ module Observability # rubocop:disable Metrics/ModuleLength ATTR_VALUE_SERVICE_VIEWS = "views" ATTR_VALUE_SERVICE_ANALYTICS = "analytics" ATTR_VALUE_SERVICE_MANAGEMENT = "management" + + METER_NAME_OPERATION_DURATION = "db.client.operation.duration" end end diff --git a/test/logging_meter_test.rb b/test/logging_meter_test.rb new file mode 100644 index 00000000..78e8eb6c --- /dev/null +++ b/test/logging_meter_test.rb @@ -0,0 +1,147 @@ +# frozen_string_literal: true + +# 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. + +require "test_helper" + +require "couchbase/metrics/logging_meter" + +module Couchbase + class LoggingMeterTest < Minitest::Test + include TestUtilities + + def setup + @meter = Metrics::LoggingMeter.new + end + + def teardown + @meter.close + end + + def test_record_values + get_recorder = @meter.value_recorder("db.client.operation.duration", { + "couchbase.service" => "kv", + "db.operation.name" => "get", + }) + + assert_instance_of Metrics::LoggingValueRecorder, get_recorder + + get_recorder.record_value(50) + get_recorder.record_value(75) + get_recorder.record_value(100) + + replace_recorder = @meter.value_recorder("db.client.operation.duration", { + "couchbase.service" => "kv", + "db.operation.name" => "replace", + }) + + assert_instance_of Metrics::LoggingValueRecorder, replace_recorder + + replace_recorder.record_value(150) + + query_recorder = @meter.value_recorder("db.client.operation.duration", { + "couchbase.service" => "query", + "db.operation.name" => "query", + }) + + assert_instance_of Metrics::LoggingValueRecorder, query_recorder + + query_recorder.record_value(400) + query_recorder.record_value(500) + query_recorder.record_value(600) + + report = @meter.create_report + + assert_equal 600000, report[:meta][:emit_interval_ms] + assert_equal 2, report[:operations].size + + get_report = report[:operations]["kv"]["get"] + + assert_equal 3, get_report[:total_count] + assert_equal( + { + "50.0" => 75, + "90.0" => 100, + "99.0" => 100, + "99.9" => 100, + "100.0" => 100, + }, + get_report[:percentiles_us], + ) + + replace_report = report[:operations]["kv"]["replace"] + + assert_equal 1, replace_report[:total_count] + assert_equal( + { + "50.0" => 150, + "90.0" => 150, + "99.0" => 150, + "99.9" => 150, + "100.0" => 150, + }, + replace_report[:percentiles_us], + ) + + query_report = report[:operations]["query"]["query"] + + assert_equal 3, query_report[:total_count] + assert_equal( + { + "50.0" => 500, + "90.0" => 600, + "99.0" => 600, + "99.9" => 600, + "100.0" => 600, + }, + query_report[:percentiles_us], + ) + + # Check that after reporting, the metrics are reset + assert_empty @meter.create_report + end + + def test_unrecognized_meters_are_ignored + rec = @meter.value_recorder("unknown.meter", { + "couchbase.service" => "kv", + "db.operation.name" => "get", + }) + + assert_instance_of Metrics::NoopValueRecorder, rec + + assert_empty @meter.create_report + end + + def test_metrics_with_missing_service_are_ignored + rec = @meter.value_recorder("db.client.operation.duration", { + "db.operation.name" => "get", + }) + + assert_instance_of Metrics::NoopValueRecorder, rec + + assert_empty @meter.create_report + end + + def test_metrics_with_missing_operation_name_are_ignored + rec = @meter.value_recorder("db.client.operation.duration", { + "couchbase.service" => "kv", + }) + + assert_instance_of Metrics::NoopValueRecorder, rec + + assert_empty @meter.create_report + end + end +end diff --git a/test/metrics_test.rb b/test/metrics_test.rb new file mode 100644 index 00000000..6631d895 --- /dev/null +++ b/test/metrics_test.rb @@ -0,0 +1,142 @@ +# frozen_string_literal: true + +# 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. + +require_relative "test_helper" +require_relative "utils/metrics" + +module Couchbase + class MetricsTest < Minitest::Test + include TestUtilities + + EXISTING_DOC_ID = "metrics-test-doc" + + def setup + @meter = TestMeter.new + connect(Options::Cluster.new(meter: @meter)) + @bucket = @cluster.bucket(env.bucket) + @collection = @bucket.default_collection + @collection.upsert(EXISTING_DOC_ID, {foo: "bar"}) + @meter.reset + end + + def teardown + disconnect + end + + def test_get_and_replace + 10.times do + res = @collection.get(EXISTING_DOC_ID) + @collection.replace(EXISTING_DOC_ID, {foo: uniq_id("content")}, Options::Replace.new(cas: res.cas)) + end + + assert_operation_metrics( + env, + 10, + operation_name: "get", + service: "kv", + bucket_name: env.bucket, + scope_name: "_default", + collection_name: "_default", + ) + assert_operation_metrics( + env, + 10, + operation_name: "replace", + service: "kv", + bucket_name: env.bucket, + scope_name: "_default", + collection_name: "_default", + ) + end + + def test_get_document_not_found + assert_raises(Couchbase::Error::DocumentNotFound) do + @collection.get(uniq_id(:does_not_exist)) + end + + assert_operation_metrics( + env, + 1, + operation_name: "get", + service: "kv", + bucket_name: env.bucket, + scope_name: "_default", + collection_name: "_default", + error: "DocumentNotFound", + ) + end + + def test_upsert + @collection.upsert(uniq_id(:foo), {foo: "bar"}) + + assert_operation_metrics( + env, + 1, + operation_name: "upsert", + service: "kv", + bucket_name: env.bucket, + scope_name: "_default", + collection_name: "_default", + ) + end + + def test_cluster_level_query + skip("#{name}: CAVES does not support query service") if use_caves? + + @cluster.query("SELECT 1=1") + + assert_operation_metrics( + env, + 1, + operation_name: "query", + service: "query", + ) + end + + def test_scope_level_query + skip("#{name}: CAVES does not support query service") if use_caves? + + @bucket.default_scope.query("SELECT 1=1") + + assert_operation_metrics( + env, + 1, + operation_name: "query", + service: "query", + bucket_name: env.bucket, + scope_name: "_default", + ) + end + + def test_query_parsing_failure + skip("#{name}: CAVES does not support query service") if use_caves? + + assert_raises(Couchbase::Error::ParsingFailure) do + @bucket.default_scope.query("SEEEELECT 1=1") + end + + assert_operation_metrics( + env, + 1, + operation_name: "query", + service: "query", + bucket_name: env.bucket, + scope_name: "_default", + error: "ParsingFailure", + ) + end + end +end diff --git a/test/query_index_manager_test.rb b/test/query_index_manager_test.rb index ca30048b..f8e8536a 100644 --- a/test/query_index_manager_test.rb +++ b/test/query_index_manager_test.rb @@ -67,6 +67,7 @@ def test_get_all_indexes assert_equal 1, get_all_indexes_spans.size assert_http_span( + env, get_all_indexes_spans.first, "manager_query_get_all_indexes", parent: @parent_span, @@ -79,6 +80,7 @@ def test_get_all_indexes assert_equal 2, create_index_spans.size create_index_spans.each do |span| assert_http_span( + env, span, "manager_query_create_index", parent: @parent_span, @@ -129,6 +131,7 @@ def test_query_indexes assert_equal 4, get_all_indexes_root_spans.size get_all_indexes_root_spans.each do |span| assert_http_span( + env, span, "manager_query_get_all_indexes", parent: @parent_span, @@ -141,6 +144,7 @@ def test_query_indexes assert_equal 1, create_primary_index_spans.size assert_http_span( + env, create_primary_index_spans.first, "manager_query_create_primary_index", parent: @parent_span, @@ -153,6 +157,7 @@ def test_query_indexes assert_equal 2, create_index_spans.size create_index_spans.each do |span| assert_http_span( + env, span, "manager_query_create_index", parent: @parent_span, @@ -165,6 +170,7 @@ def test_query_indexes assert_equal 1, build_deferred_indexes_spans.size assert_http_span( + env, build_deferred_indexes_spans.first, "manager_query_build_deferred_indexes", parent: @parent_span, @@ -177,6 +183,7 @@ def test_query_indexes assert_equal 2, watch_indexes_spans.size watch_indexes_spans.each do |span| assert_http_span( + env, span, "manager_query_watch_indexes", parent: @parent_span, @@ -187,6 +194,7 @@ def test_query_indexes assert_predicate span.children.size, :positive? span.children.each do |child_span| assert_http_span( + env, child_span, "manager_query_get_all_indexes", parent: span, diff --git a/test/test_helper.rb b/test/test_helper.rb index fa5a1cef..7ecf7942 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -141,6 +141,10 @@ def supports_multiple_xattr_keys_mutation? def supports_server_group_replica_reads? @version >= Gem::Version.create("7.6.2") end + + def supports_cluster_labels? + @version >= Gem::Version.create("7.6.4") + end end require "couchbase" @@ -177,7 +181,7 @@ def bucket end def management_endpoint - @management_endpoint = ENV.fetch("TEST_MANAGEMENT_ENDPOINT") do + @management_endpoint ||= ENV.fetch("TEST_MANAGEMENT_ENDPOINT") do if connection_string parsed = Couchbase::Backend.parse_connection_string(connection_string) first_node_address = parsed[:nodes].first[:address] @@ -216,6 +220,29 @@ def consistency TestUtilities::MockConsistencyHelper.new end end + + def cluster_name + fetch_cluster_labels if @cluster_name.nil? + @cluster_name + end + + def cluster_uuid + fetch_cluster_labels if @cluster_uuid.nil? + @cluster_uuid + end + + private + + def fetch_cluster_labels + uri = URI("#{management_endpoint}/pools/default/nodeServices") + req = Net::HTTP::Get.new(uri) + req.basic_auth(username, password) + resp = Net::HTTP.start(uri.hostname, uri.port) { |http| http.request(req) } + body = JSON.parse(resp.body) + + @cluster_name = body["clusterName"] + @cluster_uuid = body["clusterUUID"] + end end module TestUtilities diff --git a/test/tracing_test.rb b/test/tracing_test.rb index a574a498..1245f4fc 100644 --- a/test/tracing_test.rb +++ b/test/tracing_test.rb @@ -42,7 +42,7 @@ def test_get spans = @tracer.spans("get") assert_equal 1, spans.size - assert_kv_span spans[0], "get", parent + assert_kv_span env, spans[0], "get", parent end def test_upsert @@ -53,8 +53,8 @@ def test_upsert spans = @tracer.spans("upsert") assert_equal 1, spans.size - assert_kv_span spans[0], "upsert", parent - assert_has_request_encoding_span spans[0] + assert_kv_span env, spans[0], "upsert", parent + assert_has_request_encoding_span env, spans[0] end def test_replace @@ -67,8 +67,8 @@ def test_replace spans = @tracer.spans("replace") assert_equal 1, spans.size - assert_kv_span spans[0], "replace", parent - assert_has_request_encoding_span spans[0] + assert_kv_span env, spans[0], "replace", parent + assert_has_request_encoding_span env, spans[0] end def test_replace_durable @@ -84,8 +84,8 @@ def test_replace_durable spans = @tracer.spans("replace") assert_equal 1, spans.size - assert_kv_span spans[0], "replace", parent - assert_has_request_encoding_span spans[0] + assert_kv_span env, spans[0], "replace", parent + assert_has_request_encoding_span env, spans[0] assert_equal "persist_majority", spans[0].attributes["couchbase.durability"] end end diff --git a/test/utils/metrics.rb b/test/utils/metrics.rb new file mode 100644 index 00000000..b0d8909b --- /dev/null +++ b/test/utils/metrics.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +# 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. + +require_relative "metrics/test_meter" +require_relative "metrics/test_value_recorder" + +def assert_operation_metrics( + env, + count, + operation_name:, + service: nil, + bucket_name: nil, + scope_name: nil, + collection_name: nil, + error: nil +) + attributes = { + "db.system.name" => "couchbase", + "db.operation.name" => operation_name, + } + + if env.server_version.supports_cluster_labels? + attributes["couchbase.cluster.name"] = env.cluster_name + attributes["couchbase.cluster.uuid"] = env.cluster_uuid + end + + attributes["couchbase.service"] = service unless service.nil? + attributes["db.namespace"] = bucket_name unless bucket_name.nil? + attributes["couchbase.scope.name"] = scope_name unless scope_name.nil? + attributes["couchbase.collection.name"] = collection_name unless collection_name.nil? + attributes["error.type"] = error unless error.nil? + + values = @meter.values( + "db.client.operation.duration", + attributes, + :exact, + ) + + values.each do |v| + assert_kind_of Integer, v + assert_predicate v, :positive? + end + + assert_equal count, values.size, + "Expected exactly #{count} value for meter db.client.operation.duration and attributes #{attributes.inspect}" +end diff --git a/test/utils/metrics/test_meter.rb b/test/utils/metrics/test_meter.rb new file mode 100644 index 00000000..b2ef7305 --- /dev/null +++ b/test/utils/metrics/test_meter.rb @@ -0,0 +1,63 @@ +# frozen_string_literal: true + +# 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. + +require "couchbase/metrics/meter" +require_relative "test_value_recorder" + +module Couchbase + module TestUtilities + class TestMeter < Couchbase::Metrics::Meter + def initialize + super + @value_recorders = {} + @mutex = Mutex.new + end + + def value_recorder(name, attributes) + puts "Creating or retrieving ValueRecorder for name: #{name}, attributes: #{attributes}" + @mutex.synchronize do + @value_recorders[name] ||= {} + @value_recorders[name][attributes] ||= TestValueRecorder.new(name, attributes) + end + end + + def values(name, attributes, attributes_filter_strategy = :subset) + @mutex.synchronize do + return [] unless @value_recorders.key?(name) + + matching_recorders = @value_recorders[name].select do |attrs, _| + case attributes_filter_strategy + when :subset + attrs >= attributes + when :exact + attrs == attributes + else + raise ArgumentError, "Unknown attributes_filter_strategy: #{attributes_filter_strategy}" + end + end + + matching_recorders.values.flat_map(&:values) + end + end + + def reset + @mutex.synchronize do + @value_recorders.clear + end + end + end + end +end diff --git a/test/utils/metrics/test_value_recorder.rb b/test/utils/metrics/test_value_recorder.rb new file mode 100644 index 00000000..239837c1 --- /dev/null +++ b/test/utils/metrics/test_value_recorder.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +# 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. + +require "couchbase/metrics/value_recorder" + +module Couchbase + module TestUtilities + class TestValueRecorder < Couchbase::Metrics::ValueRecorder + attr_reader :name + attr_reader :attributes + attr_reader :values + + def initialize(name, attributes) + super() + @name = name + @attributes = attributes + @values = [] + @mutex = Mutex.new + end + + def record_value(value) + @mutex.synchronize do + @values << value + end + end + end + end +end diff --git a/test/utils/tracing.rb b/test/utils/tracing.rb index 0796390d..553ca605 100644 --- a/test/utils/tracing.rb +++ b/test/utils/tracing.rb @@ -17,7 +17,7 @@ require_relative "tracing/test_span" require_relative "tracing/test_tracer" -def assert_span(span, name, parent = nil) +def assert_span(env, span, name, parent = nil) puts JSON.pretty_generate(@tracer.spans[0].to_h) assert_equal name, span.name @@ -26,10 +26,18 @@ def assert_span(span, name, parent = nil) assert_instance_of Time, span.end_time assert_operator span.start_time, :<, span.end_time assert_equal "couchbase", span.attributes["db.system.name"] + + if env.server_version.supports_cluster_labels? + assert_equal env.cluster_name, span.attributes["couchbase.cluster.name"] + assert_equal env.cluster_uuid, span.attributes["couchbase.cluster.uuid"] + else + refute span.attributes.key?("couchbase.cluster.name") + refute span.attributes.key?("couchbase.cluster.uuid") + end end -def assert_kv_span(span, op_name, parent = nil) - assert_span span, op_name, parent +def assert_kv_span(env, span, op_name, parent = nil) + assert_span env, span, op_name, parent assert_equal "kv", span.attributes["couchbase.service"] assert_equal @collection.bucket_name, span.attributes["db.namespace"] @@ -37,15 +45,16 @@ def assert_kv_span(span, op_name, parent = nil) assert_equal @collection.name, span.attributes["couchbase.collection.name"] end -def assert_has_request_encoding_span(span) +def assert_has_request_encoding_span(env, span) assert_predicate span.children.size, :positive? request_encoding_span = span.children[0] # The request encoding span is always the first child span - assert_span request_encoding_span, "request_encoding", span + assert_span env, request_encoding_span, "request_encoding", span end def assert_http_span( + env, span, op_name, parent: nil, @@ -55,7 +64,7 @@ def assert_http_span( collection_name: nil, statement: nil ) - assert_span span, op_name, parent + assert_span env, span, op_name, parent if service.nil? assert_nil span.attributes["couchbase.service"]