lib/temporalio/runtime.rb



# frozen_string_literal: true

require 'temporalio/internal/bridge'
require 'temporalio/internal/bridge/runtime'
require 'temporalio/internal/metric'
require 'temporalio/metric'

module Temporalio
  # Runtime for Temporal Ruby SDK.
  #
  # Only one global {Runtime} needs to exist. Users are encouraged to use {default}. To configure it, create a runtime
  # before any clients are created, and set it via {default=}. Every time a new runtime is created, a new internal Rust
  # thread pool is created.
  class Runtime
    TelemetryOptions = Data.define(
      :logging,
      :metrics
    )

    # Telemetry options for the runtime.
    #
    # @!attribute logging
    #   @return [LoggingOptions, nil] Logging options, default is new {LoggingOptions} with no parameters. Can be set
    #     to nil to disable logging.
    # @!attribute metrics
    #   @return [MetricsOptions, nil] Metrics options.
    class TelemetryOptions
      # Create telemetry options.
      #
      # @param logging [LoggingOptions, nil] Logging options, default is new {LoggingOptions} with no parameters. Can be
      #   set to nil to disable logging.
      # @param metrics [MetricsOptions, nil] Metrics options.
      def initialize(logging: LoggingOptions.new, metrics: nil)
        super
      end

      # @!visibility private
      def _to_bridge
        # @type self: TelemetryOptions
        Internal::Bridge::Runtime::TelemetryOptions.new(
          logging: logging&._to_bridge,
          metrics: metrics&._to_bridge
        )
      end
    end

    LoggingOptions = Data.define(
      :log_filter
      # TODO(cretz): forward_to
    )

    # Logging options for runtime telemetry.
    #
    # @!attribute log_filter
    #   @return [LoggingFilterOptions, String] Logging filter for Core, default is new {LoggingFilterOptions} with no
    #     parameters.
    class LoggingOptions
      # Create logging options
      #
      # @param log_filter [LoggingFilterOptions, String] Logging filter for Core.
      def initialize(log_filter: LoggingFilterOptions.new)
        super
      end

      # @!visibility private
      def _to_bridge
        # @type self: LoggingOptions
        Internal::Bridge::Runtime::LoggingOptions.new(
          log_filter: if log_filter.is_a?(String)
                        log_filter
                      elsif log_filter.is_a?(LoggingFilterOptions)
                        log_filter._to_bridge
                      else
                        raise 'Log filter must be string or LoggingFilterOptions'
                      end
        )
      end
    end

    LoggingFilterOptions = Data.define(
      :core_level,
      :other_level
    )

    # Logging filter options for Core.
    #
    # @!attribute core_level
    #   @return ['TRACE', 'DEBUG', 'INFO', 'WARN', 'ERROR'] Log level for Core log messages.
    # @!attribute other_level
    #   @return ['TRACE', 'DEBUG', 'INFO', 'WARN', 'ERROR'] Log level for other Rust log messages.
    class LoggingFilterOptions
      # Create logging filter options.
      #
      # @param core_level ['TRACE', 'DEBUG', 'INFO', 'WARN', 'ERROR'] Log level for Core log messages.
      # @!attribute other_level ['TRACE', 'DEBUG', 'INFO', 'WARN', 'ERROR'] Log level for other Rust log messages.
      def initialize(core_level: 'WARN', other_level: 'ERROR')
        super
      end

      # @!visibility private
      def _to_bridge
        # @type self: LoggingFilterOptions
        "#{other_level},temporal_sdk_core=#{core_level},temporal_client=#{core_level},temporal_sdk=#{core_level}"
      end
    end

    MetricsOptions = Data.define(
      :opentelemetry,
      :prometheus,
      :attach_service_name,
      :global_tags,
      :metric_prefix
    )

    # Metrics options for runtime telemetry. Either {opentelemetry} or {prometheus} required, but not both.
    #
    # @!attribute opentelemetry
    #   @return [OpenTelemetryMetricsOptions, nil] OpenTelemetry options if using OpenTelemetry. This is mutually
    #     exclusive with +prometheus+.
    # @!attribute prometheus
    #   @return [PrometheusMetricsOptions, nil] Prometheus options if using Prometheus. This is mutually exclusive with
    #     +opentelemetry+.
    # @!attribute attach_service_name
    #   @return [Boolean] Whether to put the service_name on every metric.
    # @!attribute global_tags
    #   @return [Hash<String, String>, nil] Resource tags to be applied to all metrics.
    # @!attribute metric_prefix
    #   @return [String, nil] Prefix to put on every Temporal metric. If unset, defaults to `temporal_`.
    class MetricsOptions
      # Create metrics options. Either `opentelemetry` or `prometheus` required, but not both.
      #
      # @param opentelemetry [OpenTelemetryMetricsOptions, nil] OpenTelemetry options if using OpenTelemetry. This is
      #   mutually exclusive with `prometheus`.
      # @param prometheus [PrometheusMetricsOptions, nil] Prometheus options if using Prometheus. This is mutually
      #   exclusive with `opentelemetry`.
      # @param attach_service_name [Boolean] Whether to put the service_name on every metric.
      # @param global_tags [Hash<String, String>, nil] Resource tags to be applied to all metrics.
      # @param metric_prefix [String, nil] Prefix to put on every Temporal metric. If unset, defaults to `temporal_`.
      def initialize(
        opentelemetry: nil,
        prometheus: nil,
        attach_service_name: true,
        global_tags: nil,
        metric_prefix: nil
      )
        super
      end

      # @!visibility private
      def _to_bridge
        # @type self: MetricsOptions
        Internal::Bridge::Runtime::MetricsOptions.new(
          opentelemetry: opentelemetry&._to_bridge,
          prometheus: prometheus&._to_bridge,
          attach_service_name:,
          global_tags:,
          metric_prefix:
        )
      end
    end

    OpenTelemetryMetricsOptions = Data.define(
      :url,
      :headers,
      :metric_periodicity,
      :metric_temporality,
      :durations_as_seconds
    )

    # Options for exporting metrics to OpenTelemetry.
    #
    # @!attribute url
    #   @return [String] URL for OpenTelemetry endpoint.
    # @!attribute headers
    #   @return [Hash<String, String>, nil] Headers for OpenTelemetry endpoint.
    # @!attribute metric_periodicity
    #   @return [Float, nil] How frequently metrics should be exported, unset uses internal default.
    # @!attribute metric_temporality
    #   @return [MetricTemporality] How frequently metrics should be exported, default is
    #     {MetricTemporality::CUMULATIVE}.
    # @!attribute durations_as_seconds
    #   @return [Boolean] Whether to use float seconds instead of integer milliseconds for durations, default is
    #     +false+.
    class OpenTelemetryMetricsOptions
      # OpenTelemetry metric temporality.
      module MetricTemporality
        CUMULATIVE = 1
        DELTA = 2
      end

      # Create OpenTelemetry options.
      #
      # @param url [String] URL for OpenTelemetry endpoint.
      # @param headers [Hash<String, String>, nil] Headers for OpenTelemetry endpoint.
      # @param metric_periodicity [Float, nil] How frequently metrics should be exported, unset uses internal default.
      # @param metric_temporality [MetricTemporality] How frequently metrics should be exported.
      # @param durations_as_seconds [Boolean] Whether to use float seconds instead of integer milliseconds for
      #   durations.
      def initialize(
        url:,
        headers: nil,
        metric_periodicity: nil,
        metric_temporality: MetricTemporality::CUMULATIVE,
        durations_as_seconds: false
      )
        super
      end

      # @!visibility private
      def _to_bridge
        # @type self: OpenTelemetryMetricsOptions
        Internal::Bridge::Runtime::OpenTelemetryMetricsOptions.new(
          url:,
          headers:,
          metric_periodicity:,
          metric_temporality_delta: case metric_temporality
                                    when MetricTemporality::CUMULATIVE then false
                                    when MetricTemporality::DELTA then true
                                    else raise 'Unrecognized metric temporality'
                                    end,
          durations_as_seconds:
        )
      end
    end

    PrometheusMetricsOptions = Data.define(
      :bind_address,
      :counters_total_suffix,
      :unit_suffix,
      :durations_as_seconds
    )

    # Options for exporting metrics to Prometheus.
    #
    # @!attribute bind_address
    #   @return [String] Address to bind to for Prometheus endpoint.
    # @!attribute counters_total_suffix
    #   @return [Boolean] If `true`, all counters will include a `_total` suffix.
    # @!attribute unit_suffix
    #   @return [Boolean] If `true`, all histograms will include the unit in their name as a suffix.
    # @!attribute durations_as_seconds
    #   @return [Boolean] Whether to use float seconds instead of integer milliseconds for durations.
    class PrometheusMetricsOptions
      # Create Prometheus options.
      #
      # @param bind_address [String] Address to bind to for Prometheus endpoint.
      # @param counters_total_suffix [Boolean] If `true`, all counters will include a `_total` suffix.
      # @param unit_suffix [Boolean] If `true`, all histograms will include the unit in their name as a suffix.
      # @param durations_as_seconds [Boolean] Whether to use float seconds instead of integer milliseconds for
      #   durations.
      def initialize(
        bind_address:,
        counters_total_suffix: false,
        unit_suffix: false,
        durations_as_seconds: false
      )
        super
      end

      # @!visibility private
      def _to_bridge
        # @type self: PrometheusMetricsOptions
        Internal::Bridge::Runtime::PrometheusMetricsOptions.new(
          bind_address:,
          counters_total_suffix:,
          unit_suffix:,
          durations_as_seconds:
        )
      end
    end

    # Default runtime, lazily created upon first access. If needing a different default, make sure it is updated via
    # {default=} before this is called (either directly or as a parameter to something like {Client}).
    #
    # @return [Runtime] Default runtime.
    def self.default
      @default ||= Runtime.new
    end

    # Set the default runtime. Must be called before {default} accessed.
    #
    # @param runtime [Runtime] Runtime to set as default.
    # @raise If default has already been accessed.
    def self.default=(runtime)
      raise 'Runtime already set or requested' unless @default.nil?

      @default = runtime
    end

    # @return [Metric::Meter] Metric meter that can create and record metric values.
    attr_reader :metric_meter

    # Create new Runtime. For most users, this should only be done once globally. In addition to creating a Rust thread
    # pool, this also consumes a Ruby thread for its lifetime.
    #
    # @param telemetry [TelemetryOptions] Telemetry options to set.
    def initialize(telemetry: TelemetryOptions.new)
      @core_runtime = Internal::Bridge::Runtime.new(
        Internal::Bridge::Runtime::Options.new(telemetry: telemetry._to_bridge)
      )
      @metric_meter = Internal::Metric::Meter.create_from_runtime(self) || Metric::Meter.null
      # We need a thread to run the command loop
      # TODO(cretz): Is this something users should be concerned about or need control over?
      Thread.new do
        @core_runtime.run_command_loop
      end
    end

    # @!visibility private
    def _core_runtime
      @core_runtime
    end
  end
end