Skip to content

Commit a4063d1

Browse files
authored
Spotlight Sidecar support in Sentry-Ruby (#2175)
1 parent 9a22f26 commit a4063d1

File tree

12 files changed

+294
-65
lines changed

12 files changed

+294
-65
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
### Features
44

5+
- You can now use [Spotlight](https://spotlightjs.com) with your apps that use sentry-ruby! [#2175](https://github.com/getsentry/sentry-ruby/pulls/2175)
56
- Improve default slug generation for `sidekiq-scheduler` [#2184](https://github.com/getsentry/sentry-ruby/pull/2184)
67

78
### Bug Fixes

sentry-ruby/lib/sentry/client.rb

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,10 @@ class Client
1010
# @return [Transport]
1111
attr_reader :transport
1212

13+
# The Transport object that'll send events for the client.
14+
# @return [SpotlightTransport, nil]
15+
attr_reader :spotlight_transport
16+
1317
# @!macro configuration
1418
attr_reader :configuration
1519

@@ -32,6 +36,8 @@ def initialize(configuration)
3236
DummyTransport.new(configuration)
3337
end
3438
end
39+
40+
@spotlight_transport = SpotlightTransport.new(configuration) if configuration.spotlight
3541
end
3642

3743
# Applies the given scope's data to the event and sends it to Sentry.
@@ -167,6 +173,7 @@ def send_event(event, hint = nil)
167173
end
168174

169175
transport.send_event(event)
176+
spotlight_transport&.send_event(event)
170177

171178
event
172179
rescue => e

sentry-ruby/lib/sentry/configuration.rb

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,14 @@ class Configuration
142142
# @return [Boolean]
143143
attr_accessor :include_local_variables
144144

145+
# Whether to capture events and traces into Spotlight. Default is false.
146+
# If you set this to true, Sentry will send events and traces to the local
147+
# Sidecar proxy at http://localhost:8969/stream.
148+
# If you want to use a different Sidecar proxy address, set this to String
149+
# with the proxy URL.
150+
# @return [Boolean, String]
151+
attr_accessor :spotlight
152+
145153
# @deprecated Use {#include_local_variables} instead.
146154
alias_method :capture_exception_frame_locals, :include_local_variables
147155

@@ -344,6 +352,7 @@ def initialize
344352
self.auto_session_tracking = true
345353
self.trusted_proxies = []
346354
self.dsn = ENV['SENTRY_DSN']
355+
self.spotlight = false
347356
self.server_name = server_name_from_env
348357
self.instrumenter = :sentry
349358
self.trace_propagation_targets = [PROPAGATION_TARGETS_MATCH_ALL]
@@ -451,7 +460,7 @@ def profiles_sample_rate=(profiles_sample_rate)
451460
def sending_allowed?
452461
@errors = []
453462

454-
valid? && capture_in_environment?
463+
spotlight || (valid? && capture_in_environment?)
455464
end
456465

457466
def sample_allowed?

sentry-ruby/lib/sentry/transport.rb

Lines changed: 1 addition & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -119,18 +119,6 @@ def is_rate_limited?(item_type)
119119
!!delay && delay > Time.now
120120
end
121121

122-
def generate_auth_header
123-
now = Sentry.utc_now.to_i
124-
fields = {
125-
'sentry_version' => PROTOCOL_VERSION,
126-
'sentry_client' => USER_AGENT,
127-
'sentry_timestamp' => now,
128-
'sentry_key' => @dsn.public_key
129-
}
130-
fields['sentry_secret'] = @dsn.secret_key if @dsn.secret_key
131-
'Sentry ' + fields.map { |key, value| "#{key}=#{value}" }.join(', ')
132-
end
133-
134122
def envelope_from_event(event)
135123
# Convert to hash
136124
event_payload = event.to_hash
@@ -220,3 +208,4 @@ def reject_rate_limited_items(envelope)
220208

221209
require "sentry/transport/dummy_transport"
222210
require "sentry/transport/http_transport"
211+
require "sentry/transport/spotlight_transport"

sentry-ruby/lib/sentry/transport/http_transport.rb

Lines changed: 49 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -26,9 +26,7 @@ class HTTPTransport < Transport
2626

2727
def initialize(*args)
2828
super
29-
@endpoint = @dsn.envelope_endpoint
30-
31-
log_debug("Sentry HTTP Transport will connect to #{@dsn.server}")
29+
log_debug("Sentry HTTP Transport will connect to #{@dsn.server}") if @dsn
3230
end
3331

3432
def send_data(data)
@@ -42,12 +40,14 @@ def send_data(data)
4240
headers = {
4341
'Content-Type' => CONTENT_TYPE,
4442
'Content-Encoding' => encoding,
45-
'X-Sentry-Auth' => generate_auth_header,
4643
'User-Agent' => USER_AGENT
4744
}
4845

46+
auth_header = generate_auth_header
47+
headers['X-Sentry-Auth'] = auth_header if auth_header
48+
4949
response = conn.start do |http|
50-
request = ::Net::HTTP::Post.new(@endpoint, headers)
50+
request = ::Net::HTTP::Post.new(endpoint, headers)
5151
request.body = data
5252
http.request(request)
5353
end
@@ -69,9 +69,53 @@ def send_data(data)
6969
raise Sentry::ExternalError, error_info
7070
end
7171
rescue SocketError, *HTTP_ERRORS => e
72+
on_error if respond_to?(:on_error)
7273
raise Sentry::ExternalError.new(e&.message)
7374
end
7475

76+
def endpoint
77+
@dsn.envelope_endpoint
78+
end
79+
80+
def generate_auth_header
81+
return nil unless @dsn
82+
83+
now = Sentry.utc_now.to_i
84+
fields = {
85+
'sentry_version' => PROTOCOL_VERSION,
86+
'sentry_client' => USER_AGENT,
87+
'sentry_timestamp' => now,
88+
'sentry_key' => @dsn.public_key
89+
}
90+
fields['sentry_secret'] = @dsn.secret_key if @dsn.secret_key
91+
'Sentry ' + fields.map { |key, value| "#{key}=#{value}" }.join(', ')
92+
end
93+
94+
def conn
95+
server = URI(@dsn.server)
96+
97+
# connection respects proxy setting from @transport_configuration, or environment variables (HTTP_PROXY, HTTPS_PROXY, NO_PROXY)
98+
# Net::HTTP will automatically read the env vars.
99+
# See https://ruby-doc.org/3.2.2/stdlibs/net/Net/HTTP.html#class-Net::HTTP-label-Proxies
100+
connection =
101+
if proxy = normalize_proxy(@transport_configuration.proxy)
102+
::Net::HTTP.new(server.hostname, server.port, proxy[:uri].hostname, proxy[:uri].port, proxy[:user], proxy[:password])
103+
else
104+
::Net::HTTP.new(server.hostname, server.port)
105+
end
106+
107+
connection.use_ssl = server.scheme == "https"
108+
connection.read_timeout = @transport_configuration.timeout
109+
connection.write_timeout = @transport_configuration.timeout if connection.respond_to?(:write_timeout)
110+
connection.open_timeout = @transport_configuration.open_timeout
111+
112+
ssl_configuration.each do |key, value|
113+
connection.send("#{key}=", value)
114+
end
115+
116+
connection
117+
end
118+
75119
private
76120

77121
def has_rate_limited_header?(headers)
@@ -136,31 +180,6 @@ def should_compress?(data)
136180
@transport_configuration.encoding == GZIP_ENCODING && data.bytesize >= GZIP_THRESHOLD
137181
end
138182

139-
def conn
140-
server = URI(@dsn.server)
141-
142-
# connection respects proxy setting from @transport_configuration, or environment variables (HTTP_PROXY, HTTPS_PROXY, NO_PROXY)
143-
# Net::HTTP will automatically read the env vars.
144-
# See https://ruby-doc.org/3.2.2/stdlibs/net/Net/HTTP.html#class-Net::HTTP-label-Proxies
145-
connection =
146-
if proxy = normalize_proxy(@transport_configuration.proxy)
147-
::Net::HTTP.new(server.hostname, server.port, proxy[:uri].hostname, proxy[:uri].port, proxy[:user], proxy[:password])
148-
else
149-
::Net::HTTP.new(server.hostname, server.port)
150-
end
151-
152-
connection.use_ssl = server.scheme == "https"
153-
connection.read_timeout = @transport_configuration.timeout
154-
connection.write_timeout = @transport_configuration.timeout if connection.respond_to?(:write_timeout)
155-
connection.open_timeout = @transport_configuration.open_timeout
156-
157-
ssl_configuration.each do |key, value|
158-
connection.send("#{key}=", value)
159-
end
160-
161-
connection
162-
end
163-
164183
# @param proxy [String, URI, Hash] Proxy config value passed into `config.transport`.
165184
# Accepts either a URI formatted string, URI, or a hash with the `uri`, `user`, and `password` keys.
166185
# @return [Hash] Normalized proxy config that will be passed into `Net::HTTP`
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
# frozen_string_literal: true
2+
3+
require "net/http"
4+
require "zlib"
5+
6+
module Sentry
7+
# Designed to just report events to Spotlight in development.
8+
class SpotlightTransport < HTTPTransport
9+
DEFAULT_SIDECAR_URL = "http://localhost:8969/stream"
10+
MAX_FAILED_REQUESTS = 3
11+
12+
def initialize(configuration)
13+
super
14+
@sidecar_url = configuration.spotlight.is_a?(String) ? configuration.spotlight : DEFAULT_SIDECAR_URL
15+
@failed = 0
16+
@logged = false
17+
18+
log_debug("[Spotlight] initialized for url #{@sidecar_url}")
19+
end
20+
21+
def endpoint
22+
"/stream"
23+
end
24+
25+
def send_data(data)
26+
if @failed >= MAX_FAILED_REQUESTS
27+
unless @logged
28+
log_debug("[Spotlight] disabling because of too many request failures")
29+
@logged = true
30+
end
31+
32+
return
33+
end
34+
35+
super
36+
end
37+
38+
def on_error
39+
@failed += 1
40+
end
41+
42+
# Similar to HTTPTransport connection, but does not support Proxy and SSL
43+
def conn
44+
sidecar = URI(@sidecar_url)
45+
connection = ::Net::HTTP.new(sidecar.hostname, sidecar.port, nil)
46+
connection.use_ssl = false
47+
connection
48+
end
49+
end
50+
end

sentry-ruby/spec/sentry/client_spec.rb

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,34 @@ def sentry_context
6767
end
6868
end
6969

70+
describe "#spotlight_transport" do
71+
it "nil by default" do
72+
expect(subject.spotlight_transport).to eq(nil)
73+
end
74+
75+
it "nil when false" do
76+
configuration.spotlight = false
77+
expect(subject.spotlight_transport).to eq(nil)
78+
end
79+
80+
it "has a transport when true" do
81+
configuration.spotlight = true
82+
expect(described_class.new(configuration).spotlight_transport).to be_a(Sentry::SpotlightTransport)
83+
end
84+
end
85+
86+
describe "#send_event" do
87+
context "with spotlight enabled" do
88+
before { configuration.spotlight = true }
89+
90+
it "calls spotlight transport" do
91+
event = subject.event_from_message('test')
92+
expect(subject.spotlight_transport).to receive(:send_event).with(event)
93+
subject.send_event(event)
94+
end
95+
end
96+
end
97+
7098
describe '#event_from_message' do
7199
let(:message) { 'This is a message' }
72100

sentry-ruby/spec/sentry/configuration_spec.rb

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -256,6 +256,19 @@
256256
end
257257
end
258258

259+
describe "#spotlight" do
260+
it "false by default" do
261+
expect(subject.spotlight).to eq(false)
262+
end
263+
end
264+
265+
describe "#sending_allowed?" do
266+
it "true when spotlight" do
267+
subject.spotlight = true
268+
expect(subject.sending_allowed?).to eq(true)
269+
end
270+
end
271+
259272
context 'configuring for async' do
260273
it 'should be configurable to send events async' do
261274
subject.async = ->(_e) { :ok }

sentry-ruby/spec/sentry/transport/http_transport_spec.rb

Lines changed: 36 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
end
1313
let(:client) { Sentry::Client.new(configuration) }
1414
let(:event) { client.event_from_message("foobarbaz") }
15+
let(:fake_time) { Time.now }
1516
let(:data) do
1617
subject.serialize_envelope(subject.envelope_from_event(event.to_hash)).first
1718
end
@@ -132,7 +133,7 @@
132133
it "accepts a proxy from ENV[HTTP_PROXY]" do
133134
begin
134135
ENV["http_proxy"] = "https://stan:[email protected]:8080"
135-
136+
136137
stub_request(fake_response) do |_, http_obj|
137138
expect(http_obj.proxy_address).to eq("example.com")
138139
expect(http_obj.proxy_port).to eq(8080)
@@ -142,7 +143,7 @@
142143
expect(http_obj.proxy_pass).to eq("foobar")
143144
end
144145
end
145-
146+
146147
subject.send_data(data)
147148
ensure
148149
ENV["http_proxy"] = nil
@@ -277,7 +278,7 @@
277278
allow(::Net::HTTP).to receive(:new).and_raise(SocketError.new("socket error"))
278279
expect do
279280
subject.send_data(data)
280-
end.to raise_error(Sentry::ExternalError)
281+
end.to raise_error(Sentry::ExternalError)
281282
end
282283

283284
it "reports other errors to Sentry if they are not recognized" do
@@ -321,4 +322,36 @@
321322
end
322323
end
323324
end
325+
326+
describe "#generate_auth_header" do
327+
it "generates an auth header" do
328+
expect(subject.send(:generate_auth_header)).to eq(
329+
"Sentry sentry_version=7, sentry_client=sentry-ruby/#{Sentry::VERSION}, sentry_timestamp=#{fake_time.to_i}, " \
330+
"sentry_key=12345, sentry_secret=67890"
331+
)
332+
end
333+
334+
it "generates an auth header without a secret (Sentry 9)" do
335+
configuration.server = "https://[email protected]/42"
336+
337+
expect(subject.send(:generate_auth_header)).to eq(
338+
"Sentry sentry_version=7, sentry_client=sentry-ruby/#{Sentry::VERSION}, sentry_timestamp=#{fake_time.to_i}, " \
339+
"sentry_key=66260460f09b5940498e24bb7ce093a0"
340+
)
341+
end
342+
end
343+
344+
describe "#endpoint" do
345+
it "returns correct endpoint" do
346+
expect(subject.endpoint).to eq("/sentry/api/42/envelope/")
347+
end
348+
end
349+
350+
describe "#conn" do
351+
it "returns a connection" do
352+
expect(subject.conn).to be_a(Net::HTTP)
353+
expect(subject.conn.address).to eq("sentry.localdomain")
354+
expect(subject.conn.use_ssl?).to eq(false)
355+
end
356+
end
324357
end

0 commit comments

Comments
 (0)