diff --git a/CHANGELOG.md b/CHANGELOG.md index 2ff392831..85ff53d84 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ### Features +- You can now use [Spotlight](https://spotlightjs.com) with your apps that use sentry-ruby! [#2175](https://github.com/getsentry/sentry-ruby/pulls/2175) - Improve default slug generation for `sidekiq-scheduler` [#2184](https://github.com/getsentry/sentry-ruby/pull/2184) ### Bug Fixes diff --git a/sentry-ruby/lib/sentry/client.rb b/sentry-ruby/lib/sentry/client.rb index d9e0f5b07..d2e886395 100644 --- a/sentry-ruby/lib/sentry/client.rb +++ b/sentry-ruby/lib/sentry/client.rb @@ -10,6 +10,10 @@ class Client # @return [Transport] attr_reader :transport + # The Transport object that'll send events for the client. + # @return [SpotlightTransport, nil] + attr_reader :spotlight_transport + # @!macro configuration attr_reader :configuration @@ -32,6 +36,8 @@ def initialize(configuration) DummyTransport.new(configuration) end end + + @spotlight_transport = SpotlightTransport.new(configuration) if configuration.spotlight end # Applies the given scope's data to the event and sends it to Sentry. @@ -167,6 +173,7 @@ def send_event(event, hint = nil) end transport.send_event(event) + spotlight_transport&.send_event(event) event rescue => e diff --git a/sentry-ruby/lib/sentry/configuration.rb b/sentry-ruby/lib/sentry/configuration.rb index 1f89f31a3..606147aae 100644 --- a/sentry-ruby/lib/sentry/configuration.rb +++ b/sentry-ruby/lib/sentry/configuration.rb @@ -142,6 +142,14 @@ class Configuration # @return [Boolean] attr_accessor :include_local_variables + # Whether to capture events and traces into Spotlight. Default is false. + # If you set this to true, Sentry will send events and traces to the local + # Sidecar proxy at http://localhost:8969/stream. + # If you want to use a different Sidecar proxy address, set this to String + # with the proxy URL. + # @return [Boolean, String] + attr_accessor :spotlight + # @deprecated Use {#include_local_variables} instead. alias_method :capture_exception_frame_locals, :include_local_variables @@ -344,6 +352,7 @@ def initialize self.auto_session_tracking = true self.trusted_proxies = [] self.dsn = ENV['SENTRY_DSN'] + self.spotlight = false self.server_name = server_name_from_env self.instrumenter = :sentry self.trace_propagation_targets = [PROPAGATION_TARGETS_MATCH_ALL] @@ -451,7 +460,7 @@ def profiles_sample_rate=(profiles_sample_rate) def sending_allowed? @errors = [] - valid? && capture_in_environment? + spotlight || (valid? && capture_in_environment?) end def sample_allowed? diff --git a/sentry-ruby/lib/sentry/transport.rb b/sentry-ruby/lib/sentry/transport.rb index 2b786af78..393e36de6 100644 --- a/sentry-ruby/lib/sentry/transport.rb +++ b/sentry-ruby/lib/sentry/transport.rb @@ -119,18 +119,6 @@ def is_rate_limited?(item_type) !!delay && delay > Time.now end - def generate_auth_header - now = Sentry.utc_now.to_i - fields = { - 'sentry_version' => PROTOCOL_VERSION, - 'sentry_client' => USER_AGENT, - 'sentry_timestamp' => now, - 'sentry_key' => @dsn.public_key - } - fields['sentry_secret'] = @dsn.secret_key if @dsn.secret_key - 'Sentry ' + fields.map { |key, value| "#{key}=#{value}" }.join(', ') - end - def envelope_from_event(event) # Convert to hash event_payload = event.to_hash @@ -220,3 +208,4 @@ def reject_rate_limited_items(envelope) require "sentry/transport/dummy_transport" require "sentry/transport/http_transport" +require "sentry/transport/spotlight_transport" diff --git a/sentry-ruby/lib/sentry/transport/http_transport.rb b/sentry-ruby/lib/sentry/transport/http_transport.rb index 4b1fa1771..270f05d4f 100644 --- a/sentry-ruby/lib/sentry/transport/http_transport.rb +++ b/sentry-ruby/lib/sentry/transport/http_transport.rb @@ -26,9 +26,7 @@ class HTTPTransport < Transport def initialize(*args) super - @endpoint = @dsn.envelope_endpoint - - log_debug("Sentry HTTP Transport will connect to #{@dsn.server}") + log_debug("Sentry HTTP Transport will connect to #{@dsn.server}") if @dsn end def send_data(data) @@ -42,12 +40,14 @@ def send_data(data) headers = { 'Content-Type' => CONTENT_TYPE, 'Content-Encoding' => encoding, - 'X-Sentry-Auth' => generate_auth_header, 'User-Agent' => USER_AGENT } + auth_header = generate_auth_header + headers['X-Sentry-Auth'] = auth_header if auth_header + response = conn.start do |http| - request = ::Net::HTTP::Post.new(@endpoint, headers) + request = ::Net::HTTP::Post.new(endpoint, headers) request.body = data http.request(request) end @@ -69,9 +69,53 @@ def send_data(data) raise Sentry::ExternalError, error_info end rescue SocketError, *HTTP_ERRORS => e + on_error if respond_to?(:on_error) raise Sentry::ExternalError.new(e&.message) end + def endpoint + @dsn.envelope_endpoint + end + + def generate_auth_header + return nil unless @dsn + + now = Sentry.utc_now.to_i + fields = { + 'sentry_version' => PROTOCOL_VERSION, + 'sentry_client' => USER_AGENT, + 'sentry_timestamp' => now, + 'sentry_key' => @dsn.public_key + } + fields['sentry_secret'] = @dsn.secret_key if @dsn.secret_key + 'Sentry ' + fields.map { |key, value| "#{key}=#{value}" }.join(', ') + end + + def conn + server = URI(@dsn.server) + + # connection respects proxy setting from @transport_configuration, or environment variables (HTTP_PROXY, HTTPS_PROXY, NO_PROXY) + # Net::HTTP will automatically read the env vars. + # See https://ruby-doc.org/3.2.2/stdlibs/net/Net/HTTP.html#class-Net::HTTP-label-Proxies + connection = + if proxy = normalize_proxy(@transport_configuration.proxy) + ::Net::HTTP.new(server.hostname, server.port, proxy[:uri].hostname, proxy[:uri].port, proxy[:user], proxy[:password]) + else + ::Net::HTTP.new(server.hostname, server.port) + end + + connection.use_ssl = server.scheme == "https" + connection.read_timeout = @transport_configuration.timeout + connection.write_timeout = @transport_configuration.timeout if connection.respond_to?(:write_timeout) + connection.open_timeout = @transport_configuration.open_timeout + + ssl_configuration.each do |key, value| + connection.send("#{key}=", value) + end + + connection + end + private def has_rate_limited_header?(headers) @@ -136,31 +180,6 @@ def should_compress?(data) @transport_configuration.encoding == GZIP_ENCODING && data.bytesize >= GZIP_THRESHOLD end - def conn - server = URI(@dsn.server) - - # connection respects proxy setting from @transport_configuration, or environment variables (HTTP_PROXY, HTTPS_PROXY, NO_PROXY) - # Net::HTTP will automatically read the env vars. - # See https://ruby-doc.org/3.2.2/stdlibs/net/Net/HTTP.html#class-Net::HTTP-label-Proxies - connection = - if proxy = normalize_proxy(@transport_configuration.proxy) - ::Net::HTTP.new(server.hostname, server.port, proxy[:uri].hostname, proxy[:uri].port, proxy[:user], proxy[:password]) - else - ::Net::HTTP.new(server.hostname, server.port) - end - - connection.use_ssl = server.scheme == "https" - connection.read_timeout = @transport_configuration.timeout - connection.write_timeout = @transport_configuration.timeout if connection.respond_to?(:write_timeout) - connection.open_timeout = @transport_configuration.open_timeout - - ssl_configuration.each do |key, value| - connection.send("#{key}=", value) - end - - connection - end - # @param proxy [String, URI, Hash] Proxy config value passed into `config.transport`. # Accepts either a URI formatted string, URI, or a hash with the `uri`, `user`, and `password` keys. # @return [Hash] Normalized proxy config that will be passed into `Net::HTTP` diff --git a/sentry-ruby/lib/sentry/transport/spotlight_transport.rb b/sentry-ruby/lib/sentry/transport/spotlight_transport.rb new file mode 100644 index 000000000..804f1d23e --- /dev/null +++ b/sentry-ruby/lib/sentry/transport/spotlight_transport.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +require "net/http" +require "zlib" + +module Sentry + # Designed to just report events to Spotlight in development. + class SpotlightTransport < HTTPTransport + DEFAULT_SIDECAR_URL = "http://localhost:8969/stream" + MAX_FAILED_REQUESTS = 3 + + def initialize(configuration) + super + @sidecar_url = configuration.spotlight.is_a?(String) ? configuration.spotlight : DEFAULT_SIDECAR_URL + @failed = 0 + @logged = false + + log_debug("[Spotlight] initialized for url #{@sidecar_url}") + end + + def endpoint + "/stream" + end + + def send_data(data) + if @failed >= MAX_FAILED_REQUESTS + unless @logged + log_debug("[Spotlight] disabling because of too many request failures") + @logged = true + end + + return + end + + super + end + + def on_error + @failed += 1 + end + + # Similar to HTTPTransport connection, but does not support Proxy and SSL + def conn + sidecar = URI(@sidecar_url) + connection = ::Net::HTTP.new(sidecar.hostname, sidecar.port, nil) + connection.use_ssl = false + connection + end + end +end diff --git a/sentry-ruby/spec/sentry/client_spec.rb b/sentry-ruby/spec/sentry/client_spec.rb index a3517dc1d..91eeeceee 100644 --- a/sentry-ruby/spec/sentry/client_spec.rb +++ b/sentry-ruby/spec/sentry/client_spec.rb @@ -67,6 +67,34 @@ def sentry_context end end + describe "#spotlight_transport" do + it "nil by default" do + expect(subject.spotlight_transport).to eq(nil) + end + + it "nil when false" do + configuration.spotlight = false + expect(subject.spotlight_transport).to eq(nil) + end + + it "has a transport when true" do + configuration.spotlight = true + expect(described_class.new(configuration).spotlight_transport).to be_a(Sentry::SpotlightTransport) + end + end + + describe "#send_event" do + context "with spotlight enabled" do + before { configuration.spotlight = true } + + it "calls spotlight transport" do + event = subject.event_from_message('test') + expect(subject.spotlight_transport).to receive(:send_event).with(event) + subject.send_event(event) + end + end + end + describe '#event_from_message' do let(:message) { 'This is a message' } diff --git a/sentry-ruby/spec/sentry/configuration_spec.rb b/sentry-ruby/spec/sentry/configuration_spec.rb index 5e4beb7ee..367c1450e 100644 --- a/sentry-ruby/spec/sentry/configuration_spec.rb +++ b/sentry-ruby/spec/sentry/configuration_spec.rb @@ -256,6 +256,19 @@ end end + describe "#spotlight" do + it "false by default" do + expect(subject.spotlight).to eq(false) + end + end + + describe "#sending_allowed?" do + it "true when spotlight" do + subject.spotlight = true + expect(subject.sending_allowed?).to eq(true) + 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..3c7b1b051 100644 --- a/sentry-ruby/spec/sentry/transport/http_transport_spec.rb +++ b/sentry-ruby/spec/sentry/transport/http_transport_spec.rb @@ -12,6 +12,7 @@ end let(:client) { Sentry::Client.new(configuration) } let(:event) { client.event_from_message("foobarbaz") } + let(:fake_time) { Time.now } let(:data) do subject.serialize_envelope(subject.envelope_from_event(event.to_hash)).first end @@ -132,7 +133,7 @@ it "accepts a proxy from ENV[HTTP_PROXY]" do begin ENV["http_proxy"] = "https://stan:foobar@example.com:8080" - + stub_request(fake_response) do |_, http_obj| expect(http_obj.proxy_address).to eq("example.com") expect(http_obj.proxy_port).to eq(8080) @@ -142,7 +143,7 @@ expect(http_obj.proxy_pass).to eq("foobar") end end - + subject.send_data(data) ensure ENV["http_proxy"] = nil @@ -277,7 +278,7 @@ allow(::Net::HTTP).to receive(:new).and_raise(SocketError.new("socket error")) expect do subject.send_data(data) - end.to raise_error(Sentry::ExternalError) + end.to raise_error(Sentry::ExternalError) end it "reports other errors to Sentry if they are not recognized" do @@ -321,4 +322,36 @@ end end end + + describe "#generate_auth_header" do + it "generates an auth header" do + expect(subject.send(:generate_auth_header)).to eq( + "Sentry sentry_version=7, sentry_client=sentry-ruby/#{Sentry::VERSION}, sentry_timestamp=#{fake_time.to_i}, " \ + "sentry_key=12345, sentry_secret=67890" + ) + end + + it "generates an auth header without a secret (Sentry 9)" do + configuration.server = "https://66260460f09b5940498e24bb7ce093a0@sentry.io/42" + + expect(subject.send(:generate_auth_header)).to eq( + "Sentry sentry_version=7, sentry_client=sentry-ruby/#{Sentry::VERSION}, sentry_timestamp=#{fake_time.to_i}, " \ + "sentry_key=66260460f09b5940498e24bb7ce093a0" + ) + end + end + + describe "#endpoint" do + it "returns correct endpoint" do + expect(subject.endpoint).to eq("/sentry/api/42/envelope/") + end + end + + describe "#conn" do + it "returns a connection" do + expect(subject.conn).to be_a(Net::HTTP) + expect(subject.conn.address).to eq("sentry.localdomain") + expect(subject.conn.use_ssl?).to eq(false) + end + end end diff --git a/sentry-ruby/spec/sentry/transport/spotlight_transport_spec.rb b/sentry-ruby/spec/sentry/transport/spotlight_transport_spec.rb new file mode 100644 index 000000000..04de57af6 --- /dev/null +++ b/sentry-ruby/spec/sentry/transport/spotlight_transport_spec.rb @@ -0,0 +1,81 @@ +require 'spec_helper' + +RSpec.describe Sentry::SpotlightTransport do + let(:configuration) do + Sentry::Configuration.new.tap do |config| + config.spotlight = true + config.logger = Logger.new(nil) + end + end + + let(:custom_configuration) do + Sentry::Configuration.new.tap do |config| + config.spotlight = 'http://foobar@test.com' + config.logger = Logger.new(nil) + end + end + + let(:client) { Sentry::Client.new(configuration) } + let(:event) { client.event_from_message("foobarbaz") } + let(:data) do + subject.serialize_envelope(subject.envelope_from_event(event.to_hash)).first + end + + subject { described_class.new(configuration) } + + it 'logs a debug message during initialization' do + string_io = StringIO.new + configuration.logger = Logger.new(string_io) + + subject + + expect(string_io.string).to include('sentry: [Spotlight] initialized for url http://localhost:8969/stream') + end + + describe '#endpoint' do + it 'returs correct endpoint' do + expect(subject.endpoint).to eq('/stream') + end + end + + describe '#conn' do + it 'returns connection with default host' do + expect(subject.conn).to be_a(Net::HTTP) + expect(subject.conn.address).to eq('localhost') + expect(subject.conn.port).to eq(8969) + expect(subject.conn.use_ssl?).to eq(false) + end + + it 'returns connection with overriden host' do + subject = described_class.new(custom_configuration) + expect(subject.conn).to be_a(Net::HTTP) + expect(subject.conn.address).to eq('test.com') + expect(subject.conn.port).to eq(80) + expect(subject.conn.use_ssl?).to eq(false) + end + end + + describe '#send_data' do + it 'fails a maximum of three times and logs disable once' do + string_io = StringIO.new + configuration.logger = Logger.new(string_io) + configuration.logger.level = :debug + + allow(::Net::HTTP).to receive(:new).and_raise(Errno::ECONNREFUSED) + + 3.times do + expect do + subject.send_data(data) + end.to raise_error(Sentry::ExternalError) + end + + 3.times do + expect do + subject.send_data(data) + end.not_to raise_error + end + + expect(string_io.string.scan('sentry: [Spotlight] disabling because of too many request failures').size).to eq(1) + end + end +end diff --git a/sentry-ruby/spec/sentry/transport_spec.rb b/sentry-ruby/spec/sentry/transport_spec.rb index 7a44cbc89..fbad63039 100644 --- a/sentry-ruby/spec/sentry/transport_spec.rb +++ b/sentry-ruby/spec/sentry/transport_spec.rb @@ -9,7 +9,6 @@ config.logger = logger end end - let(:fake_time) { Time.now } let(:client) { Sentry::Client.new(configuration) } let(:hub) do @@ -475,22 +474,4 @@ end end end - - describe "#generate_auth_header" do - it "generates an auth header" do - expect(subject.send(:generate_auth_header)).to eq( - "Sentry sentry_version=7, sentry_client=sentry-ruby/#{Sentry::VERSION}, sentry_timestamp=#{fake_time.to_i}, " \ - "sentry_key=12345, sentry_secret=67890" - ) - end - - it "generates an auth header without a secret (Sentry 9)" do - configuration.server = "https://66260460f09b5940498e24bb7ce093a0@sentry.io/42" - - expect(subject.send(:generate_auth_header)).to eq( - "Sentry sentry_version=7, sentry_client=sentry-ruby/#{Sentry::VERSION}, sentry_timestamp=#{fake_time.to_i}, " \ - "sentry_key=66260460f09b5940498e24bb7ce093a0" - ) - end - end end diff --git a/sentry-ruby/spec/sentry_spec.rb b/sentry-ruby/spec/sentry_spec.rb index b2fd37164..0396734ad 100644 --- a/sentry-ruby/spec/sentry_spec.rb +++ b/sentry-ruby/spec/sentry_spec.rb @@ -1,6 +1,9 @@ require "spec_helper" +require 'contexts/with_request_mock' RSpec.describe Sentry do + include_context "with request mock" + before do perform_basic_setup end @@ -145,6 +148,21 @@ event = last_sentry_event expect(event.tags[:hint][:foo]).to eq("bar") end + + context "with spotlight" do + before { perform_basic_setup { |c| c.spotlight = true } } + + it "sends the event to spotlight too" do + stub_request(build_fake_response("200")) do |request, http_obj| + expect(request["Content-Type"]).to eq("application/x-sentry-envelope") + expect(request["Content-Encoding"]).to eq("gzip") + expect(http_obj.address).to eq("localhost") + expect(http_obj.port).to eq(8969) + end + + described_class.send_event(event) + end + end end describe ".capture_event" do