diff --git a/Gemfile b/Gemfile index 70ebd835..e05aebac 100644 --- a/Gemfile +++ b/Gemfile @@ -95,3 +95,10 @@ gem 'json-schema', '~> 6.0' gem 'triangulum', '0.25.5' gem 'benchmark' + +# OpenTelemetry +gem 'opentelemetry-exporter-otlp', '~> 0.34.0' # we need this to get traces +gem 'opentelemetry-exporter-otlp-logs', '~> 0.5.1' # we need this to get logs +gem 'opentelemetry-exporter-otlp-metrics', '~> 0.10.0' # we need this to get metrics +gem 'opentelemetry-instrumentation-all', '~> 0.94.0' # we need this for logs and traces +gem 'opentelemetry-instrumentation-logger', '~> 0.4.0' # we need this to get logs diff --git a/Gemfile.lock b/Gemfile.lock index 8ffaa24a..70801da0 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -212,6 +212,214 @@ GEM mini_portile2 (~> 2.8.2) racc (~> 1.4) open3 (0.2.1) + opentelemetry-api (1.10.0) + logger + opentelemetry-common (0.25.0) + opentelemetry-api (~> 1.0) + opentelemetry-exporter-otlp (0.34.0) + google-protobuf (>= 3.18) + googleapis-common-protos-types (~> 1.3) + opentelemetry-api (~> 1.1) + opentelemetry-common (~> 0.20) + opentelemetry-sdk (~> 1.10) + opentelemetry-semantic_conventions + opentelemetry-exporter-otlp-logs (0.5.1) + google-protobuf (>= 3.18) + googleapis-common-protos-types (~> 1.3) + opentelemetry-api (~> 1.1) + opentelemetry-common (~> 0.20) + opentelemetry-logs-api (~> 0.1) + opentelemetry-logs-sdk (~> 0.1) + opentelemetry-sdk + opentelemetry-semantic_conventions + opentelemetry-exporter-otlp-metrics (0.10.0) + google-protobuf (>= 3.18, < 5.0) + googleapis-common-protos-types (~> 1.3) + opentelemetry-api (~> 1.1) + opentelemetry-common (~> 0.20) + opentelemetry-metrics-api (~> 0.2) + opentelemetry-metrics-sdk (~> 0.5) + opentelemetry-sdk (~> 1.2) + opentelemetry-semantic_conventions + opentelemetry-helpers-mysql (0.6.0) + opentelemetry-api (~> 1.7) + opentelemetry-common (~> 0.21) + opentelemetry-helpers-sql (0.4.0) + opentelemetry-api (~> 1.7) + opentelemetry-helpers-sql-processor (0.5.0) + opentelemetry-api (~> 1.0) + opentelemetry-common (~> 0.21) + opentelemetry-instrumentation-action_mailer (0.8.1) + opentelemetry-instrumentation-active_support (~> 0.10) + opentelemetry-instrumentation-action_pack (0.18.0) + opentelemetry-instrumentation-rack (~> 0.29) + opentelemetry-instrumentation-action_view (0.13.0) + opentelemetry-instrumentation-active_support (~> 0.10) + opentelemetry-instrumentation-active_job (0.13.0) + opentelemetry-instrumentation-base (~> 0.25) + opentelemetry-instrumentation-active_model_serializers (0.25.0) + opentelemetry-instrumentation-active_support (>= 0.7.0) + opentelemetry-instrumentation-active_record (0.13.0) + opentelemetry-instrumentation-base (~> 0.25) + opentelemetry-instrumentation-active_storage (0.5.1) + opentelemetry-instrumentation-active_support (~> 0.10) + opentelemetry-instrumentation-active_support (0.12.0) + opentelemetry-instrumentation-base (~> 0.25) + opentelemetry-instrumentation-all (0.94.0) + opentelemetry-instrumentation-active_model_serializers (~> 0.25.0) + opentelemetry-instrumentation-anthropic (~> 0.5.0) + opentelemetry-instrumentation-aws_lambda (~> 0.7.0) + opentelemetry-instrumentation-aws_sdk (~> 0.12.0) + opentelemetry-instrumentation-bunny (~> 0.25.0) + opentelemetry-instrumentation-concurrent_ruby (~> 0.25.0) + opentelemetry-instrumentation-dalli (~> 0.30.0) + opentelemetry-instrumentation-delayed_job (~> 0.26.0) + opentelemetry-instrumentation-ethon (~> 0.29.0) + opentelemetry-instrumentation-excon (~> 0.29.0) + opentelemetry-instrumentation-faraday (~> 0.33.0) + opentelemetry-instrumentation-grape (~> 0.7.0) + opentelemetry-instrumentation-graphql (~> 0.32.0) + opentelemetry-instrumentation-grpc (~> 0.5.0) + opentelemetry-instrumentation-gruf (~> 0.6.0) + opentelemetry-instrumentation-http (~> 0.30.0) + opentelemetry-instrumentation-http_client (~> 0.29.0) + opentelemetry-instrumentation-httpx (~> 0.8.0) + opentelemetry-instrumentation-koala (~> 0.24.0) + opentelemetry-instrumentation-lmdb (~> 0.26.0) + opentelemetry-instrumentation-mongo (~> 0.26.0) + opentelemetry-instrumentation-mysql2 (~> 0.34.0) + opentelemetry-instrumentation-net_http (~> 0.29.0) + opentelemetry-instrumentation-pg (~> 0.36.0) + opentelemetry-instrumentation-que (~> 0.13.0) + opentelemetry-instrumentation-racecar (~> 0.7.0) + opentelemetry-instrumentation-rack (~> 0.31.0) + opentelemetry-instrumentation-rails (~> 0.42.0) + opentelemetry-instrumentation-rake (~> 0.6.0) + opentelemetry-instrumentation-rdkafka (~> 0.10.0) + opentelemetry-instrumentation-redis (~> 0.29.0) + opentelemetry-instrumentation-resque (~> 0.9.0) + opentelemetry-instrumentation-restclient (~> 0.28.0) + opentelemetry-instrumentation-ruby_kafka (~> 0.25.0) + opentelemetry-instrumentation-sidekiq (~> 0.29.0) + opentelemetry-instrumentation-sinatra (~> 0.30.0) + opentelemetry-instrumentation-trilogy (~> 0.69.0) + opentelemetry-instrumentation-anthropic (0.5.0) + opentelemetry-instrumentation-base (~> 0.25) + opentelemetry-instrumentation-aws_lambda (0.7.0) + opentelemetry-instrumentation-base (~> 0.25) + opentelemetry-instrumentation-aws_sdk (0.12.0) + opentelemetry-instrumentation-base (~> 0.25) + opentelemetry-instrumentation-base (0.26.1) + opentelemetry-api (~> 1.7) + opentelemetry-common (~> 0.21) + opentelemetry-registry (~> 0.1) + opentelemetry-instrumentation-bunny (0.25.0) + opentelemetry-instrumentation-base (~> 0.25) + opentelemetry-instrumentation-concurrent_ruby (0.25.0) + opentelemetry-instrumentation-base (~> 0.25) + opentelemetry-instrumentation-dalli (0.30.0) + opentelemetry-instrumentation-base (~> 0.25) + opentelemetry-instrumentation-delayed_job (0.26.0) + opentelemetry-instrumentation-base (~> 0.25) + opentelemetry-instrumentation-ethon (0.29.0) + opentelemetry-instrumentation-base (~> 0.25) + opentelemetry-instrumentation-excon (0.29.1) + opentelemetry-instrumentation-base (~> 0.25) + opentelemetry-instrumentation-faraday (0.33.0) + opentelemetry-instrumentation-base (~> 0.25) + opentelemetry-instrumentation-grape (0.7.0) + opentelemetry-instrumentation-rack (~> 0.29) + opentelemetry-instrumentation-graphql (0.32.0) + opentelemetry-instrumentation-base (~> 0.25) + opentelemetry-instrumentation-grpc (0.5.1) + opentelemetry-instrumentation-base (~> 0.25) + opentelemetry-instrumentation-gruf (0.6.1) + opentelemetry-instrumentation-base (~> 0.25) + opentelemetry-instrumentation-http (0.30.0) + opentelemetry-instrumentation-base (~> 0.25) + opentelemetry-instrumentation-http_client (0.29.0) + opentelemetry-instrumentation-base (~> 0.25) + opentelemetry-instrumentation-httpx (0.8.0) + opentelemetry-instrumentation-base (~> 0.25) + opentelemetry-instrumentation-koala (0.24.0) + opentelemetry-instrumentation-base (~> 0.25) + opentelemetry-instrumentation-lmdb (0.26.0) + opentelemetry-instrumentation-base (~> 0.25) + opentelemetry-instrumentation-logger (0.4.0) + opentelemetry-instrumentation-base (~> 0.25) + opentelemetry-logs-api (~> 0.1) + opentelemetry-instrumentation-mongo (0.26.0) + opentelemetry-instrumentation-base (~> 0.25) + opentelemetry-instrumentation-mysql2 (0.34.0) + opentelemetry-helpers-mysql + opentelemetry-helpers-sql + opentelemetry-helpers-sql-processor + opentelemetry-instrumentation-base (~> 0.25) + opentelemetry-instrumentation-net_http (0.29.0) + opentelemetry-instrumentation-base (~> 0.25) + opentelemetry-instrumentation-pg (0.36.0) + opentelemetry-helpers-sql + opentelemetry-helpers-sql-processor + opentelemetry-instrumentation-base (~> 0.25) + opentelemetry-instrumentation-que (0.13.0) + opentelemetry-instrumentation-base (~> 0.25) + opentelemetry-instrumentation-racecar (0.7.0) + opentelemetry-instrumentation-base (~> 0.25) + opentelemetry-instrumentation-rack (0.31.1) + opentelemetry-instrumentation-base (~> 0.25) + opentelemetry-instrumentation-rails (0.42.0) + opentelemetry-instrumentation-action_mailer (~> 0.7) + opentelemetry-instrumentation-action_pack (~> 0.17) + opentelemetry-instrumentation-action_view (~> 0.12) + opentelemetry-instrumentation-active_job (~> 0.11) + opentelemetry-instrumentation-active_record (~> 0.12) + opentelemetry-instrumentation-active_storage (~> 0.4) + opentelemetry-instrumentation-active_support (~> 0.11) + opentelemetry-instrumentation-concurrent_ruby (~> 0.25) + opentelemetry-instrumentation-rake (0.6.0) + opentelemetry-instrumentation-base (~> 0.25) + opentelemetry-instrumentation-rdkafka (0.10.0) + opentelemetry-instrumentation-base (~> 0.25) + opentelemetry-instrumentation-redis (0.29.0) + opentelemetry-instrumentation-base (~> 0.25) + opentelemetry-instrumentation-resque (0.9.0) + opentelemetry-instrumentation-base (~> 0.25) + opentelemetry-instrumentation-restclient (0.28.0) + opentelemetry-instrumentation-base (~> 0.25) + opentelemetry-instrumentation-ruby_kafka (0.25.0) + opentelemetry-instrumentation-base (~> 0.25) + opentelemetry-instrumentation-sidekiq (0.29.0) + opentelemetry-instrumentation-base (~> 0.25) + opentelemetry-instrumentation-sinatra (0.30.0) + opentelemetry-instrumentation-rack (~> 0.29) + opentelemetry-instrumentation-trilogy (0.69.0) + opentelemetry-helpers-mysql + opentelemetry-helpers-sql + opentelemetry-helpers-sql-processor + opentelemetry-instrumentation-base (~> 0.25) + opentelemetry-semantic_conventions (>= 1.8.0) + opentelemetry-logs-api (0.4.0) + opentelemetry-api (~> 1.0) + opentelemetry-logs-sdk (0.6.0) + opentelemetry-api (~> 1.2) + opentelemetry-logs-api (~> 0.1) + opentelemetry-sdk (~> 1.3) + opentelemetry-metrics-api (0.6.0) + opentelemetry-api (~> 1.0) + opentelemetry-metrics-sdk (0.15.0) + opentelemetry-api (~> 1.1) + opentelemetry-metrics-api (~> 0.2) + opentelemetry-sdk (~> 1.2) + opentelemetry-registry (0.6.0) + opentelemetry-api (~> 1.1) + opentelemetry-sdk (1.12.0) + logger + opentelemetry-api (~> 1.1) + opentelemetry-common (~> 0.20) + opentelemetry-registry (~> 0.2) + opentelemetry-semantic_conventions + opentelemetry-semantic_conventions (1.41.0) + opentelemetry-api (~> 1.0) parallel (1.28.0) parser (3.3.11.1) ast (~> 2.4.1) @@ -429,6 +637,11 @@ DEPENDENCIES image_processing (>= 1.2) json-schema (~> 6.0) lograge (~> 0.14.0) + opentelemetry-exporter-otlp (~> 0.34.0) + opentelemetry-exporter-otlp-logs (~> 0.5.1) + opentelemetry-exporter-otlp-metrics (~> 0.10.0) + opentelemetry-instrumentation-all (~> 0.94.0) + opentelemetry-instrumentation-logger (~> 0.4.0) pg (~> 1.1) pry (~> 0.16.0) pry-byebug (~> 3.10) diff --git a/app/grpc/concerns/grpc_stream_handler.rb b/app/grpc/concerns/grpc_stream_handler.rb index a617dbcb..672475cc 100644 --- a/app/grpc/concerns/grpc_stream_handler.rb +++ b/app/grpc/concerns/grpc_stream_handler.rb @@ -4,6 +4,10 @@ module GrpcStreamHandler include Code0::ZeroTrack::Loggable extend ActiveSupport::Concern + StreamItem = Struct.new(:data, :otel_context, keyword_init: true) + + Tracer = ::OpenTelemetry.tracer_provider.tracer('sagittarius-grpc-stream') + class_methods do def grpc_stream(method) define_method(method) do |_, call| @@ -17,17 +21,23 @@ def grpc_stream(method) grpc_object: grpc_object) encoded_data = send('encoders')[method].call(grpc_object) - encoded_data64 = Base64.encode64(encoded_data) + encoded_data64 = Base64.encode64(encoded_data).delete("\n") GrpcStreamHandler.logger.info(message: 'Encoded data', runtime_id: runtime_id, method: method, encoded_data: encoded_data64) + carrier = {} + OpenTelemetry.propagation.inject(carrier) + trace_context64 = Base64.encode64(carrier.to_json).delete("\n") + + notification_payload = "#{self},#{method},#{runtime_id},#{encoded_data64},#{trace_context64}" + ActiveRecord::Base.connection.raw_connection - .exec("NOTIFY grpc_streams, '#{self},#{method},#{runtime_id},#{encoded_data64}'") + .exec("NOTIFY grpc_streams, '#{notification_payload}'") end define_singleton_method("end_#{method}") do |runtime_id| ActiveRecord::Base.connection.raw_connection - .exec("NOTIFY grpc_streams, '#{self},#{method},#{runtime_id},end'") + .exec("NOTIFY grpc_streams, '#{self},#{method},#{runtime_id},end,'") end end end @@ -48,11 +58,18 @@ def self.listen! conn.wait_for_notify(1) do |_, _, payload| logger.info(message: 'Received notification', payload: payload) - class_name, method_name, runtime_id, encoded_data64 = payload.split(',') + parts = payload.split(',') + class_name = parts[0] + method_name = parts[1] + runtime_id = parts[2] + encoded_data64 = parts[3] + trace_context64 = parts[4] clazz = class_name.constantize method_name = method_name.to_sym + otel_context = extract_otel_context(trace_context64) + if encoded_data64 == 'end' decoded_data = :end else @@ -62,7 +79,7 @@ def self.listen! queues = GrpcStreamHandler.yielders.dig(clazz, method_name, runtime_id.to_i) queues&.each do |queue| - queue << decoded_data + queue << StreamItem.new(data: decoded_data, otel_context: otel_context) rescue StandardError => e logger.error(message: 'Error while yielding data', error: e.message, backtrace: e.backtrace) end @@ -73,6 +90,15 @@ def self.listen! end end + def self.extract_otel_context(trace_context64) + return nil if trace_context64.blank? + + carrier = JSON.parse(Base64.decode64(trace_context64)) + OpenTelemetry.propagation.extract(carrier) + rescue StandardError + nil + end + def create_enumerator(clazz, method, runtime_id, _call) logger.debug(message: 'Creating enumerator', runtime_id: runtime_id, clazz: clazz, method: method) @@ -82,7 +108,7 @@ def create_enumerator(clazz, method, runtime_id, _call) method_queues = queues[method] ||= {} runtime_queues = method_queues[runtime_id] ||= [] - runtime_queues.each { |existing_queue| existing_queue << :end } + runtime_queues.each { |existing_queue| existing_queue << StreamItem.new(data: :end, otel_context: nil) } runtime_queues.clear runtime_queues << queue @@ -92,12 +118,17 @@ def create_enumerator(clazz, method, runtime_id, _call) loop do item = queue.pop(timeout: 1) next if item.nil? - break if item == :end + break if item.data == :end begin - y << item - ApplicationRecord.connection_pool.with_connection do - Runtime.update(runtime_id, last_heartbeat: Time.zone.now) + otel_context = item.otel_context || OpenTelemetry::Context.current + OpenTelemetry::Context.with_current(otel_context) do + Tracer.in_span("grpc_stream.send/#{method}") do + y << item.data + end + ApplicationRecord.connection_pool.with_connection do + Runtime.update(runtime_id, last_heartbeat: Time.zone.now) + end end rescue ActiveRecord::ActiveRecordError => e logger.warn(message: 'Failed to update runtime heartbeat', exception: e.message, backtrace: e.backtrace) diff --git a/app/grpc/execution_handler.rb b/app/grpc/execution_handler.rb index 7e473680..48b3e411 100644 --- a/app/grpc/execution_handler.rb +++ b/app/grpc/execution_handler.rb @@ -38,7 +38,7 @@ def test(requests, call) rescue StandardError => e logger.error(message: 'Error reading execution stream', error: e.message, backtrace: e.backtrace) - outbound_queue << :end if outbound_queue + outbound_queue << GrpcStreamHandler::StreamItem.new(data: :end, otel_context: nil) if outbound_queue ensure logger.info(message: 'Execution runtime request stream closed') end diff --git a/config/initializers/opentelemetry.rb b/config/initializers/opentelemetry.rb new file mode 100644 index 00000000..dcc94dec --- /dev/null +++ b/config/initializers/opentelemetry.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +otel_config = Sagittarius::Configuration.config[:opentelemetry] +return unless otel_config[:enabled] + +require 'opentelemetry-metrics-sdk' +require 'opentelemetry/sdk' + +require_relative '../../lib/sagittarius/open_telemetry/encoding_sanitizer' + +begin + OpenTelemetry.logger.formatter = Code0::ZeroTrack::Logs::JsonFormatter.new + + resource_conventions = OpenTelemetry::SemanticConventions::Resource + otel_resource = OpenTelemetry::SDK::Resources::Resource.create( + resource_conventions::DEPLOYMENT_ENVIRONMENT => Rails.env.to_s, + resource_conventions::SERVICE_NAME => otel_config[:service_name], + resource_conventions::SERVICE_VERSION => Sagittarius::Version + ) + + OpenTelemetry::SDK.configure do |c| + c.resource = otel_resource + c.use_all({ + 'OpenTelemetry::Instrumentation::ActiveJob' => { + propagation_style: :child, + span_naming: :job_class, + }, + }) + end + + if defined?(OpenTelemetry::Exporter::OTLP::Exporter) + OpenTelemetry::Exporter::OTLP::Exporter.prepend(Sagittarius::OpenTelemetry::EncodingSanitizer) + end + if defined?(OpenTelemetry::Exporter::OTLP::Metrics::Util) + OpenTelemetry::Exporter::OTLP::Metrics::Util.prepend(Sagittarius::OpenTelemetry::EncodingSanitizer) + end + if defined?(OpenTelemetry::Exporter::OTLP::Logs::LogsExporter) + OpenTelemetry::Exporter::OTLP::Logs::LogsExporter.prepend(Sagittarius::OpenTelemetry::EncodingSanitizer) + end + + metrics_exporter = OpenTelemetry::Exporter::OTLP::Metrics::MetricsExporter.new( + endpoint: otel_config[:metrics_endpoint] + ) + metric_reader = OpenTelemetry::SDK::Metrics::Export::PeriodicMetricReader.new(exporter: metrics_exporter) + OpenTelemetry.meter_provider.add_metric_reader(metric_reader) +rescue StandardError => e + Code0::ZeroTrack::Loggable.logger.warn(message: 'Failed to configure OpenTelemetry', exception: e) +end diff --git a/config/sagittarius.example.yml b/config/sagittarius.example.yml index 3a2576e4..abe82e00 100644 --- a/config/sagittarius.example.yml +++ b/config/sagittarius.example.yml @@ -33,4 +33,11 @@ velorum: jwt_secret: jwt_ttl_minutes: 8 +opentelemetry: + enabled: false + service_name: sagittarius + logs_endpoint: + metrics_endpoint: + traces_endpoint: + application_setting_overrides: {} diff --git a/lib/sagittarius/configuration.rb b/lib/sagittarius/configuration.rb index aee9f800..d1ba0fda 100644 --- a/lib/sagittarius/configuration.rb +++ b/lib/sagittarius/configuration.rb @@ -72,6 +72,13 @@ def self.defaults jwt_secret: nil, jwt_ttl_minutes: 8, }, + opentelemetry: { + enabled: false, + service_name: 'sagittarius', + logs_endpoint: nil, + metrics_endpoint: nil, + traces_endpoint: nil, + }, application_setting_overrides: {}, } end diff --git a/lib/sagittarius/grpc/launcher.rb b/lib/sagittarius/grpc/launcher.rb index 905fc85f..f980558c 100644 --- a/lib/sagittarius/grpc/launcher.rb +++ b/lib/sagittarius/grpc/launcher.rb @@ -10,6 +10,7 @@ class Launcher def create_server # grpc handles interceptors in opposite order. Reversing so we can list them in top-to-bottom order @server = GRPC::RpcServer.new(interceptors: [ + Sagittarius::Middleware::Grpc::OpenTelemetry.new, Sagittarius::Middleware::Grpc::Context.new, Sagittarius::Middleware::Grpc::Logger.new, Sagittarius::Middleware::Grpc::Authentication.new diff --git a/lib/sagittarius/middleware/grpc/open_telemetry.rb b/lib/sagittarius/middleware/grpc/open_telemetry.rb new file mode 100644 index 00000000..22d31ec0 --- /dev/null +++ b/lib/sagittarius/middleware/grpc/open_telemetry.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +module Sagittarius + module Middleware + module Grpc + class OpenTelemetry < Grpc::AllMethodServerInterceptor + Tracer = ::OpenTelemetry.tracer_provider.tracer('sagittarius-grpc') + + def execute(method:, call:, **_, &block) + service_name = method.owner.service_name + found_method, = method.owner + .rpc_descs + .find { |k, _| ::GRPC::GenericService.underscore(k.to_s) == method.name.to_s } + + method_name = found_method&.to_s || '(unknown)' + + links = extract_remote_span_links(call) + + Tracer.in_span("/#{service_name}/#{method_name}", links: links, &block) + end + + private + + def extract_remote_span_links(call) + extracted_context = ::OpenTelemetry.propagation.extract(call.metadata) + remote_span_context = ::OpenTelemetry::Trace.current_span(extracted_context).context + + return [] unless remote_span_context.valid? + + [::OpenTelemetry::Trace::Link.new(remote_span_context)] + rescue StandardError + [] + end + end + end + end +end diff --git a/lib/sagittarius/open_telemetry/encoding_sanitizer.rb b/lib/sagittarius/open_telemetry/encoding_sanitizer.rb new file mode 100644 index 00000000..52c5ca0c --- /dev/null +++ b/lib/sagittarius/open_telemetry/encoding_sanitizer.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +# Workaround for https://github.com/open-telemetry/opentelemetry-ruby/issues/1649 +# Rack delivers HTTP headers as ASCII-8BIT strings. When these contain non-UTF-8 +# bytes (e.g. malicious user agents), the OTLP protobuf serializer raises +# Encoding::UndefinedConversionError. This patch sanitizes strings to valid UTF-8 +# before they reach protobuf. Remove once the upstream issue is resolved. +module Sagittarius + module OpenTelemetry + module EncodingSanitizer + def self.sanitize(value) + return value unless value.is_a?(String) + return value if value.encoding == Encoding::UTF_8 && value.valid_encoding? + + value.encode('UTF-8', invalid: :replace, undef: :replace) + end + + private + + def as_otlp_any_value(value) + super(EncodingSanitizer.sanitize(value)) + end + end + end +end diff --git a/spec/grpc/execution_handler_spec.rb b/spec/grpc/execution_handler_spec.rb index 027554db..bf3f2f09 100644 --- a/spec/grpc/execution_handler_spec.rb +++ b/spec/grpc/execution_handler_spec.rb @@ -44,7 +44,7 @@ queues = GrpcStreamHandler.yielders.dig(described_class, :test, runtime.id) expect(queues.size).to eq(1) - queues.each { |queue| queue << :end } + queues.each { |queue| queue << GrpcStreamHandler::StreamItem.new(data: :end, otel_context: nil) } end end @@ -60,7 +60,7 @@ expect(Timeout.timeout(1) { first_enumerator.to_a }).to eq([]) - GrpcStreamHandler.yielders.dig(described_class, :test, runtime.id).each { |queue| queue << :end } + GrpcStreamHandler.yielders.dig(described_class, :test, runtime.id).each { |queue| queue << GrpcStreamHandler::StreamItem.new(data: :end, otel_context: nil) } end end end diff --git a/spec/lib/sagittarius/middleware/grpc/open_telemetry_spec.rb b/spec/lib/sagittarius/middleware/grpc/open_telemetry_spec.rb new file mode 100644 index 00000000..a689e0c0 --- /dev/null +++ b/spec/lib/sagittarius/middleware/grpc/open_telemetry_spec.rb @@ -0,0 +1,164 @@ +# frozen_string_literal: true + +require 'rails_helper' +require 'google/protobuf/well_known_types' + +RSpec.describe Sagittarius::Middleware::Grpc::OpenTelemetry do + let(:rpc_class) do + Class.new do + include GRPC::GenericService + + self.marshal_class_method = :encode + self.unmarshal_class_method = :decode + self.service_name = 'test.Test' + + rpc :Test, Google::Protobuf::Value, Google::Protobuf::Value + end + end + + let(:service_class) do + Class.new(rpc_class) do + def test(_msg, _call) + # Do nothing + end + end + end + + let(:method) { service_class.new.method(:test) } + let(:request) { double } + let(:metadata) { {} } + let(:call) { instance_double(GRPC::ActiveCall::SingleReqView, peer: '', metadata: metadata) } + + let(:interceptor) { described_class.new } + + describe '#request_response' do + context 'when no trace context is present in metadata' do + it 'yields control' do + expect { |b| interceptor.request_response(request: request, call: call, method: method, &b) } + .to yield_control + end + + it 'creates a span with no links' do + span = nil + + interceptor.request_response(request: request, call: call, method: method) do + span = OpenTelemetry::Trace.current_span + end + + expect(span).not_to be_nil + end + end + + context 'when valid trace context is present in metadata' do + let(:metadata) do + { 'traceparent' => '00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01' } + end + + it 'yields control' do + expect { |b| interceptor.request_response(request: request, call: call, method: method, &b) } + .to yield_control + end + + it 'creates a span linked to the remote context' do + span = nil + + interceptor.request_response(request: request, call: call, method: method) do + span = OpenTelemetry::Trace.current_span + end + + expect(span).not_to be_nil + end + end + + context 'when invalid trace context is present in metadata' do + let(:metadata) do + { 'traceparent' => 'garbage-value' } + end + + it 'yields control without raising' do + expect { |b| interceptor.request_response(request: request, call: call, method: method, &b) } + .to yield_control + end + end + end + + describe '#bidi_streamer' do + context 'when no trace context is present in metadata' do + it 'yields control' do + expect { |b| interceptor.bidi_streamer(request: request, call: call, method: method, &b) } + .to yield_control + end + end + + context 'when valid trace context is present in metadata' do + let(:metadata) do + { 'traceparent' => '00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01' } + end + + it 'yields control' do + expect { |b| interceptor.bidi_streamer(request: request, call: call, method: method, &b) } + .to yield_control + end + end + end + + describe 'span attributes' do + it 'names the span after the service and method' do + spans = [] + + allow(described_class::Tracer).to receive(:in_span).and_wrap_original do |original, name, **kwargs, &blk| + spans << { name: name, links: kwargs[:links] } + original.call(name, **kwargs, &blk) + end + + # rubocop:disable Lint/EmptyBlock -- the block is part of the api and needs to be given + interceptor.request_response(request: request, call: call, method: method) {} + # rubocop:enable Lint/EmptyBlock + + expect(spans.size).to eq(1) + expect(spans.first[:name]).to eq('/test.Test/Test') + end + + context 'when valid trace context is present' do + let(:metadata) do + { 'traceparent' => '00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01' } + end + + it 'passes a link to the remote span context' do + spans = [] + + allow(described_class::Tracer).to receive(:in_span).and_wrap_original do |original, name, **kwargs, &blk| + spans << { name: name, links: kwargs[:links] } + original.call(name, **kwargs, &blk) + end + + # rubocop:disable Lint/EmptyBlock -- the block is part of the api and needs to be given + interceptor.request_response(request: request, call: call, method: method) {} + # rubocop:enable Lint/EmptyBlock + + expect(spans.first[:links].size).to eq(1) + + link = spans.first[:links].first + expect(link.span_context.hex_trace_id).to eq('0af7651916cd43dd8448eb211c80319c') + expect(link.span_context.hex_span_id).to eq('b7ad6b7169203331') + end + end + + context 'when no trace context is present' do + it 'passes no links' do + spans = [] + + allow(described_class::Tracer).to receive(:in_span).and_wrap_original do |original, name, **kwargs, &blk| + spans << { name: name, links: kwargs[:links] } + original.call(name, **kwargs, &blk) + end + + # rubocop:disable Lint/EmptyBlock -- the block is part of the api and needs to be given + interceptor.request_response(request: request, call: call, method: method) {} + # rubocop:enable Lint/EmptyBlock + + expect(spans.first[:links]).to be_empty + end + end + end +end