Verified Commit b5934bb8 authored by Hercules Merscher's avatar Hercules Merscher 🌴
Browse files

feat: Configure OTel SDK via config block

parent e1177894
Loading
Loading
Loading
Loading
+15 −2
Original line number Diff line number Diff line
@@ -6,12 +6,18 @@ module Labkit
    # distributed tracing system within the process, given the
    # tracing connection string
    class Factory
      def self.create_tracer(service_name, connection_string)
      # @param service_name [String] The service name for the tracer
      # @param connection_string [String] The connection string (e.g., "otlp://localhost:4318")
      # @yield [config] Optional configuration block for OpenTelemetry SDK customization (OTLP only)
      # @yieldparam config [OpenTelemetry::SDK::Configurator] The SDK configurator
      # @return [Tracer, nil] The configured tracer or nil if initialization fails
      def self.create_tracer(service_name, connection_string, &config_block)
        return unless connection_string.present?

        if Tracing.otlp_connection?(connection_string)
          OpenTelemetryFactory.create_tracer(service_name, connection_string)
          OpenTelemetryFactory.create_tracer(service_name, connection_string, &config_block)
        elsif Tracing.opentracing_connection?(connection_string)
          warn_opentracing_block_ignored if config_block
          OpenTracingFactory.create_tracer(service_name, connection_string)
        else
          raise "Unknown protocol"
@@ -20,6 +26,13 @@ module Labkit
        warn "Unable to instantiate tracer: #{e}"
        nil
      end

      def self.warn_opentracing_block_ignored
        warn "Warning: Configuration block provided but ignored - " \
          "OpenTracing connection strings don't support SDK customization. " \
          "Use OTLP (otlp://) for OpenTelemetry SDK features."
      end
      private_class_method :warn_opentracing_block_ignored
    end
  end
end
+76 −18
Original line number Diff line number Diff line
@@ -24,7 +24,12 @@ module Labkit
      DEFAULT_GRPC_ENDPOINT = "http://localhost:4317"

      class << self
        def create_tracer(service_name, connection_string)
        # @param service_name [String] The service name for the tracer
        # @param connection_string [String] The connection string (e.g., "otlp://localhost:4318")
        # @yield [config] Optional configuration block for OpenTelemetry SDK customization
        # @yieldparam config [OpenTelemetry::SDK::Configurator] The SDK configurator
        # @return [Tracer, nil] The configured tracer or nil if initialization fails
        def create_tracer(service_name, connection_string, &config_block)
          return unless connection_string.present?

          options = parse_otlp_connection_string(connection_string)
@@ -34,27 +39,22 @@ module Labkit
          # parse exporter headers as necessary
          headers = build_headers(options)

          # Get sampler and exporter
          # Get sampler and exporter from GITLAB_TRACING
          sampler = get_sampler(options[:sampler], options[:sampler_param])
          exporter = get_exporter(options[:http_endpoint], options[:grpc_endpoint], options[:udp_endpoint], headers)

          # Build the tracer provider manually to have more control
          span_processors = []
          span_processors << OpenTelemetry::SDK::Trace::Export::BatchSpanProcessor.new(exporter) if exporter

          resource = OpenTelemetry::SDK::Resources::Resource.create(
          # Build base resource
          base_resource = OpenTelemetry::SDK::Resources::Resource.create(
            OpenTelemetry::SemanticConventions::Resource::SERVICE_NAME => service_name
          )

          tracer_provider = OpenTelemetry::SDK::Trace::TracerProvider.new(
            resource: resource,
            sampler: sampler
          )

          span_processors.each { |processor| tracer_provider.add_span_processor(processor) }

          # Register the tracer provider globally
          OpenTelemetry.tracer_provider = tracer_provider
          # If user provided a config block, use SDK.configure for full configurability
          # Otherwise, use the manual configuration for backward compatibility
          if config_block
            configure_with_sdk(service_name, base_resource, sampler, exporter, &config_block)
          else
            configure_manually(service_name, base_resource, sampler, exporter)
          end

          extra_params = options.except(
            :sampler,
@@ -64,7 +64,8 @@ module Labkit
            :udp_endpoint,
            :strict_parsing,
            :debug,
            :service_name
            :service_name,
            :protocol
          )

          if extra_params.present?
@@ -75,11 +76,68 @@ module Labkit
            warn message
          end

          tracer_provider.tracer(service_name)
          OpenTelemetry.tracer_provider.tracer(service_name)
        end

        private

        def configure_with_sdk(service_name, base_resource, sampler, exporter)
          # Use OpenTelemetry::SDK.configure for user customization
          # We'll create the provider manually first with sampler, then allow customization
          OpenTelemetry::SDK.configure do |c|
            c.service_name = service_name
            c.resource = base_resource

            # Add span processor with our exporter from GITLAB_TRACING
            if exporter
              c.add_span_processor(
                OpenTelemetry::SDK::Trace::Export::BatchSpanProcessor.new(exporter)
              )
            end

            # Yield to user configuration block (runs last, can override/extend)
            yield(c) if block_given?
          end

          # SDK.configure doesn't expose sampler configuration directly
          # We need to replace the tracer provider to set the sampler
          # This is a known limitation of the OpenTelemetry Ruby SDK
          current_provider = OpenTelemetry.tracer_provider
          return unless current_provider.is_a?(OpenTelemetry::SDK::Trace::TracerProvider)

          # Create new provider with sampler, preserving resource
          new_provider = OpenTelemetry::SDK::Trace::TracerProvider.new(
            resource: current_provider.resource,
            sampler: sampler
          )

          # Re-add span processor with our exporter
          if exporter
            new_provider.add_span_processor(
              OpenTelemetry::SDK::Trace::Export::BatchSpanProcessor.new(exporter)
            )
          end

          # Set globally
          OpenTelemetry.tracer_provider = new_provider
        end

        def configure_manually(_service_name, base_resource, sampler, exporter)
          # Manual configuration (backward compatible)
          span_processors = []
          span_processors << OpenTelemetry::SDK::Trace::Export::BatchSpanProcessor.new(exporter) if exporter

          tracer_provider = OpenTelemetry::SDK::Trace::TracerProvider.new(
            resource: base_resource,
            sampler: sampler
          )

          span_processors.each { |processor| tracer_provider.add_span_processor(processor) }

          # Register the tracer provider globally
          OpenTelemetry.tracer_provider = tracer_provider
        end

        def build_headers(options)
          return {} unless options&.key?(:http_endpoint) || options&.key?(:grpc_endpoint)

+53 −0
Original line number Diff line number Diff line
@@ -44,6 +44,59 @@ describe Labkit::Tracing::Factory do

        expect(described_class.create_tracer(service_name, connection_string)).to be(mock_tracer)
      end

      it "passes config block to OpenTelemetryFactory" do
        connection_string = "otlp://localhost:4318"
        config_block = proc { |c| c.service_name = "custom" }

        expect(Labkit::Tracing::OpenTelemetryFactory).to receive(:create_tracer) do |name, conn, &block|
          expect(name).to eq(service_name)
          expect(conn).to eq(connection_string)
          expect(block).to eq(config_block)
          mock_tracer
        end

        result = described_class.create_tracer(service_name, connection_string, &config_block)
        expect(result).to be(mock_tracer)
      end
    end

    context "when config block is provided" do
      it "works without block for backward compatibility" do
        connection_string = "otlp://localhost:4318"
        expect(Labkit::Tracing::OpenTelemetryFactory).to receive(:create_tracer).with(
          service_name,
          connection_string
        ).and_call_original

        expect { described_class.create_tracer(service_name, connection_string) }.not_to raise_error
      end

      it "warns when block provided with OpenTracing connection" do
        connection_string = "opentracing://jaeger"
        expect(Labkit::Tracing::OpenTracingFactory).to receive(:create_tracer).with(
          service_name,
          connection_string
        ).and_return(double("tracer"))

        expect do
          described_class.create_tracer(service_name, connection_string) do |c|
            c.service_name = "custom"
          end
        end.to output(/Configuration block provided but ignored/).to_stderr
      end

      it "does not warn when no block provided with OpenTracing" do
        connection_string = "opentracing://jaeger"
        expect(Labkit::Tracing::OpenTracingFactory).to receive(:create_tracer).with(
          service_name,
          connection_string
        ).and_return(double("tracer"))

        expect do
          described_class.create_tracer(service_name, connection_string)
        end.not_to output(/Configuration block/).to_stderr
      end
    end
  end
end
+92 −0
Original line number Diff line number Diff line
@@ -357,4 +357,96 @@ describe Labkit::Tracing::OpenTelemetryFactory do
      described_class.send(:get_exporter, "http://localhost:4318", "http://localhost:4317", nil, headers)
    end
  end

  describe "with config block" do
    let(:service_name) { "test-service" }
    let(:connection_string) { "otlp://localhost:4318" }

    after do
      OpenTelemetry.tracer_provider = OpenTelemetry::Internal::ProxyTracerProvider.new
    end

    it "yields to the block with configurator" do
      block_called = false
      received_configurator = nil

      described_class.create_tracer(service_name, connection_string) do |c|
        block_called = true
        received_configurator = c
      end

      expect(block_called).to be true
      expect(received_configurator).to be_a(OpenTelemetry::SDK::Configurator)
    end

    it "applies GITLAB_TRACING settings before block" do
      tracer = described_class.create_tracer(service_name, connection_string) do |c|
        # Block runs after GITLAB_TRACING settings
        expect(c.service_name).to eq(service_name)
      end

      expect(tracer).to be_a(OpenTelemetry::Trace::Tracer)
    end

    it "allows block to add span processors" do
      custom_processor = instance_double(OpenTelemetry::SDK::Trace::SpanProcessor)
      allow(custom_processor).to receive(:on_start)
      allow(custom_processor).to receive(:on_finish)
      allow(custom_processor).to receive(:shutdown)
      allow(custom_processor).to receive(:force_flush)

      tracer = described_class.create_tracer(service_name, connection_string) do |c|
        c.add_span_processor(custom_processor)
      end

      expect(tracer).to be_a(OpenTelemetry::Trace::Tracer)
      expect(OpenTelemetry.tracer_provider).to be_a(OpenTelemetry::SDK::Trace::TracerProvider)
    end

    it "preserves GITLAB_TRACING exporter configuration" do
      expect(OpenTelemetry::Exporter::OTLP::Exporter).to receive(:new).with(
        endpoint: "http://localhost:4318",
        headers: {}
      ).and_call_original.at_least(:once)

      tracer = described_class.create_tracer(service_name, connection_string) do |c|
        # User config shouldn't remove GITLAB_TRACING exporter
      end

      expect(tracer).to be_a(OpenTelemetry::Trace::Tracer)
    end

    it "works without config block for backward compatibility" do
      tracer = described_class.create_tracer(service_name, connection_string)

      expect(tracer).to be_a(OpenTelemetry::Trace::Tracer)
      expect(OpenTelemetry.tracer_provider).to be_a(OpenTelemetry::SDK::Trace::TracerProvider)
    end

    it "applies sampler from connection string" do
      sampler_connection = "otlp://localhost:4318?sampler=const&sampler_param=0"
      tracer = described_class.create_tracer(service_name, sampler_connection) do |c|
        # Sampler from connection string should be applied
      end

      expect(tracer).to be_a(OpenTelemetry::Trace::Tracer)
      provider = OpenTelemetry.tracer_provider
      expect(provider).to be_a(OpenTelemetry::SDK::Trace::TracerProvider)
    end

    it "handles multiple calls (last wins)" do
      # First call
      tracer1 = described_class.create_tracer("service1", connection_string) do |c|
        c.service_name = "service1"
      end

      # Second call should reconfigure
      tracer2 = described_class.create_tracer("service2", connection_string) do |c|
        c.service_name = "service2"
      end

      expect(tracer1).to be_a(OpenTelemetry::Trace::Tracer)
      expect(tracer2).to be_a(OpenTelemetry::Trace::Tracer)
    end
  end
end