1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
require "opentelemetry/sdk"
require "opentelemetry/exporter/otlp"
require "opentelemetry/instrumentation/rack"
require "sentry-ruby"
require "sentry-opentelemetry"

module ProTacts
  # Tracing runs on OpenTelemetry, which hands every span to Sentry and,
  # when an endpoint is configured, to an OTLP collector such as
  # VictoriaTraces. Sentry's own instrumentation is off (config.ru's
  # instrumenter), so its transactions are built from these spans.
  module Tracing
    # A proxy until configure installs the SDK, which it then delegates to,
    # so the app can hold it from require time.
    TRACER = OpenTelemetry.tracer_provider.tracer("pro_tacts") #: OpenTelemetry::Trace::Tracer

    # The statuses whose transactions Sentry drops: sentry-ruby's default
    # (TRACE_IGNORE_STATUS_CODES_DEFAULT) less 303, which answers every
    # admin write, so an upload or a Save is traced. Applied by scrub,
    # because Sentry's trace_ignore_status_codes never sees the status of
    # a span it did not start.
    UNTRACED_STATUSES = [(301..302), (305..399), (401..404)].freeze #: Array[Range[Integer]]

    #: (String? endpoint) -> void
    def self.configure(endpoint)
      OpenTelemetry::SDK.configure do |otel|
        otel.service_name = "pro-tacts"
        # Named by route rather than the method alone, which would make
        # every request one Sentry transaction. PATH_INFO, not the request
        # URI the instrumentation offers, so the name carries no query.
        otel.use "OpenTelemetry::Instrumentation::Rack",
          url_quantization: ->(_uri, env) { "#{env["REQUEST_METHOD"]} #{env["PATH_INFO"]}" }
        otel.add_span_processor(Sentry::OpenTelemetry::SpanProcessor.instance)
        if endpoint
          exporter = OpenTelemetry::Exporter::OTLP::Exporter.new(endpoint:)
          otel.add_span_processor(OpenTelemetry::SDK::Trace::Export::BatchSpanProcessor.new(exporter))
        end
      end
      OpenTelemetry.propagation = Sentry::OpenTelemetry::Propagator.new
    end

    # The request span's middleware, for the outside of the app's stack.
    #: () -> Array[untyped]
    def self.middleware
      OpenTelemetry::Instrumentation::Rack::Instrumentation.instance.middleware_args
    end

    # For before_send_transaction. Sentry copies every attribute of the
    # request span into the transaction's otel context, which
    # send_default_pii does not reach, so the query string goes here.
    #: (Sentry::TransactionEvent event) -> Sentry::TransactionEvent?
    def self.scrub(event)
      otel = event.contexts[:otel]
      attributes = otel&.[](:attributes)
      return event unless otel && attributes

      status = attributes["http.response.status_code"]
      return if status.is_a?(Integer) && UNTRACED_STATUSES.any? { it.cover?(status) }

      # A finished span's attributes are frozen.
      otel[:attributes] = attributes.except("url.query")
      event
    end
  end
end