event_timeline 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: acfe8f6da4ca9044255ba5db0c60ea66e6aaedfebd3b9c47a36eb2ace19631f6
4
- data.tar.gz: 9659ba87a4dd38ac503ddb6ee5573fb6088789240472e4e9f4cbe2ff0e063983
3
+ metadata.gz: e6bb353fe95dc4e1add0e9b01de0f938d1e9feb7dcffddf4501d9c4fc6d74a3d
4
+ data.tar.gz: af1804a78347c235856b8a950bd843d412a5fe7724573a139653a5aef689460b
5
5
  SHA512:
6
- metadata.gz: 2d4e4efbc26ca24019666469a1d64d2f183f7c2264a0aecef97d61913fe018299f7fac4d00fa6e37d15f7fb39b064cd90d344bc02ba60f2e33460e309e4a3f42
7
- data.tar.gz: fab0b6ee70307348d2513ade4988e94bbe1abaca374bcac8ea5b6b64a4f14519ac8bfe226841e047f3cdbc3e5c8a066b834e818abea6d5d1ca6b1662de97c1c2
6
+ metadata.gz: 3f58e34396e514e0f4affb1f71198b14711081147618d631c82b33b2ab49b8b8e15e5efe9b5a60bcc63e5f012aad80cb6b59da7263af2ca4969f2dd924d2bebf
7
+ data.tar.gz: a21b2da8e8e4a60351238484b5927b02634c37f3f49b79ea053b6b64107528931cf94cf22c825b7c781640c5b31ac6a362bc528049624099c49032701e5197b8
data/README.md CHANGED
@@ -1,128 +1,95 @@
1
1
  # EventTimeline
2
2
 
3
- Ever wished you could replay what happened during a request? EventTimeline tracks method calls in your Rails app so you can see exactly how your code executed.
3
+ A Rails engine that records method calls during requests so you can see what actually happened.
4
4
 
5
- ## What it does
5
+ ## Why?
6
6
 
7
- EventTimeline records method calls and returns as they happen, letting you:
7
+ You get a bug report: "Order failed for user X". You check the logs, see a 500 error, but the stack trace doesn't tell you what state the app was in or what data was being processed.
8
8
 
9
- - See the exact sequence of method calls during a request
10
- - Inspect parameters passed to each method
11
- - View return values
12
- - Track execution flow across multiple services
13
- - Debug production issues by replaying what actually happened
9
+ EventTimeline records the method calls, parameters, and return values during the request. You visit `/event_timeline/sessions/<request_id>` and see exactly what happened.
14
10
 
15
- ## Installation
16
-
17
- Add to your Gemfile:
11
+ ## Install
18
12
 
19
13
  ```ruby
20
14
  gem 'event_timeline'
21
15
  ```
22
16
 
23
- Then:
24
-
25
17
  ```bash
26
18
  bundle install
27
19
  rails generate event_timeline:install
28
20
  rails db:migrate
29
21
  ```
30
22
 
31
- ## Basic Usage
23
+ ## Setup
32
24
 
33
- ### 1. Configure what to track
25
+ Tell it what to track:
34
26
 
35
27
  ```ruby
36
28
  # config/initializers/event_timeline.rb
37
29
  EventTimeline.configure do |config|
38
- # Track specific paths
39
30
  config.watch 'app/services'
40
- config.watch 'app/models/order.rb'
41
- config.watch 'lib/payment_processor'
31
+ config.watch 'app/models'
42
32
  end
43
33
  ```
44
34
 
45
- ### 2. View the timeline
46
-
47
- Visit `/event_timeline/sessions/:request_id` to see what happened during any request.
48
-
49
- Example: Your logs show request ID `abc-123-def` failed? Go to `/event_timeline/sessions/abc-123-def` and see every method that was called.
35
+ ## Usage
50
36
 
51
- ## Real-world Example
52
-
53
- Let's say a payment fails in production. Here's what you'd see:
37
+ Make a request, grab the request ID from logs, visit:
54
38
 
55
39
  ```
56
- OrdersController#create
57
- Order#initialize
58
- Order#validate_items
59
- Order#calculate_total
60
- Order#save
61
- PaymentService#charge
62
- PaymentGateway#create_charge
63
- <- Returns: {error: "Insufficient funds"}
64
- PaymentService#handle_failure
65
- Order#mark_as_failed
66
- CustomerMailer#payment_failed
40
+ /event_timeline/sessions/abc-123-def
67
41
  ```
68
42
 
69
- Now you know exactly where things went wrong and what data was involved.
43
+ You'll see the call timeline with params and return values. If the request crashed, you'll see the exception with its source location and backtrace.
70
44
 
71
- ## Configuration Options
45
+ ## Configuration
72
46
 
73
47
  ```ruby
74
48
  EventTimeline.configure do |config|
75
- # Filter sensitive data
76
- config.add_filtered_attributes :credit_card, :ssn, :api_key
49
+ # What to track
50
+ config.watch 'app/services'
51
+ config.watch 'lib/payments'
77
52
 
78
- # Custom PII filtering
79
- config.filter_pii do |key, value|
80
- return '<REDACTED>' if key.to_s =~ /bank_account/
81
- end
53
+ # Filter sensitive params (these are filtered by default: password, token, secret, etc.)
54
+ config.add_filtered_attributes :credit_card, :ssn
82
55
 
83
- # Customize how events are described
84
- config.narrator do |event|
85
- case event.name
86
- when /Stripe/
87
- "[PAYMENT] #{event.name}"
88
- else
89
- event.name
90
- end
56
+ # Custom filtering
57
+ config.filter_pii do |key, value, context|
58
+ key.to_s.include?('account_number') ? true : nil
91
59
  end
92
60
 
93
- # Data retention (defaults shown)
94
- config.max_events_per_correlation = 500 # Per request
95
- config.max_total_events = 10_000 # Total stored
96
- config.max_event_age = 1.month # Auto-delete after
61
+ # Retention
62
+ config.max_events_per_correlation = 500
63
+ config.max_total_events = 10_000
64
+ config.max_event_age = 1.month
65
+
66
+ # Truncation
67
+ config.max_string_length = 100
68
+ config.max_inspect_length = 200
97
69
  end
98
70
  ```
99
71
 
100
- ## Performance
72
+ ## Runtime control
101
73
 
102
- EventTimeline uses TracePoint which has minimal overhead. Data is automatically rotated to prevent unbounded growth:
103
-
104
- - Old events are deleted after 1 month
105
- - Per-request events are capped at 500
106
- - Total events are capped at 10,000
107
-
108
- ## Pro Tips
109
-
110
- 1. **Production Debugging**: When users report issues, ask for their request ID from the logs. EventTimeline will show you exactly what happened.
74
+ ```ruby
75
+ EventTimeline::CallTracker.uninstall! # stop tracking
76
+ EventTimeline::CallTracker.install! # start tracking
77
+ EventTimeline::CallTracker.installed? # check status
78
+ ```
111
79
 
112
- 2. **Development**: Watch your code execute in real-time. Great for understanding unfamiliar codebases.
80
+ ## Custom correlation IDs
113
81
 
114
- 3. **Testing**: Verify your code follows the expected execution path.
82
+ For background jobs or anything outside a request:
115
83
 
116
- 4. **Correlation IDs**: EventTimeline automatically groups events by request ID, but you can set custom correlation IDs:
117
- ```ruby
118
- EventTimeline::CurrentCorrelation.id = "import-job-#{job.id}"
119
- ```
84
+ ```ruby
85
+ EventTimeline::CurrentCorrelation.id = "import-job-#{job.id}"
86
+ ```
120
87
 
121
88
  ## Limitations
122
89
 
123
- - Only tracks Ruby method calls (not database queries or external HTTP calls)
124
- - TracePoint doesn't work with some metaprogramming techniques
125
- - Large payloads are truncated to keep storage reasonable
90
+ - Only tracks Ruby method calls (not SQL queries or HTTP calls)
91
+ - TracePoint has some overhead - probably don't enable in high-traffic production without sampling
92
+ - Large values get truncated
126
93
 
127
94
  ## License
128
95
 
data/Rakefile CHANGED
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'bundler/setup'
4
-
5
4
  require 'bundler/gem_tasks'
5
+
6
+ # Load all rake tasks
7
+ Dir.glob('tasks/**/*.rake').each { |task| load task }
@@ -1,40 +1,82 @@
1
1
  <div class="container">
2
2
  <h1>Timeline for <span class="correlation-id"><%= @correlation_id %></span></h1>
3
3
 
4
+ <% has_exception = @events.any? { |e| e.category == 'exception' } %>
5
+ <% if @events.any? %>
6
+ <div class="status-badge <%= has_exception ? 'failed' : 'success' %>">
7
+ <%= has_exception ? 'FAILED' : 'SUCCESS' %>
8
+ </div>
9
+ <% end %>
10
+
4
11
  <div class="timeline">
5
12
  <% @events.each do |event| %>
6
13
  <% is_return = event.category == "method_return" %>
7
- <div class="event <%= event.severity %> <%= 'return' if is_return %>">
8
- <div class="event-header">
9
- <span class="event-name">
10
- <% if is_return %>
11
- &lt;- <%= event.name.gsub('_return', '') %>
12
- <% else %>
13
- <%= narrate_event(event) %>
14
- <% end %>
15
- </span>
14
+ <% is_exception = event.category == "exception" %>
15
+
16
+ <% if is_exception %>
17
+ <div class="event exception">
18
+ <div class="exception-header">
19
+ <%= event.payload['exception_class'] %>
20
+ </div>
21
+
22
+ <div class="exception-message">
23
+ <%= event.payload['message'] %>
24
+ </div>
25
+
26
+ <% if event.payload['source_file'] %>
27
+ <div class="exception-location">
28
+ Raised in <strong><%= event.payload['source_method'] %></strong>
29
+ at <%= event.payload['source_file'] %>:<%= event.payload['source_line'] %>
30
+ </div>
31
+ <% end %>
32
+
16
33
  <span class="event-time"><%= event.occurred_at.strftime("%H:%M:%S.%L") %></span>
34
+
35
+ <% if event.payload['backtrace'].present? %>
36
+ <details>
37
+ <summary>Show backtrace</summary>
38
+ <div class="exception-backtrace">
39
+ <% event.payload['backtrace'].each do |line| %>
40
+ <div class="exception-backtrace-line"><%= line %></div>
41
+ <% end %>
42
+ </div>
43
+ </details>
44
+ <% end %>
17
45
  </div>
18
46
 
19
- <% if event.payload.present? %>
20
- <% if is_return %>
21
- <div style="font-size: 14px; color: #059862;">
22
- Returns: <%= event.payload['return_value'] %>
23
- </div>
24
- <% elsif event.payload['params'].present? && !event.payload['params'].empty? %>
25
- <div style="font-size: 14px; color: #0066cc;">
26
- Params: <%= event.payload['params'].inspect %>
27
- </div>
47
+ <% else %>
48
+ <div class="event <%= event.severity %> <%= 'return' if is_return %>">
49
+ <div class="event-header">
50
+ <span class="event-name">
51
+ <% if is_return %>
52
+ &lt;- <%= event.name.gsub('_return', '') %>
53
+ <% else %>
54
+ <%= narrate_event(event) %>
55
+ <% end %>
56
+ </span>
57
+ <span class="event-time"><%= event.occurred_at.strftime("%H:%M:%S.%L") %></span>
58
+ </div>
59
+
60
+ <% if event.payload.present? %>
61
+ <% if is_return %>
62
+ <div style="font-size: 14px; color: #059862;">
63
+ Returns: <%= event.payload['return_value'] %>
64
+ </div>
65
+ <% elsif event.payload['params'].present? && !event.payload['params'].empty? %>
66
+ <div style="font-size: 14px; color: #0066cc;">
67
+ Params: <%= event.payload['params'].inspect %>
68
+ </div>
69
+ <% end %>
70
+
71
+ <details>
72
+ <summary>Show full payload</summary>
73
+ <div class="event-payload">
74
+ <%= JSON.pretty_generate(event.payload) %>
75
+ </div>
76
+ </details>
28
77
  <% end %>
29
-
30
- <details>
31
- <summary>Show full payload</summary>
32
- <div class="event-payload">
33
- <%= JSON.pretty_generate(event.payload) %>
34
- </div>
35
- </details>
36
- <% end %>
37
- </div>
78
+ </div>
79
+ <% end %>
38
80
  <% end %>
39
81
 
40
82
  <% if @events.empty? %>
@@ -61,6 +61,65 @@
61
61
  background: #dc3545;
62
62
  border-color: #dc3545;
63
63
  }
64
+ .event.exception {
65
+ background: #fff5f5;
66
+ border: 2px solid #dc3545;
67
+ }
68
+ .event.exception::before {
69
+ background: #dc3545;
70
+ border-color: #dc3545;
71
+ width: 14px;
72
+ height: 14px;
73
+ left: -27px;
74
+ top: 18px;
75
+ }
76
+ .exception-header {
77
+ color: #dc3545;
78
+ font-weight: bold;
79
+ font-size: 16px;
80
+ }
81
+ .exception-message {
82
+ background: #f8d7da;
83
+ color: #721c24;
84
+ padding: 10px 15px;
85
+ border-radius: 4px;
86
+ margin: 10px 0;
87
+ font-family: monospace;
88
+ }
89
+ .exception-location {
90
+ font-size: 13px;
91
+ color: #666;
92
+ margin-bottom: 10px;
93
+ }
94
+ .exception-backtrace {
95
+ background: #282c34;
96
+ color: #abb2bf;
97
+ padding: 15px;
98
+ border-radius: 4px;
99
+ font-family: monospace;
100
+ font-size: 12px;
101
+ overflow-x: auto;
102
+ margin-top: 10px;
103
+ }
104
+ .exception-backtrace-line {
105
+ margin: 2px 0;
106
+ }
107
+ .status-badge {
108
+ display: inline-block;
109
+ padding: 4px 10px;
110
+ border-radius: 4px;
111
+ font-size: 12px;
112
+ font-weight: bold;
113
+ margin-bottom: 15px;
114
+ }
115
+ .status-badge.success {
116
+ background: #d4edda;
117
+ color: #155724;
118
+ }
119
+ .status-badge.failed {
120
+ background: #f8d7da;
121
+ color: #721c24;
122
+ }
64
123
  .event.warning::before {
65
124
  background: #ffc107;
66
125
  border-color: #ffc107;
@@ -2,201 +2,209 @@
2
2
 
3
3
  module EventTimeline
4
4
  class CallTracker
5
+ THREAD_KEY_METHOD_STACK = :event_timeline_method_stack
6
+ THREAD_KEY_EVENT_BUFFER = :event_timeline_event_buffer
7
+
5
8
  class << self
9
+ attr_reader :call_trace, :return_trace
10
+
6
11
  def install!
7
- @last_location = {}
8
- @method_stack = Hash.new { |h, k| h[k] = [] }
9
-
10
- call_trace = TracePoint.new(:call) do |tp|
11
- next unless EventTimeline.configuration&.watched?(tp.path)
12
-
13
- current_location = "#{tp.path}:#{tp.lineno}"
14
- correlation_id = CurrentCorrelation.id || determine_correlation_id
15
-
16
- if @last_location[correlation_id] != current_location
17
- @last_location[correlation_id] = current_location
18
-
19
- # Capture method arguments
20
- params = capture_params(tp)
21
-
22
- event_id = SecureRandom.uuid
23
- @method_stack[correlation_id].push({
24
- event_id: event_id,
25
- method: "#{tp.defined_class}##{tp.method_id}"
26
- })
27
-
28
- Session.create!(
29
- name: "#{tp.defined_class}##{tp.method_id}",
30
- severity: 'info',
31
- category: 'method_call',
32
- payload: {
33
- event_id: event_id,
34
- file: tp.path,
35
- line: tp.lineno,
36
- class: tp.defined_class.to_s,
37
- method: tp.method_id.to_s,
38
- params: params
39
- },
40
- correlation_id: correlation_id,
41
- occurred_at: Time.current
42
- )
43
-
44
- # Enforce rotation limits
45
- RotationService.enforce_correlation_limit(correlation_id)
46
- RotationService.cleanup_if_needed
47
- end
48
- rescue StandardError => e
49
- Rails.logger.error "EventTimeline call tracking failed: #{e.message}" if defined?(Rails.logger)
50
- end
12
+ return if installed?
51
13
 
52
- return_trace = TracePoint.new(:return) do |tp|
53
- next unless EventTimeline.configuration&.watched?(tp.path)
54
-
55
- correlation_id = CurrentCorrelation.id || determine_correlation_id
56
- method_info = @method_stack[correlation_id].find { |m| m[:method] == "#{tp.defined_class}##{tp.method_id}" }
57
-
58
- if method_info
59
- @method_stack[correlation_id].delete(method_info)
60
-
61
- Session.create!(
62
- name: "#{tp.defined_class}##{tp.method_id}_return",
63
- severity: 'info',
64
- category: 'method_return',
65
- payload: {
66
- event_id: method_info[:event_id],
67
- return_value: filter_sensitive_data(:return_value, tp.return_value, { context: :return_value }),
68
- class: tp.defined_class.to_s,
69
- method: tp.method_id.to_s
70
- },
71
- correlation_id: correlation_id,
72
- occurred_at: Time.current
73
- )
74
- end
75
- rescue StandardError => e
76
- Rails.logger.error "EventTimeline return tracking failed: #{e.message}" if defined?(Rails.logger)
77
- end
14
+ @call_trace = TracePoint.new(:call) { |tp| handle_call(tp) }
15
+ @return_trace = TracePoint.new(:return) { |tp| handle_return(tp) }
78
16
 
79
- call_trace.enable
80
- return_trace.enable
17
+ @call_trace.enable
18
+ @return_trace.enable
81
19
  end
82
20
 
83
- private
21
+ def uninstall!
22
+ @call_trace&.disable
23
+ @return_trace&.disable
24
+ @call_trace = nil
25
+ @return_trace = nil
26
+ end
84
27
 
85
- def capture_params(tp)
86
- method = tp.self.method(tp.method_id)
87
- params = {}
28
+ def installed?
29
+ @call_trace&.enabled? || @return_trace&.enabled?
30
+ end
88
31
 
89
- method.parameters.each_value do |name|
90
- if tp.binding.local_variable_defined?(name)
91
- value = tp.binding.local_variable_get(name)
92
- params[name] = filter_sensitive_data(name, value, { context: :parameter })
93
- end
94
- end
32
+ def flush_events(correlation_id)
33
+ buffer = thread_event_buffer
34
+ return if buffer.empty?
95
35
 
96
- params
36
+ Session.insert_all(buffer)
37
+
38
+ RotationService.enforce_correlation_limit(correlation_id)
39
+ RotationService.cleanup_if_needed
97
40
  rescue StandardError => e
98
- { error: "Failed to capture params: #{e.message}" }
41
+ Rails.logger.error "EventTimeline flush failed: #{e.message}" if defined?(Rails.logger)
99
42
  end
100
43
 
101
- def safe_inspect(value)
102
- case value
103
- when String
104
- value.length > 100 ? "#{value[0..100]}..." : value
105
- when Hash, Array
106
- value.inspect.length > 200 ? "#{value.class}[#{value.size} items]" : value.inspect
107
- when defined?(ActiveRecord::Base) && ActiveRecord::Base
108
- inspect_activerecord(value)
109
- when defined?(ActiveModel::Model) && ActiveModel::Model
110
- inspect_model(value)
111
- when Class
112
- value.name
113
- when Module
114
- value.name
115
- else
116
- simple_inspect(value)
117
- end
44
+ def cleanup_thread_state(correlation_id)
45
+ thread_method_stack.delete(correlation_id)
46
+ Thread.current[THREAD_KEY_EVENT_BUFFER] = []
47
+ end
48
+
49
+ def record_exception(exception, correlation_id)
50
+ backtrace = clean_backtrace(exception.backtrace || [])
51
+ source_location = extract_source_location(backtrace)
52
+
53
+ buffer_event(
54
+ name: "EXCEPTION: #{exception.class.name}",
55
+ severity: 'error',
56
+ category: 'exception',
57
+ payload: {
58
+ exception_class: exception.class.name,
59
+ message: exception.message,
60
+ backtrace: backtrace.first(10),
61
+ source_file: source_location[:file],
62
+ source_line: source_location[:line],
63
+ source_method: source_location[:method]
64
+ },
65
+ correlation_id: correlation_id,
66
+ occurred_at: Time.current
67
+ )
118
68
  rescue StandardError => e
119
- "<inspect failed: #{e.message}>"
69
+ Rails.logger.error "EventTimeline exception recording failed: #{e.message}" if defined?(Rails.logger)
70
+ end
71
+
72
+ private
73
+
74
+ def clean_backtrace(backtrace)
75
+ # Filter out gem internals, keep app code
76
+ backtrace.reject { |line| line.include?('/gems/') || line.include?('/ruby/') }
120
77
  end
121
78
 
122
- def inspect_activerecord(record)
123
- if record.persisted?
124
- id_attr = record.class.primary_key
125
- id_value = record.send(id_attr) if id_attr
126
- "#{record.class.name}(#{id_attr}: #{id_value})"
79
+ def extract_source_location(backtrace)
80
+ return { file: nil, line: nil, method: nil } if backtrace.empty?
81
+
82
+ # Parse first line: "/path/to/file.rb:123:in `method_name'"
83
+ if backtrace.first =~ /\A(.+):(\d+):in `(.+)'\z/
84
+ { file: ::Regexp.last_match(1), line: ::Regexp.last_match(2).to_i, method: ::Regexp.last_match(3) }
127
85
  else
128
- "#{record.class.name}(new_record)"
86
+ { file: backtrace.first, line: nil, method: nil }
129
87
  end
88
+ end
89
+
90
+ def handle_call(tp)
91
+ return unless EventTimeline.configuration&.watched?(tp.path)
92
+
93
+ correlation_id = CurrentCorrelation.id || determine_correlation_id
94
+ event_id = SecureRandom.uuid
95
+
96
+ push_to_stack(correlation_id, event_id, tp)
97
+ buffer_call_event(correlation_id, event_id, tp)
130
98
  rescue StandardError => e
131
- "#{record.class.name}(<inspection failed>)"
99
+ Rails.logger.error "EventTimeline call tracking failed: #{e.message}" if defined?(Rails.logger)
132
100
  end
133
101
 
134
- def inspect_model(model)
135
- "#{model.class.name}(#{model.class.attribute_names.size} attributes)"
102
+ def handle_return(tp)
103
+ return unless EventTimeline.configuration&.watched?(tp.path)
104
+
105
+ correlation_id = CurrentCorrelation.id || determine_correlation_id
106
+ method_info = pop_from_stack(correlation_id, tp)
107
+ return unless method_info
108
+
109
+ buffer_return_event(correlation_id, method_info, tp)
136
110
  rescue StandardError => e
137
- "#{model.class.name}(<inspection failed>)"
111
+ Rails.logger.error "EventTimeline return tracking failed: #{e.message}" if defined?(Rails.logger)
138
112
  end
139
113
 
140
- def simple_inspect(value)
141
- result = value.inspect
142
- if result.length > 200
143
- "#{value.class.name}[#{result.length} chars]"
144
- else
145
- result
146
- end
114
+ def push_to_stack(correlation_id, event_id, tp)
115
+ stack = thread_method_stack[correlation_id] ||= []
116
+ stack.push(
117
+ event_id: event_id,
118
+ method: method_signature(tp)
119
+ )
147
120
  end
148
121
 
149
- def filter_sensitive_data(key, value, context = {})
150
- if EventTimeline.configuration&.should_filter?(key, value, context)
151
- case value
152
- when String
153
- '<FILTERED>'
154
- when Hash
155
- filter_hash(value)
156
- when Array
157
- value.map.with_index { |item, index| filter_sensitive_data("item_#{index}", item, context) }
158
- when defined?(ActiveRecord::Base) && ActiveRecord::Base
159
- filter_activerecord(value)
160
- else
161
- '<FILTERED>'
162
- end
163
- else
164
- safe_inspect(value)
165
- end
122
+ def pop_from_stack(correlation_id, tp)
123
+ stack = thread_method_stack[correlation_id]
124
+ return unless stack
125
+
126
+ signature = method_signature(tp)
127
+
128
+ # Find the LAST matching entry (proper LIFO for recursion)
129
+ index = stack.rindex { |m| m[:method] == signature }
130
+ return unless index
131
+
132
+ stack.delete_at(index)
166
133
  end
167
134
 
168
- def filter_hash(hash)
169
- filtered = {}
170
- hash.each do |key, value|
171
- filtered[key] = filter_sensitive_data(key, value, { context: :hash_value })
172
- end
173
- filtered
135
+ def method_signature(tp)
136
+ "#{tp.defined_class}##{tp.method_id}"
174
137
  end
175
138
 
176
- def filter_activerecord(record)
177
- if EventTimeline.configuration&.should_filter?(:activerecord, record, { context: :model })
178
- '<FILTERED>'
179
- else
180
- # Show model with filtered attributes
181
- filtered_attrs = {}
182
- record.attributes.each do |key, value|
183
- filtered_attrs[key] = if EventTimeline.configuration&.should_filter?(key, value, { context: :attribute })
184
- '<FILTERED>'
185
- else
186
- safe_inspect(value)
187
- end
139
+ def buffer_call_event(correlation_id, event_id, tp)
140
+ buffer_event(
141
+ name: method_signature(tp),
142
+ severity: 'info',
143
+ category: 'method_call',
144
+ payload: {
145
+ event_id: event_id,
146
+ file: tp.path,
147
+ line: tp.lineno,
148
+ class: tp.defined_class.to_s,
149
+ method: tp.method_id.to_s,
150
+ params: capture_params(tp)
151
+ },
152
+ correlation_id: correlation_id,
153
+ occurred_at: Time.current
154
+ )
155
+ end
156
+
157
+ def buffer_return_event(correlation_id, method_info, tp)
158
+ buffer_event(
159
+ name: "#{method_signature(tp)}_return",
160
+ severity: 'info',
161
+ category: 'method_return',
162
+ payload: {
163
+ event_id: method_info[:event_id],
164
+ return_value: ValueFilter.filter(:return_value, tp.return_value, { context: :return_value }),
165
+ class: tp.defined_class.to_s,
166
+ method: tp.method_id.to_s
167
+ },
168
+ correlation_id: correlation_id,
169
+ occurred_at: Time.current
170
+ )
171
+ end
172
+
173
+ def thread_method_stack
174
+ Thread.current[THREAD_KEY_METHOD_STACK] ||= {}
175
+ end
176
+
177
+ def thread_event_buffer
178
+ Thread.current[THREAD_KEY_EVENT_BUFFER] ||= []
179
+ end
180
+
181
+ def buffer_event(event)
182
+ thread_event_buffer << event
183
+ end
184
+
185
+ def capture_params(tp)
186
+ method = tp.self.method(tp.method_id)
187
+ params = {}
188
+
189
+ method.parameters.each do |_type, name|
190
+ next unless name
191
+
192
+ if tp.binding.local_variable_defined?(name)
193
+ value = tp.binding.local_variable_get(name)
194
+ params[name] = ValueFilter.filter(name, value, { context: :parameter })
188
195
  end
189
- "#{inspect_activerecord(record)} {#{filtered_attrs.map { |k, v| "#{k}: #{v}" }.join(', ')}}"
190
196
  end
197
+
198
+ params
191
199
  rescue StandardError => e
192
- "#{inspect_activerecord(record)} <filtering failed>"
200
+ { error: "Failed to capture params: #{e.message}" }
193
201
  end
194
202
 
195
203
  def determine_correlation_id
196
204
  if Thread.current[:request_id]
197
205
  Thread.current[:request_id]
198
- elsif defined?(ActiveJob::Base) && ActiveJob::Base.current_execution&.job_id
199
- ActiveJob::Base.current_execution.job_id
206
+ elsif Thread.current[:active_job_id]
207
+ Thread.current[:active_job_id]
200
208
  else
201
209
  SecureRandom.uuid.tap { |id| CurrentCorrelation.id = id }
202
210
  end
@@ -3,15 +3,18 @@
3
3
  module EventTimeline
4
4
  class Configuration
5
5
  attr_accessor :narrator_proc, :watched_paths, :pii_filter_proc, :filtered_attributes,
6
- :max_events_per_correlation, :max_total_events, :cleanup_threshold, :max_event_age
6
+ :max_events_per_correlation, :max_total_events, :cleanup_threshold, :max_event_age,
7
+ :max_string_length, :max_inspect_length
7
8
 
8
9
  def initialize
9
10
  @watched_paths = []
10
11
  @filtered_attributes = default_filtered_attributes
11
- @max_events_per_correlation = 500 # Max events per correlation_id
12
- @max_total_events = 10_000 # Max total events before cleanup
13
- @cleanup_threshold = 0.8 # Start cleanup at 80% of max
14
- @max_event_age = 1.month # Delete events older than this
12
+ @max_events_per_correlation = 500 # Max events per correlation_id
13
+ @max_total_events = 10_000 # Max total events before cleanup
14
+ @cleanup_threshold = 0.8 # Start cleanup at 80% of max
15
+ @max_event_age = 1.month # Delete events older than this
16
+ @max_string_length = 100 # Truncate strings longer than this
17
+ @max_inspect_length = 200 # Truncate inspected values longer than this
15
18
  end
16
19
 
17
20
  def narrator(&block)
@@ -81,7 +84,11 @@ module EventTimeline
81
84
  def normalize_path(path)
82
85
  path = path.to_s
83
86
  path = "#{Rails.root}/#{path}" unless path.start_with?('/')
84
- "#{path.gsub(%r{/$}, '')}/**/*.rb" unless path.include?('*')
87
+ if path.include?('*')
88
+ path
89
+ else
90
+ "#{path.gsub(%r{/$}, '')}/**/*.rb"
91
+ end
85
92
  end
86
93
  end
87
94
  end
@@ -4,14 +4,12 @@ module EventTimeline
4
4
  class Engine < ::Rails::Engine
5
5
  isolate_namespace EventTimeline
6
6
 
7
- initializer 'timeline.install_call_tracker' do
8
- ActiveSupport.on_load(:after_initialize) do
9
- EventTimeline::CallTracker.install!
10
- end
7
+ initializer 'event_timeline.setup_middleware' do |app|
8
+ app.middleware.use EventTimeline::Middleware
11
9
  end
12
10
 
13
- initializer 'timeline.setup_middleware' do |app|
14
- app.middleware.use EventTimeline::Middleware
11
+ config.after_initialize do
12
+ EventTimeline::CallTracker.install! if EventTimeline.configuration
15
13
  end
16
14
  end
17
15
  end
@@ -8,11 +8,21 @@ module EventTimeline
8
8
 
9
9
  def call(env)
10
10
  request = ActionDispatch::Request.new(env)
11
- CurrentCorrelation.id = request.request_id
12
- Thread.current[:request_id] = request.request_id
11
+ correlation_id = request.request_id
12
+ CurrentCorrelation.id = correlation_id
13
+ Thread.current[:request_id] = correlation_id
13
14
 
14
15
  @app.call(env)
16
+ rescue Exception => e # rubocop:disable Lint/RescueException
17
+ # Capture the exception before re-raising
18
+ CallTracker.record_exception(e, correlation_id) if correlation_id
19
+ raise
15
20
  ensure
21
+ # Flush buffered events to database
22
+ CallTracker.flush_events(correlation_id) if correlation_id
23
+
24
+ # Clean up thread-local state
25
+ CallTracker.cleanup_thread_state(correlation_id) if correlation_id
16
26
  CurrentCorrelation.reset
17
27
  Thread.current[:request_id] = nil
18
28
  end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ module EventTimeline
4
+ class ValueFilter
5
+ FILTERED = '<FILTERED>'
6
+
7
+ class << self
8
+ def filter(key, value, context = {})
9
+ if should_filter?(key, value, context)
10
+ filter_sensitive_value(value, context)
11
+ else
12
+ filter_nested(key, value, context)
13
+ end
14
+ end
15
+
16
+ private
17
+
18
+ def should_filter?(key, value, context)
19
+ EventTimeline.configuration&.should_filter?(key, value, context)
20
+ end
21
+
22
+ def filter_sensitive_value(value, context)
23
+ case value
24
+ when String
25
+ FILTERED
26
+ when Hash
27
+ filter_hash(value, context)
28
+ when Array
29
+ filter_array(value, context)
30
+ else
31
+ return filter_activerecord(value) if ValueInspector.activerecord_model?(value)
32
+
33
+ FILTERED
34
+ end
35
+ end
36
+
37
+ def filter_nested(key, value, context)
38
+ case value
39
+ when Hash
40
+ filter_hash(value, context)
41
+ when Array
42
+ filter_array(value, context)
43
+ else
44
+ return filter_activerecord(value) if ValueInspector.activerecord_model?(value)
45
+
46
+ ValueInspector.inspect(value)
47
+ end
48
+ end
49
+
50
+ def filter_hash(hash, context = {})
51
+ hash.each_with_object({}) do |(key, value), filtered|
52
+ filtered[key] = filter(key, value, context.merge(context: :hash_value))
53
+ end
54
+ end
55
+
56
+ def filter_array(array, context = {})
57
+ array.map.with_index do |item, index|
58
+ filter("item_#{index}", item, context)
59
+ end
60
+ end
61
+
62
+ def filter_activerecord(record)
63
+ if should_filter?(:activerecord, record, { context: :model })
64
+ FILTERED
65
+ else
66
+ filter_activerecord_attributes(record)
67
+ end
68
+ rescue StandardError
69
+ "#{ValueInspector.inspect_activerecord(record)} <filtering failed>"
70
+ end
71
+
72
+ def filter_activerecord_attributes(record)
73
+ filtered_attrs = record.attributes.each_with_object({}) do |(key, value), attrs|
74
+ attrs[key] = if should_filter?(key, value, { context: :attribute })
75
+ FILTERED
76
+ else
77
+ ValueInspector.inspect(value)
78
+ end
79
+ end
80
+
81
+ "#{ValueInspector.inspect_activerecord(record)} {#{filtered_attrs.map { |k, v| "#{k}: #{v}" }.join(', ')}}"
82
+ end
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module EventTimeline
4
+ class ValueInspector
5
+ class << self
6
+ def inspect(value)
7
+ return inspect_string(value) if value.is_a?(String)
8
+ return inspect_collection(value) if value.is_a?(Hash) || value.is_a?(Array)
9
+ return inspect_activerecord(value) if activerecord_model?(value)
10
+ return inspect_activemodel(value) if activemodel_model?(value)
11
+ return value.name if value.is_a?(Class) || value.is_a?(Module)
12
+
13
+ inspect_generic(value)
14
+ rescue StandardError
15
+ '<inspect failed>'
16
+ end
17
+
18
+ def inspect_activerecord(record)
19
+ if record.persisted?
20
+ id_attr = record.class.primary_key
21
+ id_value = record.send(id_attr) if id_attr
22
+ "#{record.class.name}(#{id_attr}: #{id_value})"
23
+ else
24
+ "#{record.class.name}(new_record)"
25
+ end
26
+ rescue StandardError
27
+ "#{record.class.name}(<inspection failed>)"
28
+ end
29
+
30
+ def activerecord_model?(value)
31
+ defined?(ActiveRecord::Base) && value.is_a?(ActiveRecord::Base)
32
+ end
33
+
34
+ def activemodel_model?(value)
35
+ return false unless defined?(ActiveModel::Model)
36
+
37
+ value.class.included_modules.include?(ActiveModel::Model)
38
+ end
39
+
40
+ private
41
+
42
+ def inspect_string(value)
43
+ max_length = EventTimeline.configuration&.max_string_length || 100
44
+ value.length > max_length ? "#{value[0...max_length]}..." : value
45
+ end
46
+
47
+ def inspect_collection(value)
48
+ max_length = EventTimeline.configuration&.max_inspect_length || 200
49
+ inspected = value.inspect
50
+ inspected.length > max_length ? "#{value.class}[#{value.size} items]" : inspected
51
+ end
52
+
53
+ def inspect_activemodel(model)
54
+ "#{model.class.name}(#{model.class.attribute_names.size} attributes)"
55
+ rescue StandardError
56
+ "#{model.class.name}(<inspection failed>)"
57
+ end
58
+
59
+ def inspect_generic(value)
60
+ max_length = EventTimeline.configuration&.max_inspect_length || 200
61
+ result = value.inspect
62
+ result.length > max_length ? "#{value.class.name}[#{result.length} chars]" : result
63
+ end
64
+ end
65
+ end
66
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module EventTimeline
4
- VERSION = '0.1.0'
4
+ VERSION = '0.2.0'
5
5
  end
@@ -4,6 +4,8 @@ require 'event_timeline/version'
4
4
  require 'event_timeline/engine'
5
5
  require 'event_timeline/configuration'
6
6
  require 'event_timeline/current_correlation'
7
+ require 'event_timeline/value_inspector'
8
+ require 'event_timeline/value_filter'
7
9
  require 'event_timeline/call_tracker'
8
10
  require 'event_timeline/middleware'
9
11
  require 'event_timeline/rotation_service'
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rails/generators'
4
+ require 'rails/generators/migration'
5
+
6
+ module EventTimeline
7
+ module Generators
8
+ class InstallGenerator < Rails::Generators::Base
9
+ include Rails::Generators::Migration
10
+
11
+ source_root File.expand_path('templates', __dir__)
12
+
13
+ desc 'Creates an EventTimeline initializer and copies migrations'
14
+
15
+ def self.next_migration_number(dirname)
16
+ next_migration_number = current_migration_number(dirname) + 1
17
+ ActiveRecord::Migration.next_migration_number(next_migration_number)
18
+ end
19
+
20
+ def copy_initializer
21
+ template 'initializer.rb', 'config/initializers/event_timeline.rb'
22
+ end
23
+
24
+ def copy_migrations
25
+ migration_template 'create_event_timeline_sessions.rb',
26
+ 'db/migrate/create_event_timeline_sessions.rb',
27
+ skip: true
28
+ end
29
+
30
+ def show_post_install_message
31
+ say ''
32
+ say 'EventTimeline installed successfully!', :green
33
+ say ''
34
+ say 'Next steps:'
35
+ say ' 1. Run migrations: rails db:migrate'
36
+ say ' 2. Configure watched paths in config/initializers/event_timeline.rb'
37
+ say ' 3. Visit /event_timeline/sessions/:request_id to view timelines'
38
+ say ''
39
+ end
40
+ end
41
+ end
42
+ end
@@ -1,12 +1,12 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- class CreateEventTimelineSessions < ActiveRecord::Migration[8.1]
3
+ class CreateEventTimelineSessions < ActiveRecord::Migration[7.0]
4
4
  def change
5
5
  create_table :event_timeline_sessions do |t|
6
6
  t.string :name, null: false
7
7
  t.string :severity, default: 'info'
8
8
  t.string :category
9
- t.jsonb :payload
9
+ t.json :payload
10
10
  t.string :correlation_id, null: false
11
11
  t.datetime :occurred_at, null: false
12
12
 
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ EventTimeline.configure do |config|
4
+ # Configure which paths to track method calls for.
5
+ # Supports glob patterns and specific file paths.
6
+ #
7
+ # Examples:
8
+ # config.watch 'app/services'
9
+ # config.watch 'app/models/order.rb'
10
+ # config.watch 'lib/payment_processor'
11
+ #
12
+ # config.watch 'app/services'
13
+
14
+ # Add additional attributes to filter (beyond the defaults).
15
+ # Default filtered: password, token, secret, key, credential, auth,
16
+ # session, cookie, ssn, social_security, credit_card,
17
+ # card_number, cvv, pin, private, confidential
18
+ #
19
+ # config.add_filtered_attributes :bank_account, :routing_number
20
+
21
+ # Custom PII filtering logic (optional).
22
+ # Return true to filter, false to keep, nil to use default logic.
23
+ #
24
+ # config.filter_pii do |key, value, context|
25
+ # return true if key.to_s =~ /account_number/
26
+ # nil # Fall back to default filtering
27
+ # end
28
+
29
+ # Customize how events are displayed in the timeline (optional).
30
+ #
31
+ # config.narrator do |event|
32
+ # case event.name
33
+ # when /Payment/
34
+ # "[PAYMENT] #{event.name}"
35
+ # else
36
+ # event.name.humanize
37
+ # end
38
+ # end
39
+
40
+ # Data retention settings (defaults shown).
41
+ #
42
+ # config.max_events_per_correlation = 500 # Max events per request
43
+ # config.max_total_events = 10_000 # Max total events stored
44
+ # config.max_event_age = 1.month # Auto-delete events older than this
45
+ end
metadata CHANGED
@@ -1,13 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: event_timeline
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - svn-arv
8
+ autorequire:
8
9
  bindir: bin
9
10
  cert_chain: []
10
- date: 1980-01-02 00:00:00.000000000 Z
11
+ date: 2026-01-10 00:00:00.000000000 Z
11
12
  dependencies:
12
13
  - !ruby/object:Gem::Dependency
13
14
  name: rails
@@ -23,11 +24,39 @@ dependencies:
23
24
  - - ">="
24
25
  - !ruby/object:Gem::Version
25
26
  version: '7.0'
27
+ - !ruby/object:Gem::Dependency
28
+ name: minitest
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '5.0'
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '5.0'
41
+ - !ruby/object:Gem::Dependency
42
+ name: sqlite3
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - ">="
46
+ - !ruby/object:Gem::Version
47
+ version: '2.0'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - ">="
53
+ - !ruby/object:Gem::Version
54
+ version: '2.0'
26
55
  description: EventTimeline tracks method calls in your Rails app so you can replay
27
56
  and debug what happened during any request. Perfect for understanding production
28
57
  issues and unfamiliar codebases.
29
58
  email:
30
- - svn-arv@users.noreply.github.com
59
+ - eventtimelinerails@gmail.com
31
60
  executables: []
32
61
  extensions: []
33
62
  extra_rdoc_files: []
@@ -43,7 +72,6 @@ files:
43
72
  - app/views/event_timeline/sessions/show.html.erb
44
73
  - app/views/layouts/event_timeline/application.html.erb
45
74
  - config/routes.rb
46
- - db/migrate/20260103180115_create_event_timeline_sessions.rb
47
75
  - lib/event_timeline.rb
48
76
  - lib/event_timeline/call_tracker.rb
49
77
  - lib/event_timeline/configuration.rb
@@ -51,10 +79,17 @@ files:
51
79
  - lib/event_timeline/engine.rb
52
80
  - lib/event_timeline/middleware.rb
53
81
  - lib/event_timeline/rotation_service.rb
82
+ - lib/event_timeline/value_filter.rb
83
+ - lib/event_timeline/value_inspector.rb
54
84
  - lib/event_timeline/version.rb
85
+ - lib/generators/event_timeline/install/install_generator.rb
86
+ - lib/generators/event_timeline/install/templates/create_event_timeline_sessions.rb
87
+ - lib/generators/event_timeline/install/templates/initializer.rb
88
+ homepage: https://github.com/svn-arv/event_timeline
55
89
  licenses:
56
90
  - MIT
57
91
  metadata: {}
92
+ post_install_message:
58
93
  rdoc_options: []
59
94
  require_paths:
60
95
  - lib
@@ -62,14 +97,15 @@ required_ruby_version: !ruby/object:Gem::Requirement
62
97
  requirements:
63
98
  - - ">="
64
99
  - !ruby/object:Gem::Version
65
- version: '0'
100
+ version: '3.0'
66
101
  required_rubygems_version: !ruby/object:Gem::Requirement
67
102
  requirements:
68
103
  - - ">="
69
104
  - !ruby/object:Gem::Version
70
105
  version: '0'
71
106
  requirements: []
72
- rubygems_version: 3.7.2
107
+ rubygems_version: 3.4.10
108
+ signing_key:
73
109
  specification_version: 4
74
110
  summary: Debug Rails apps by replaying method calls from any request
75
111
  test_files: []