diff --git a/sentry-ruby/lib/sentry-ruby.rb b/sentry-ruby/lib/sentry-ruby.rb index c3577d0f0..c1d48db69 100644 --- a/sentry-ruby/lib/sentry-ruby.rb +++ b/sentry-ruby/lib/sentry-ruby.rb @@ -22,6 +22,7 @@ require "sentry/background_worker" require "sentry/session_flusher" require "sentry/cron/monitor_check_ins" +require "sentry/spotlight" [ "sentry/rake", diff --git a/sentry-ruby/lib/sentry/configuration.rb b/sentry-ruby/lib/sentry/configuration.rb index 1f89f31a3..865d3bc05 100644 --- a/sentry-ruby/lib/sentry/configuration.rb +++ b/sentry-ruby/lib/sentry/configuration.rb @@ -7,6 +7,7 @@ require "sentry/dsn" require "sentry/release_detector" require "sentry/transport/configuration" +require "sentry/spotlight/configuration" require "sentry/linecache" require "sentry/interfaces/stacktrace_builder" @@ -234,6 +235,10 @@ def capture_exception_frame_locals=(value) # @return [Boolean, nil] attr_reader :enable_tracing + # Returns the Spotlight::Configuration object + # @return [Spotlight::Configuration] + attr_reader :spotlight + # Send diagnostic client reports about dropped events, true by default # tries to attach to an existing envelope max once every 30s # @return [Boolean] @@ -358,6 +363,8 @@ def initialize @transport = Transport::Configuration.new @gem_specs = Hash[Gem::Specification.map { |spec| [spec.name, spec.version.to_s] }] if Gem::Specification.respond_to?(:map) + @spotlight = Spotlight::Configuration.new + run_post_initialization_callbacks end diff --git a/sentry-ruby/lib/sentry/spotlight.rb b/sentry-ruby/lib/sentry/spotlight.rb new file mode 100644 index 000000000..fef97affd --- /dev/null +++ b/sentry-ruby/lib/sentry/spotlight.rb @@ -0,0 +1,2 @@ +require "sentry/spotlight/configuration" +require "sentry/spotlight/transport" diff --git a/sentry-ruby/lib/sentry/spotlight/configuration.rb b/sentry-ruby/lib/sentry/spotlight/configuration.rb new file mode 100644 index 000000000..294cc14d9 --- /dev/null +++ b/sentry-ruby/lib/sentry/spotlight/configuration.rb @@ -0,0 +1,50 @@ +module Sentry + module Spotlight + # Sentry Spotlight configuration. + class Configuration + + # When enabled, Sentry will send all events and traces to the provided + # Spotlight Sidecar URL. + # Defaults to false. + # @return [Boolean] + attr_reader :enabled + + # Spotlight Sidecar URL as a String. + # Defaults to "http://localhost:8969/stream" + # @return [String] + attr_accessor :sidecar_url + + def initialize + @enabled = false + @sidecar_url = "http://localhost:8969/stream" + end + + def enabled? + enabled + end + + # Enables or disables Spotlight. + def enabled=(value) + unless [true, false].include?(value) + raise ArgumentError, "Spotlight config.enabled must be a boolean" + end + + if value == true + unless ['development', 'test'].include?(environment_from_env) + # Using the default logger here for a one-off warning. + ::Sentry::Logger.new(STDOUT).warn("[Spotlight] Spotlight is enabled in a non-development environment!") + end + end + end + + private + + # TODO: Sentry::Configuration already reads the env the same way as below, but it also has a way to _set_ environment + # in it's config. So this introduces a bug where env could be different, depending on whether the user set the environment + # manually. + def environment_from_env + ENV['SENTRY_CURRENT_ENV'] || ENV['SENTRY_ENVIRONMENT'] || ENV['RAILS_ENV'] || ENV['RACK_ENV'] || 'development' + end + end + end +end diff --git a/sentry-ruby/lib/sentry/spotlight/transport.rb b/sentry-ruby/lib/sentry/spotlight/transport.rb new file mode 100644 index 000000000..8aba4970d --- /dev/null +++ b/sentry-ruby/lib/sentry/spotlight/transport.rb @@ -0,0 +1,77 @@ +# frozen_string_literal: true + +require "net/http" +require "zlib" + +module Sentry + module Spotlight + + + # Spotlight Transport class is like HTTPTransport, + # but it's experimental, with limited featureset. + # - It does not care about rate limits, assuming working with local Sidecar proxy + # - Designed to just report events to Spotlight in development. + # + # TODO: This needs a cleanup, we could extract most of common code into a module. + class Transport + + GZIP_ENCODING = "gzip" + GZIP_THRESHOLD = 1024 * 30 + CONTENT_TYPE = 'application/x-sentry-envelope' + USER_AGENT = "sentry-ruby/#{Sentry::VERSION}" + + # Initialize a new Spotlight transport + # with the provided Spotlight configuration. + def initialize(spotlight_configuration) + @configuration = spotlight_configuration + end + + def send_data(data) + encoding = "" + + if should_compress?(data) + data = Zlib.gzip(data) + encoding = GZIP_ENCODING + end + + headers = { + 'Content-Type' => CONTENT_TYPE, + 'Content-Encoding' => encoding, + 'X-Sentry-Auth' => generate_auth_header, + 'User-Agent' => USER_AGENT + } + + response = conn.start do |http| + request = ::Net::HTTP::Post.new(@configuration.sidecar_url, headers) + request.body = data + http.request(request) + end + + unless response.code.match?(/\A2\d{2}/) + error_info = "the server responded with status #{response.code}" + error_info += "\nbody: #{response.body}" + error_info += " Error in headers is: #{response['x-sentry-error']}" if response['x-sentry-error'] + + raise Sentry::ExternalError, error_info + end + rescue SocketError => e + raise Sentry::ExternalError.new(e.message) + end + + private + + def should_compress?(data) + @transport_configuration.encoding == GZIP_ENCODING && data.bytesize >= GZIP_THRESHOLD + end + + # Similar to HTTPTransport connection, but does not support Proxy and SSL + def conn + sidecar = URL(@configuration.sidecar_url) + connection = ::Net::HTTP.new(sidecar.hostname, sidecar.port, nil) + connection.use_ssl = false + connection + end + + end + end +end diff --git a/sentry-ruby/lib/sentry/transport.rb b/sentry-ruby/lib/sentry/transport.rb index 2b786af78..686ba2dc2 100644 --- a/sentry-ruby/lib/sentry/transport.rb +++ b/sentry-ruby/lib/sentry/transport.rb @@ -32,6 +32,7 @@ class Transport def initialize(configuration) @logger = configuration.logger @transport_configuration = configuration.transport + @spotlight_configuration = configuration.spotlight @dsn = configuration.dsn @rate_limits = {} @send_client_reports = configuration.send_client_reports diff --git a/sentry-ruby/lib/sentry/transport/http_transport.rb b/sentry-ruby/lib/sentry/transport/http_transport.rb index 4b1fa1771..937dd1162 100644 --- a/sentry-ruby/lib/sentry/transport/http_transport.rb +++ b/sentry-ruby/lib/sentry/transport/http_transport.rb @@ -29,9 +29,15 @@ def initialize(*args) @endpoint = @dsn.envelope_endpoint log_debug("Sentry HTTP Transport will connect to #{@dsn.server}") + + if @spotlight_configuration.enabled? + @spotlight_transport = Sentry::Spotlight::Transport.new(@transport_configuration) + end end def send_data(data) + @spotlight_transport.send_data(data) unless @spotlight_transport.nil? + encoding = "" if should_compress?(data) diff --git a/sentry-ruby/spec/sentry/configuration_spec.rb b/sentry-ruby/spec/sentry/configuration_spec.rb index 5e4beb7ee..742532263 100644 --- a/sentry-ruby/spec/sentry/configuration_spec.rb +++ b/sentry-ruby/spec/sentry/configuration_spec.rb @@ -256,6 +256,14 @@ end end + describe "#spotlight" do + it "returns initialized Spotlight config by default" do + spotlight_config = subject.spotlight + expect(spotlight_config.enabled).to eq(false) + expect(spotlight_config.sidecar_url).to eq("http://localhost:8969/stream") + end + end + context 'configuring for async' do it 'should be configurable to send events async' do subject.async = ->(_e) { :ok } diff --git a/sentry-ruby/spec/sentry/transport/http_transport_spec.rb b/sentry-ruby/spec/sentry/transport/http_transport_spec.rb index bf342e356..27b7c85c6 100644 --- a/sentry-ruby/spec/sentry/transport/http_transport_spec.rb +++ b/sentry-ruby/spec/sentry/transport/http_transport_spec.rb @@ -321,4 +321,33 @@ end end end + + describe "Spotlight integration" do + let(:fake_response) { build_fake_response("200") } + context "when spotlight is enabled" do + let(:spotlight_transport) { Sentry::Spotlight::Transport.new(configuration.spotlight) } + + it "calls @spotlight_transport.send_data(data)" do + configuration.spotlight.enabled = true + + stub_request(fake_response) + + subject.instance_variable_set(:@spotlight_transport, spotlight_transport) + expect( spotlight_transport ).to receive(:send_data).with(data) + subject.send_data(data) + end + end + + context "when spotlight integration is disabled" do + let(:spotlight_transport) { nil } + it "does not call @spotlight_transport.send_data(data)" do + configuration.spotlight.enabled = false + + stub_request(fake_response) + + expect( spotlight_transport ).not_to receive(:send_data).with(data) + subject.send_data(data) + end + end + end end