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

feat: Extending factory to include OTel

parent 02d64b16
Loading
Loading
Loading
Loading
+2 −0
Original line number Diff line number Diff line
@@ -9,6 +9,8 @@ module Labkit
    autoload :GRPC, "labkit/tracing/grpc"
    autoload :GRPCInterceptor, "labkit/tracing/grpc_interceptor" # Deprecated
    autoload :JaegerFactory, "labkit/tracing/jaeger_factory"
    autoload :OpenTelemetryFactory, "labkit/tracing/open_telemetry_factory"
    autoload :OpenTracingFactory, "labkit/tracing/open_tracing_factory"
    autoload :RackMiddleware, "labkit/tracing/rack_middleware"
    autoload :Rails, "labkit/tracing/rails"
    autoload :Redis, "labkit/tracing/redis"
+11 −34
Original line number Diff line number Diff line
# frozen_string_literal: true

require "cgi"

module Labkit
  module Tracing
    # Factory provides tools for setting up and configuring the
    # distributed tracing system within the process, given the
    # tracing connection string
    class Factory
      OPENTRACING_SCHEME = "opentracing"

      def self.create_tracer(service_name, connection_string)
        return unless connection_string.present?

        begin
          opentracing_details = parse_connection_string(connection_string)
          driver_name = opentracing_details[:driver_name]

          case driver_name
          when "jaeger"
            JaegerFactory.create_tracer(service_name, opentracing_details[:options])
        if otlp_connection?(connection_string)
          OpenTelemetryFactory.create_tracer(service_name, connection_string)
        elsif opentracing_connection?(connection_string)
          OpenTracingFactory.create_tracer(service_name, connection_string)
        else
            raise "Unknown driver: #{driver_name}"
          raise "Unknown protocol"
        end

          # Can't create the tracer? Warn and continue sans tracer
      rescue StandardError => e
        warn "Unable to instantiate tracer: #{e}"
        nil
      end
      end

      def self.parse_connection_string(connection_string)
        parsed = URI.parse(connection_string)

        raise "Invalid tracing connection string" unless valid_uri?(parsed)

        { driver_name: parsed.host, options: parse_query(parsed.query) }
      def self.otlp_connection?(connection_string)
        connection_string.to_s.start_with?("#{OpenTelemetryFactory::OTLP_SCHEME}://")
      end
      private_class_method :parse_connection_string

      def self.parse_query(query)
        return {} unless query

        CGI.parse(query).symbolize_keys.transform_values(&:first)
      end
      private_class_method :parse_query

      def self.valid_uri?(uri)
        return false unless uri
      private_class_method :otlp_connection?

        uri.scheme == OPENTRACING_SCHEME && uri.host.to_s =~ /^[a-z0-9_]+$/ && uri.path.empty?
      def self.opentracing_connection?(connection_string)
        connection_string.to_s.start_with?("#{OpenTracingFactory::OPENTRACING_SCHEME}://")
      end
      private_class_method :valid_uri?
      private_class_method :opentracing_connection?
    end
  end
end
+199 −0
Original line number Diff line number Diff line
# frozen_string_literal: true

require "base64"
require "cgi"
require "active_support"
require "active_support/core_ext"

require "opentelemetry/sdk"
require "opentelemetry/exporter/otlp"

module Labkit
  module Tracing
    # OpenTelemetryFactory will configure OpenTelemetry distributed tracing
    class OpenTelemetryFactory
      OTLP_SCHEME = "otlp"

      # When the probabilistic sampler is used, by default 0.1% of requests will be traced
      DEFAULT_PROBABILISTIC_RATE = 0.001

      # The default endpoint for OTLP HTTP exporter
      DEFAULT_HTTP_ENDPOINT = "http://localhost:4318/v1/traces"

      # The default endpoint for OTLP gRPC exporter
      DEFAULT_GRPC_ENDPOINT = "http://localhost:4317"

      class << self
        def create_tracer(service_name, connection_string)
          return unless connection_string.present?

          options = parse_otlp_connection_string(connection_string)
          # The service_name parameter from GITLAB_TRACING takes precedence over the application one
          service_name = options[:service_name] if options[:service_name]

          # parse exporter headers as necessary
          headers = build_headers(options)

          # Get sampler and exporter
          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(
            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

          extra_params = options.except(
            :sampler,
            :sampler_param,
            :http_endpoint,
            :grpc_endpoint,
            :udp_endpoint,
            :strict_parsing,
            :debug,
            :service_name
          )

          if extra_params.present?
            message = "opentelemetry tracer: invalid option: #{extra_params.keys.join(', ')}"

            raise message if options[:strict_parsing]

            warn message
          end

          tracer_provider.tracer(service_name)
        end

        private

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

          endpoint = options[:http_endpoint] || options[:grpc_endpoint]
          return {} unless endpoint

          parsed = URI.parse(endpoint)

          headers = {}
          # add basic auth header only when both user and password are setup correctly
          user = parsed.user
          password = parsed.password
          headers["Authorization"] = "Basic #{Base64.strict_encode64("#{user}:#{password}")}" if user.present? && password.present?

          headers
        end

        def get_sampler(sampler_type, sampler_param)
          case sampler_type
          when "probabilistic"
            sampler_rate = sampler_param ? sampler_param.to_f : DEFAULT_PROBABILISTIC_RATE
            OpenTelemetry::SDK::Trace::Samplers::TraceIdRatioBased.new(sampler_rate)
          when "const"
            if sampler_param == "1"
              OpenTelemetry::SDK::Trace::Samplers::ALWAYS_ON
            else
              OpenTelemetry::SDK::Trace::Samplers::ALWAYS_OFF
            end
          else
            OpenTelemetry::SDK::Trace::Samplers::ALWAYS_ON
          end
        end

        def get_exporter(http_endpoint, grpc_endpoint, udp_endpoint, headers)
          # OpenTelemetry doesn't support UDP, warn if specified
          # https://github.com/open-telemetry/opentelemetry-collector/discussions/6016
          warn "opentelemetry tracer: UDP endpoint not supported, ignoring udp_endpoint option" if udp_endpoint.present?

          if http_endpoint.present?
            get_http_exporter(http_endpoint, headers)
          elsif grpc_endpoint.present?
            get_grpc_exporter(grpc_endpoint, headers)
          end
        end

        def get_http_exporter(endpoint, headers)
          OpenTelemetry::Exporter::OTLP::Exporter.new(
            endpoint: endpoint,
            headers: headers
          )
        end

        def get_grpc_exporter(endpoint, headers)
          # OpenTelemetry Ruby lacks native gRPC exporter support. Fall back to HTTP exporter with protobuf encoding,
          # which is the standard approach and compatible with most OTLP collectors that accept gRPC-style endpoints.
          warn "opentelemetry tracer: gRPC endpoint specified but gRPC exporter not available, using HTTP"

          parsed = URI.parse(endpoint)

          http_port = parsed.port == 4317 ? 4318 : parsed.port

          http_endpoint = URI::HTTP.build(
            scheme: parsed.scheme,
            host: parsed.host,
            port: http_port,
            path: "/v1/traces"
          ).to_s

          get_http_exporter(http_endpoint, headers)
        end

        def parse_otlp_connection_string(connection_string)
          parsed = URI.parse(connection_string)

          endpoint = build_otlp_endpoint(parsed)

          # Parse query parameters for additional options
          options = parse_query(parsed.query)

          # Determine the endpoint type and set the appropriate option
          if parsed.port == 4317 || options[:protocol] == "grpc"
            options[:grpc_endpoint] = endpoint
          else
            # Default to HTTP (port 4318 or custom)
            options[:http_endpoint] = endpoint
          end

          options
        end

        def build_otlp_endpoint(uri)
          # Reconstruct the endpoint URL with scheme, host, port, and path
          scheme = uri.scheme == OTLP_SCHEME ? "http" : uri.scheme
          host = uri.host
          port = uri.port
          path = uri.path.empty? ? "" : uri.path

          # Include userinfo (username:password) if present
          userinfo = uri.userinfo ? "#{uri.userinfo}@" : ""

          # Build the endpoint
          endpoint = "#{scheme}://#{userinfo}#{host}"
          endpoint += ":#{port}" if port
          endpoint += path

          endpoint
        end

        def parse_query(query)
          return {} unless query

          CGI.parse(query).symbolize_keys.transform_values(&:first)
        end
      end
    end
  end
end
+48 −0
Original line number Diff line number Diff line
# frozen_string_literal: true

require "cgi"

module Labkit
  module Tracing
    class OpenTracingFactory
      OPENTRACING_SCHEME = "opentracing"

      def self.create_tracer(service_name, connection_string)
        return unless connection_string.present?

        opentracing_details = parse_connection_string(connection_string)
        driver_name = opentracing_details[:driver_name]

        case driver_name
        when "jaeger"
          JaegerFactory.create_tracer(service_name, opentracing_details[:options])
        else
          raise "Unknown driver: #{driver_name}"
        end
      end

      def self.parse_connection_string(connection_string)
        parsed = URI.parse(connection_string)

        raise "Invalid tracing connection string" unless valid_uri?(parsed)

        { driver_name: parsed.host, options: parse_query(parsed.query) }
      end
      private_class_method :parse_connection_string

      def self.parse_query(query)
        return {} unless query

        CGI.parse(query).symbolize_keys.transform_values(&:first)
      end
      private_class_method :parse_query

      def self.valid_uri?(uri)
        return false unless uri

        uri.scheme == OPENTRACING_SCHEME && uri.host.to_s =~ /^[a-z0-9_]+$/ && uri.path.empty?
      end
      private_class_method :valid_uri?
    end
  end
end
+11 −17
Original line number Diff line number Diff line
@@ -22,33 +22,27 @@ describe Labkit::Tracing::Factory do
      end
    end

    context "when tracing is configured with jaeger" do
    context "when tracing is configured with opentracing" do
      let(:mock_tracer) { double("tracer") }

      it "processes default connections" do
        expect(Labkit::Tracing::JaegerFactory).to receive(:create_tracer).with(service_name, {}).and_return(mock_tracer)
      it "delegates to OpenTracingFactory" do
        expect(Labkit::Tracing::OpenTracingFactory).to receive(:create_tracer).with(service_name, "opentracing://jaeger").and_return(mock_tracer)

        expect(described_class.create_tracer(service_name, "opentracing://jaeger")).to be(mock_tracer)
      end

      it "processes connections with parameters" do
        expect(Labkit::Tracing::JaegerFactory).to receive(:create_tracer).with(service_name, { a: "1", b: "2", c: "3" }).and_return(mock_tracer)

        expect(described_class.create_tracer(service_name, "opentracing://jaeger?a=1&b=2&c=3")).to be(mock_tracer)
    end

      it "processes connections with basic auth credentials" do
        expect(Labkit::Tracing::JaegerFactory).to receive(:create_tracer).with(
    context "when tracing is configured with OpenTelemetry" do
      let(:mock_tracer) { double("tracer") }

      it "delegates to OpenTelemetryFactory with connection string" do
        connection_string = "otlp://localhost:4318"
        expect(Labkit::Tracing::OpenTelemetryFactory).to receive(:create_tracer).with(
          service_name,
          {
            a: "1",
            b: "2",
            c: "3",
            http_endpoint: "https://foo:bar@observe.gitlab.com",
          }
          connection_string
        ).and_return(mock_tracer)

        expect(described_class.create_tracer(service_name, "opentracing://jaeger?http_endpoint=https://foo:bar@observe.gitlab.com&a=1&b=2&c=3")).to be(mock_tracer)
        expect(described_class.create_tracer(service_name, connection_string)).to be(mock_tracer)
      end
    end
  end
Loading