event_timeline 0.1.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: acfe8f6da4ca9044255ba5db0c60ea66e6aaedfebd3b9c47a36eb2ace19631f6
4
+ data.tar.gz: 9659ba87a4dd38ac503ddb6ee5573fb6088789240472e4e9f4cbe2ff0e063983
5
+ SHA512:
6
+ metadata.gz: 2d4e4efbc26ca24019666469a1d64d2f183f7c2264a0aecef97d61913fe018299f7fac4d00fa6e37d15f7fb39b064cd90d344bc02ba60f2e33460e309e4a3f42
7
+ data.tar.gz: fab0b6ee70307348d2513ade4988e94bbe1abaca374bcac8ea5b6b64a4f14519ac8bfe226841e047f3cdbc3e5c8a066b834e818abea6d5d1ca6b1662de97c1c2
data/MIT-LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ Copyright svn-arv
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining
4
+ a copy of this software and associated documentation files (the
5
+ "Software"), to deal in the Software without restriction, including
6
+ without limitation the rights to use, copy, modify, merge, publish,
7
+ distribute, sublicense, and/or sell copies of the Software, and to
8
+ permit persons to whom the Software is furnished to do so, subject to
9
+ the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be
12
+ included in all copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,129 @@
1
+ # EventTimeline
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.
4
+
5
+ ## What it does
6
+
7
+ EventTimeline records method calls and returns as they happen, letting you:
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
14
+
15
+ ## Installation
16
+
17
+ Add to your Gemfile:
18
+
19
+ ```ruby
20
+ gem 'event_timeline'
21
+ ```
22
+
23
+ Then:
24
+
25
+ ```bash
26
+ bundle install
27
+ rails generate event_timeline:install
28
+ rails db:migrate
29
+ ```
30
+
31
+ ## Basic Usage
32
+
33
+ ### 1. Configure what to track
34
+
35
+ ```ruby
36
+ # config/initializers/event_timeline.rb
37
+ EventTimeline.configure do |config|
38
+ # Track specific paths
39
+ config.watch 'app/services'
40
+ config.watch 'app/models/order.rb'
41
+ config.watch 'lib/payment_processor'
42
+ end
43
+ ```
44
+
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.
50
+
51
+ ## Real-world Example
52
+
53
+ Let's say a payment fails in production. Here's what you'd see:
54
+
55
+ ```
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
67
+ ```
68
+
69
+ Now you know exactly where things went wrong and what data was involved.
70
+
71
+ ## Configuration Options
72
+
73
+ ```ruby
74
+ EventTimeline.configure do |config|
75
+ # Filter sensitive data
76
+ config.add_filtered_attributes :credit_card, :ssn, :api_key
77
+
78
+ # Custom PII filtering
79
+ config.filter_pii do |key, value|
80
+ return '<REDACTED>' if key.to_s =~ /bank_account/
81
+ end
82
+
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
91
+ end
92
+
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
97
+ end
98
+ ```
99
+
100
+ ## Performance
101
+
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.
111
+
112
+ 2. **Development**: Watch your code execute in real-time. Great for understanding unfamiliar codebases.
113
+
114
+ 3. **Testing**: Verify your code follows the expected execution path.
115
+
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
+ ```
120
+
121
+ ## Limitations
122
+
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
126
+
127
+ ## License
128
+
129
+ MIT
data/Rakefile ADDED
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'bundler/setup'
4
+
5
+ require 'bundler/gem_tasks'
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module EventTimeline
4
+ class ApplicationController < ActionController::Base
5
+ end
6
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module EventTimeline
4
+ class SessionsController < ApplicationController
5
+ def show
6
+ @correlation_id = params[:id]
7
+ @events = Session.by_correlation(@correlation_id)
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module EventTimeline
4
+ module ApplicationHelper
5
+ def narrate_event(event)
6
+ if EventTimeline.configuration&.narrator_proc
7
+ EventTimeline.configuration.narrator_proc.call(event)
8
+ else
9
+ event.name.humanize
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module EventTimeline
4
+ class ApplicationRecord < ActiveRecord::Base
5
+ self.abstract_class = true
6
+ end
7
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module EventTimeline
4
+ class Session < ApplicationRecord
5
+ self.table_name = 'event_timeline_sessions'
6
+
7
+ validates :name, presence: true
8
+ validates :correlation_id, presence: true
9
+ validates :occurred_at, presence: true
10
+
11
+ scope :by_correlation, ->(id) { where(correlation_id: id).order(occurred_at: :asc) }
12
+ end
13
+ end
@@ -0,0 +1,46 @@
1
+ <div class="container">
2
+ <h1>Timeline for <span class="correlation-id"><%= @correlation_id %></span></h1>
3
+
4
+ <div class="timeline">
5
+ <% @events.each do |event| %>
6
+ <% 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>
16
+ <span class="event-time"><%= event.occurred_at.strftime("%H:%M:%S.%L") %></span>
17
+ </div>
18
+
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>
28
+ <% 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>
38
+ <% end %>
39
+
40
+ <% if @events.empty? %>
41
+ <p style="color: #6c757d; text-align: center; padding: 40px 0;">
42
+ No events found for this correlation ID
43
+ </p>
44
+ <% end %>
45
+ </div>
46
+ </div>
@@ -0,0 +1,117 @@
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ <title>EventTimeline</title>
5
+ <%= csrf_meta_tags %>
6
+ <%= csp_meta_tag %>
7
+
8
+ <style>
9
+ body {
10
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
11
+ margin: 0;
12
+ padding: 20px;
13
+ background: #f5f5f5;
14
+ }
15
+ .container {
16
+ max-width: 800px;
17
+ margin: 0 auto;
18
+ background: white;
19
+ padding: 20px;
20
+ border-radius: 8px;
21
+ box-shadow: 0 2px 4px rgba(0,0,0,0.1);
22
+ }
23
+ h1 {
24
+ margin: 0 0 20px 0;
25
+ font-size: 24px;
26
+ color: #333;
27
+ }
28
+ .timeline {
29
+ position: relative;
30
+ padding-left: 30px;
31
+ }
32
+ .timeline::before {
33
+ content: '';
34
+ position: absolute;
35
+ left: 10px;
36
+ top: 0;
37
+ bottom: 0;
38
+ width: 2px;
39
+ background: #ddd;
40
+ }
41
+ .event {
42
+ position: relative;
43
+ margin-bottom: 20px;
44
+ padding: 15px;
45
+ background: #f8f9fa;
46
+ border-radius: 6px;
47
+ border: 1px solid #e9ecef;
48
+ }
49
+ .event::before {
50
+ content: '';
51
+ position: absolute;
52
+ left: -25px;
53
+ top: 20px;
54
+ width: 10px;
55
+ height: 10px;
56
+ background: white;
57
+ border: 2px solid #ddd;
58
+ border-radius: 50%;
59
+ }
60
+ .event.error::before {
61
+ background: #dc3545;
62
+ border-color: #dc3545;
63
+ }
64
+ .event.warning::before {
65
+ background: #ffc107;
66
+ border-color: #ffc107;
67
+ }
68
+ .event.info::before {
69
+ background: #0dcaf0;
70
+ border-color: #0dcaf0;
71
+ }
72
+ .event.return::before {
73
+ background: #059862;
74
+ border-color: #059862;
75
+ }
76
+ .event-header {
77
+ display: flex;
78
+ align-items: center;
79
+ gap: 10px;
80
+ margin-bottom: 8px;
81
+ }
82
+ .event-name {
83
+ font-weight: 600;
84
+ color: #212529;
85
+ }
86
+ .event-time {
87
+ font-size: 14px;
88
+ color: #6c757d;
89
+ }
90
+ .event-payload {
91
+ margin-top: 10px;
92
+ padding: 10px;
93
+ background: white;
94
+ border-radius: 4px;
95
+ font-family: monospace;
96
+ font-size: 13px;
97
+ overflow-x: auto;
98
+ }
99
+ details summary {
100
+ cursor: pointer;
101
+ color: #0066cc;
102
+ font-size: 14px;
103
+ }
104
+ .correlation-id {
105
+ font-family: monospace;
106
+ background: #e9ecef;
107
+ padding: 2px 6px;
108
+ border-radius: 3px;
109
+ font-size: 14px;
110
+ }
111
+ </style>
112
+ </head>
113
+
114
+ <body>
115
+ <%= yield %>
116
+ </body>
117
+ </html>
data/config/routes.rb ADDED
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ EventTimeline::Engine.routes.draw do
4
+ resources :sessions, only: [:show]
5
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ class CreateEventTimelineSessions < ActiveRecord::Migration[8.1]
4
+ def change
5
+ create_table :event_timeline_sessions do |t|
6
+ t.string :name, null: false
7
+ t.string :severity, default: 'info'
8
+ t.string :category
9
+ t.jsonb :payload
10
+ t.string :correlation_id, null: false
11
+ t.datetime :occurred_at, null: false
12
+
13
+ t.timestamps
14
+ end
15
+
16
+ add_index :event_timeline_sessions, :correlation_id
17
+ add_index :event_timeline_sessions, :occurred_at
18
+ add_index :event_timeline_sessions, %i[correlation_id occurred_at]
19
+ end
20
+ end
@@ -0,0 +1,206 @@
1
+ # frozen_string_literal: true
2
+
3
+ module EventTimeline
4
+ class CallTracker
5
+ class << self
6
+ 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
51
+
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
78
+
79
+ call_trace.enable
80
+ return_trace.enable
81
+ end
82
+
83
+ private
84
+
85
+ def capture_params(tp)
86
+ method = tp.self.method(tp.method_id)
87
+ params = {}
88
+
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
95
+
96
+ params
97
+ rescue StandardError => e
98
+ { error: "Failed to capture params: #{e.message}" }
99
+ end
100
+
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
118
+ rescue StandardError => e
119
+ "<inspect failed: #{e.message}>"
120
+ end
121
+
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})"
127
+ else
128
+ "#{record.class.name}(new_record)"
129
+ end
130
+ rescue StandardError => e
131
+ "#{record.class.name}(<inspection failed>)"
132
+ end
133
+
134
+ def inspect_model(model)
135
+ "#{model.class.name}(#{model.class.attribute_names.size} attributes)"
136
+ rescue StandardError => e
137
+ "#{model.class.name}(<inspection failed>)"
138
+ end
139
+
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
147
+ end
148
+
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
166
+ end
167
+
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
174
+ end
175
+
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
188
+ end
189
+ "#{inspect_activerecord(record)} {#{filtered_attrs.map { |k, v| "#{k}: #{v}" }.join(', ')}}"
190
+ end
191
+ rescue StandardError => e
192
+ "#{inspect_activerecord(record)} <filtering failed>"
193
+ end
194
+
195
+ def determine_correlation_id
196
+ if Thread.current[:request_id]
197
+ Thread.current[:request_id]
198
+ elsif defined?(ActiveJob::Base) && ActiveJob::Base.current_execution&.job_id
199
+ ActiveJob::Base.current_execution.job_id
200
+ else
201
+ SecureRandom.uuid.tap { |id| CurrentCorrelation.id = id }
202
+ end
203
+ end
204
+ end
205
+ end
206
+ end
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ module EventTimeline
4
+ class Configuration
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
7
+
8
+ def initialize
9
+ @watched_paths = []
10
+ @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
15
+ end
16
+
17
+ def narrator(&block)
18
+ @narrator_proc = block if block_given?
19
+ end
20
+
21
+ def watch(path)
22
+ @watched_paths << normalize_path(path)
23
+ end
24
+
25
+ def watched?(file_path)
26
+ return false if @watched_paths.empty?
27
+
28
+ @watched_paths.any? do |pattern|
29
+ File.fnmatch?(pattern, file_path, File::FNM_PATHNAME)
30
+ end
31
+ end
32
+
33
+ def filter_pii(&block)
34
+ @pii_filter_proc = block if block_given?
35
+ end
36
+
37
+ def add_filtered_attributes(*attrs)
38
+ @filtered_attributes.concat(attrs.map(&:to_s))
39
+ end
40
+
41
+ def remove_filtered_attributes(*attrs)
42
+ attrs.each { |attr| @filtered_attributes.delete(attr.to_s) }
43
+ end
44
+
45
+ def should_filter?(key, value, context = {})
46
+ key_str = key.to_s.downcase
47
+
48
+ # Check custom filter first
49
+ if @pii_filter_proc
50
+ result = @pii_filter_proc.call(key, value, context)
51
+ return result unless result.nil?
52
+ end
53
+
54
+ # Default filtering logic
55
+ @filtered_attributes.any? { |attr| key_str.include?(attr) }
56
+ end
57
+
58
+ private
59
+
60
+ def default_filtered_attributes
61
+ %w[
62
+ password
63
+ token
64
+ secret
65
+ key
66
+ credential
67
+ auth
68
+ session
69
+ cookie
70
+ ssn
71
+ social_security
72
+ credit_card
73
+ card_number
74
+ cvv
75
+ pin
76
+ private
77
+ confidential
78
+ ]
79
+ end
80
+
81
+ def normalize_path(path)
82
+ path = path.to_s
83
+ path = "#{Rails.root}/#{path}" unless path.start_with?('/')
84
+ "#{path.gsub(%r{/$}, '')}/**/*.rb" unless path.include?('*')
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module EventTimeline
4
+ class CurrentCorrelation < ActiveSupport::CurrentAttributes
5
+ attribute :id
6
+ end
7
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module EventTimeline
4
+ class Engine < ::Rails::Engine
5
+ isolate_namespace EventTimeline
6
+
7
+ initializer 'timeline.install_call_tracker' do
8
+ ActiveSupport.on_load(:after_initialize) do
9
+ EventTimeline::CallTracker.install!
10
+ end
11
+ end
12
+
13
+ initializer 'timeline.setup_middleware' do |app|
14
+ app.middleware.use EventTimeline::Middleware
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module EventTimeline
4
+ class Middleware
5
+ def initialize(app)
6
+ @app = app
7
+ end
8
+
9
+ def call(env)
10
+ request = ActionDispatch::Request.new(env)
11
+ CurrentCorrelation.id = request.request_id
12
+ Thread.current[:request_id] = request.request_id
13
+
14
+ @app.call(env)
15
+ ensure
16
+ CurrentCorrelation.reset
17
+ Thread.current[:request_id] = nil
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module EventTimeline
4
+ class RotationService
5
+ class << self
6
+ def cleanup_if_needed
7
+ return unless EventTimeline.configuration
8
+
9
+ total_count = Session.count
10
+ max_total = EventTimeline.configuration.max_total_events
11
+ threshold = (max_total * EventTimeline.configuration.cleanup_threshold).to_i
12
+
13
+ return unless total_count >= threshold
14
+
15
+ perform_cleanup(total_count, max_total)
16
+ end
17
+
18
+ def enforce_correlation_limit(correlation_id)
19
+ return unless EventTimeline.configuration
20
+
21
+ max_per_correlation = EventTimeline.configuration.max_events_per_correlation
22
+ current_count = Session.where(correlation_id: correlation_id).count
23
+
24
+ return unless current_count >= max_per_correlation
25
+
26
+ # Remove oldest events for this correlation, keeping last 80%
27
+ keep_count = (max_per_correlation * 0.8).to_i
28
+ oldest_events = Session.where(correlation_id: correlation_id)
29
+ .order(:occurred_at)
30
+ .limit(current_count - keep_count)
31
+
32
+ Session.where(id: oldest_events.pluck(:id)).delete_all
33
+
34
+ Rails.logger.info "EventTimeline: Rotated #{current_count - keep_count} events for correlation #{correlation_id}" if defined?(Rails.logger)
35
+ end
36
+
37
+ private
38
+
39
+ def perform_cleanup(current_count, max_count)
40
+ # Calculate how many events to remove
41
+ target_count = (max_count * 0.7).to_i # Remove down to 70% of max
42
+ events_to_remove = current_count - target_count
43
+
44
+ # Remove oldest events first
45
+ oldest_events = Session.order(:occurred_at).limit(events_to_remove)
46
+ Session.where(id: oldest_events.pluck(:id)).delete_all
47
+
48
+ # Also clean up very old events based on configured age
49
+ max_age = EventTimeline.configuration.max_event_age
50
+ cutoff_date = max_age.ago
51
+ old_events_count = Session.where('occurred_at < ?', cutoff_date).delete_all
52
+
53
+ Rails.logger.info "EventTimeline: Cleaned up #{events_to_remove + old_events_count} events" if defined?(Rails.logger)
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module EventTimeline
4
+ VERSION = '0.1.0'
5
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'event_timeline/version'
4
+ require 'event_timeline/engine'
5
+ require 'event_timeline/configuration'
6
+ require 'event_timeline/current_correlation'
7
+ require 'event_timeline/call_tracker'
8
+ require 'event_timeline/middleware'
9
+ require 'event_timeline/rotation_service'
10
+
11
+ module EventTimeline
12
+ class << self
13
+ attr_accessor :configuration
14
+
15
+ def configure
16
+ self.configuration ||= Configuration.new
17
+ yield(configuration) if block_given?
18
+ end
19
+
20
+ alias config configure
21
+ end
22
+ end
metadata ADDED
@@ -0,0 +1,75 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: event_timeline
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - svn-arv
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: rails
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '7.0'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '7.0'
26
+ description: EventTimeline tracks method calls in your Rails app so you can replay
27
+ and debug what happened during any request. Perfect for understanding production
28
+ issues and unfamiliar codebases.
29
+ email:
30
+ - svn-arv@users.noreply.github.com
31
+ executables: []
32
+ extensions: []
33
+ extra_rdoc_files: []
34
+ files:
35
+ - MIT-LICENSE
36
+ - README.md
37
+ - Rakefile
38
+ - app/controllers/event_timeline/application_controller.rb
39
+ - app/controllers/event_timeline/sessions_controller.rb
40
+ - app/helpers/event_timeline/application_helper.rb
41
+ - app/models/event_timeline/application_record.rb
42
+ - app/models/event_timeline/session.rb
43
+ - app/views/event_timeline/sessions/show.html.erb
44
+ - app/views/layouts/event_timeline/application.html.erb
45
+ - config/routes.rb
46
+ - db/migrate/20260103180115_create_event_timeline_sessions.rb
47
+ - lib/event_timeline.rb
48
+ - lib/event_timeline/call_tracker.rb
49
+ - lib/event_timeline/configuration.rb
50
+ - lib/event_timeline/current_correlation.rb
51
+ - lib/event_timeline/engine.rb
52
+ - lib/event_timeline/middleware.rb
53
+ - lib/event_timeline/rotation_service.rb
54
+ - lib/event_timeline/version.rb
55
+ licenses:
56
+ - MIT
57
+ metadata: {}
58
+ rdoc_options: []
59
+ require_paths:
60
+ - lib
61
+ required_ruby_version: !ruby/object:Gem::Requirement
62
+ requirements:
63
+ - - ">="
64
+ - !ruby/object:Gem::Version
65
+ version: '0'
66
+ required_rubygems_version: !ruby/object:Gem::Requirement
67
+ requirements:
68
+ - - ">="
69
+ - !ruby/object:Gem::Version
70
+ version: '0'
71
+ requirements: []
72
+ rubygems_version: 3.7.2
73
+ specification_version: 4
74
+ summary: Debug Rails apps by replaying method calls from any request
75
+ test_files: []