Skip to content

Commit e727167

Browse files
authored
feat(sessions): Implement automatic session tracking (#1715)
* SessionFlusher spawning new thread with 60s periodic flush * Hub start_session/end_session api, add session object to scope * Start implementing Session class WIP * Expose session_flusher on top level * Test commit * Test commit * Test commit * auto_session_tracking config (default true); with_session_tracking hub method * Wrap rack middleware in with_session_tracking * Simplify session class/remove mode switches since only doing aggregates for now * Fix missing require, end_session in ensure * Simplify session updates on exception * Simpler hub logic * Record aggregates * Envelope abstraction for sessions (WEBBACKEND-85) * Move session update in capture_event to hub due to scope dup * Need to make envelope outside background worker * Remove useless require * Specs for SessionFlusher * Rake middleware sessions specs * Remove rate-limit change because will do it separately * Some docs + cleanup * ref(envelopes): Add send_envelope and move rate limiting filtering to items level * Git can't merge * Add session category to rate limits * Wrong item_type.. * Add session to #is_rate_limited? spec * Move session update from capture_event to capture_exception * Code style * Add spec for only one thread * Remove aggregates_payload * Rewrite #flush test * Partial changelog * Wrong changelog.. * Add screenshot * Changelog fix
1 parent bad64df commit e727167

14 files changed

+467
-29
lines changed

CHANGELOG.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,26 @@
55
- Log Redis command arguments when sending PII is enabled [#1726](https://github.com/getsentry/sentry-ruby/pull/1726)
66
- Add request env to sampling context [#1749](https://github.com/getsentry/sentry-ruby/pull/1749)
77

8+
- Automatic session tracking [#1715](https://github.com/getsentry/sentry-ruby/pull/1715)
9+
10+
**Example**:
11+
12+
![image](https://user-images.githubusercontent.com/6536764/157057827-2893527e-7973-4901-a070-bd78a720574a.png)
13+
14+
15+
The SDK now supports [automatic session tracking / release health](https://docs.sentry.io/product/releases/health/) by default in Rack based applications.
16+
Aggregate statistics on successful / errored requests are collected and sent to the server every minute.
17+
To use this feature, make sure the SDK can detect your app's release. Or you have set it with:
18+
19+
```ruby
20+
Sentry.init do |config|
21+
config.release = 'release-foo-v1'
22+
end
23+
```
24+
25+
To disable this feature, set `config.auto_session_tracking` to `false`.
26+
27+
828
### Bug Fixes
929

1030
- Require set library [#1753](https://github.com/getsentry/sentry-ruby/pull/1753)

sentry-ruby/lib/sentry-ruby.rb

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
require "sentry/transaction"
1818
require "sentry/hub"
1919
require "sentry/background_worker"
20+
require "sentry/session_flusher"
2021

2122
[
2223
"sentry/rake",
@@ -61,6 +62,10 @@ def exception_locals_tp
6162
# @return [BackgroundWorker]
6263
attr_accessor :background_worker
6364

65+
# @!attribute [r] session_flusher
66+
# @return [SessionFlusher]
67+
attr_reader :session_flusher
68+
6469
##### Patch Registration #####
6570

6671
# @!visibility private
@@ -189,11 +194,18 @@ def init(&block)
189194
@main_hub = hub
190195
@background_worker = Sentry::BackgroundWorker.new(config)
191196

197+
@session_flusher = if config.auto_session_tracking
198+
Sentry::SessionFlusher.new(config, client)
199+
else
200+
nil
201+
end
202+
192203
if config.capture_exception_frame_locals
193204
exception_locals_tp.enable
194205
end
195206

196207
at_exit do
208+
@session_flusher&.kill
197209
@background_worker.shutdown
198210
end
199211
end
@@ -310,6 +322,26 @@ def with_scope(&block)
310322
get_current_hub.with_scope(&block)
311323
end
312324

325+
# Wrap a given block with session tracking.
326+
# Aggregate sessions in minutely buckets will be recorded
327+
# around this block and flushed every minute.
328+
#
329+
# @example
330+
# Sentry.with_session_tracking do
331+
# a = 1 + 1 # new session recorded with :exited status
332+
# end
333+
#
334+
# Sentry.with_session_tracking do
335+
# 1 / 0
336+
# rescue => e
337+
# Sentry.capture_exception(e) # new session recorded with :errored status
338+
# end
339+
# @return [void]
340+
def with_session_tracking(&block)
341+
return yield unless initialized?
342+
get_current_hub.with_session_tracking(&block)
343+
end
344+
313345
# Takes an exception and reports it to Sentry via the currently active hub.
314346
#
315347
# @yieldparam scope [Scope]

sentry-ruby/lib/sentry/configuration.rb

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -207,6 +207,10 @@ class Configuration
207207
# @return [Boolean]
208208
attr_accessor :send_client_reports
209209

210+
# Track sessions in request/response cycles automatically
211+
# @return [Boolean]
212+
attr_accessor :auto_session_tracking
213+
210214
# these are not config options
211215
# @!visibility private
212216
attr_reader :errors, :gem_specs
@@ -261,6 +265,7 @@ def initialize
261265
self.send_default_pii = false
262266
self.skip_rake_integration = false
263267
self.send_client_reports = true
268+
self.auto_session_tracking = true
264269
self.trusted_proxies = []
265270
self.dsn = ENV['SENTRY_DSN']
266271
self.server_name = server_name_from_env

sentry-ruby/lib/sentry/hub.rb

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
require "sentry/scope"
44
require "sentry/client"
5+
require "sentry/session"
56

67
module Sentry
78
class Hub
@@ -104,6 +105,8 @@ def capture_exception(exception, **options, &block)
104105

105106
return unless event
106107

108+
current_scope.session&.update_from_exception(event.exception)
109+
107110
capture_event(event, **options, &block).tap do
108111
# mark the exception as captured so we can use this information to avoid duplicated capturing
109112
exception.instance_variable_set(Sentry::CAPTURED_SIGNATURE, true)
@@ -143,7 +146,6 @@ def capture_event(event, **options, &block)
143146

144147
event = current_client.capture_event(event, scope, hint)
145148

146-
147149
if event && configuration.debug
148150
configuration.log_debug(event.to_json_compatible)
149151
end
@@ -175,6 +177,30 @@ def with_background_worker_disabled(&block)
175177
configuration.background_worker_threads = original_background_worker_threads
176178
end
177179

180+
def start_session
181+
return unless current_scope
182+
current_scope.set_session(Session.new)
183+
end
184+
185+
def end_session
186+
return unless current_scope
187+
session = current_scope.session
188+
current_scope.set_session(nil)
189+
190+
return unless session
191+
session.close
192+
Sentry.session_flusher.add_session(session)
193+
end
194+
195+
def with_session_tracking(&block)
196+
return yield unless configuration.auto_session_tracking
197+
198+
start_session
199+
yield
200+
ensure
201+
end_session
202+
end
203+
178204
private
179205

180206
def current_layer

sentry-ruby/lib/sentry/rack/capture_exceptions.rb

Lines changed: 25 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -14,30 +14,32 @@ def call(env)
1414
Sentry.clone_hub_to_current_thread
1515

1616
Sentry.with_scope do |scope|
17-
scope.clear_breadcrumbs
18-
scope.set_transaction_name(env["PATH_INFO"]) if env["PATH_INFO"]
19-
scope.set_rack_env(env)
20-
21-
transaction = start_transaction(env, scope)
22-
scope.set_span(transaction) if transaction
23-
24-
begin
25-
response = @app.call(env)
26-
rescue Sentry::Error
27-
finish_transaction(transaction, 500)
28-
raise # Don't capture Sentry errors
29-
rescue Exception => e
30-
capture_exception(e)
31-
finish_transaction(transaction, 500)
32-
raise
17+
Sentry.with_session_tracking do
18+
scope.clear_breadcrumbs
19+
scope.set_transaction_name(env["PATH_INFO"]) if env["PATH_INFO"]
20+
scope.set_rack_env(env)
21+
22+
transaction = start_transaction(env, scope)
23+
scope.set_span(transaction) if transaction
24+
25+
begin
26+
response = @app.call(env)
27+
rescue Sentry::Error
28+
finish_transaction(transaction, 500)
29+
raise # Don't capture Sentry errors
30+
rescue Exception => e
31+
capture_exception(e)
32+
finish_transaction(transaction, 500)
33+
raise
34+
end
35+
36+
exception = collect_exception(env)
37+
capture_exception(exception) if exception
38+
39+
finish_transaction(transaction, response[0])
40+
41+
response
3342
end
34-
35-
exception = collect_exception(env)
36-
capture_exception(exception) if exception
37-
38-
finish_transaction(transaction, response[0])
39-
40-
response
4143
end
4244
end
4345

sentry-ruby/lib/sentry/scope.rb

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ module Sentry
77
class Scope
88
include ArgumentCheckingHelper
99

10-
ATTRIBUTES = [:transaction_names, :contexts, :extra, :tags, :user, :level, :breadcrumbs, :fingerprint, :event_processors, :rack_env, :span]
10+
ATTRIBUTES = [:transaction_names, :contexts, :extra, :tags, :user, :level, :breadcrumbs, :fingerprint, :event_processors, :rack_env, :span, :session]
1111

1212
attr_reader(*ATTRIBUTES)
1313

@@ -76,6 +76,7 @@ def dup
7676
copy.transaction_names = transaction_names.deep_dup
7777
copy.fingerprint = fingerprint.deep_dup
7878
copy.span = span.deep_dup
79+
copy.session = session.deep_dup
7980
copy
8081
end
8182

@@ -198,6 +199,13 @@ def set_transaction_name(transaction_name)
198199
@transaction_names << transaction_name
199200
end
200201

202+
# Sets the currently active session on the scope.
203+
# @param session [Session, nil]
204+
# @return [void]
205+
def set_session(session)
206+
@session = session
207+
end
208+
201209
# Returns current transaction name.
202210
# The "transaction" here does not refer to `Transaction` objects.
203211
# @return [String, nil]
@@ -251,6 +259,7 @@ def set_default_value
251259
@event_processors = []
252260
@rack_env = {}
253261
@span = nil
262+
@session = nil
254263
set_new_breadcrumb_buffer
255264
end
256265

sentry-ruby/lib/sentry/session.rb

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
# frozen_string_literal: true
2+
3+
module Sentry
4+
class Session
5+
attr_reader :started, :status
6+
7+
# TODO-neel add :crashed after adding handled mechanism
8+
STATUSES = %i(ok errored exited)
9+
AGGREGATE_STATUSES = %i(errored exited)
10+
11+
def initialize
12+
@started = Sentry.utc_now
13+
@status = :ok
14+
end
15+
16+
# TODO-neel add :crashed after adding handled mechanism
17+
def update_from_exception(_exception = nil)
18+
@status = :errored
19+
end
20+
21+
def close
22+
@status = :exited if @status == :ok
23+
end
24+
25+
# truncate seconds from the timestamp since we only care about
26+
# minute level granularity for aggregation
27+
def aggregation_key
28+
Time.utc(started.year, started.month, started.day, started.hour, started.min)
29+
end
30+
31+
def deep_dup
32+
dup
33+
end
34+
end
35+
end
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
# frozen_string_literal: true
2+
3+
module Sentry
4+
class SessionFlusher
5+
include LoggingHelper
6+
7+
FLUSH_INTERVAL = 60
8+
9+
def initialize(configuration, client)
10+
@thread = nil
11+
@client = client
12+
@pending_aggregates = {}
13+
@release = configuration.release
14+
@environment = configuration.environment
15+
@logger = configuration.logger
16+
17+
log_debug("[Sessions] Sessions won't be captured without a valid release") unless @release
18+
end
19+
20+
def flush
21+
return if @pending_aggregates.empty?
22+
envelope = pending_envelope
23+
24+
Sentry.background_worker.perform do
25+
@client.transport.send_envelope(envelope)
26+
end
27+
28+
@pending_aggregates = {}
29+
end
30+
31+
def add_session(session)
32+
return unless @release
33+
34+
ensure_thread
35+
36+
return unless Session::AGGREGATE_STATUSES.include?(session.status)
37+
@pending_aggregates[session.aggregation_key] ||= init_aggregates(session.aggregation_key)
38+
@pending_aggregates[session.aggregation_key][session.status] += 1
39+
end
40+
41+
def kill
42+
@thread&.kill
43+
end
44+
45+
private
46+
47+
def init_aggregates(aggregation_key)
48+
aggregates = { started: aggregation_key.iso8601 }
49+
Session::AGGREGATE_STATUSES.each { |k| aggregates[k] = 0 }
50+
aggregates
51+
end
52+
53+
def pending_envelope
54+
envelope = Envelope.new
55+
56+
header = { type: 'sessions' }
57+
payload = { attrs: attrs, aggregates: @pending_aggregates.values }
58+
59+
envelope.add_item(header, payload)
60+
envelope
61+
end
62+
63+
def attrs
64+
{ release: @release, environment: @environment }
65+
end
66+
67+
def ensure_thread
68+
return if @thread&.alive?
69+
70+
@thread = Thread.new do
71+
loop do
72+
sleep(FLUSH_INTERVAL)
73+
flush
74+
end
75+
end
76+
end
77+
78+
end
79+
end

sentry-ruby/lib/sentry/transport.rb

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,8 @@ def is_rate_limited?(item_type)
6767
case item_type
6868
when "transaction"
6969
@rate_limits["transaction"]
70+
when "sessions"
71+
@rate_limits["session"]
7072
else
7173
@rate_limits["error"]
7274
end
@@ -125,7 +127,6 @@ def envelope_from_event(event)
125127
client_report_headers, client_report_payload = fetch_pending_client_report
126128
envelope.add_item(client_report_headers, client_report_payload) if client_report_headers
127129

128-
129130
envelope
130131
end
131132

0 commit comments

Comments
 (0)