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 +7 -0
- data/MIT-LICENSE +20 -0
- data/README.md +129 -0
- data/Rakefile +5 -0
- data/app/controllers/event_timeline/application_controller.rb +6 -0
- data/app/controllers/event_timeline/sessions_controller.rb +10 -0
- data/app/helpers/event_timeline/application_helper.rb +13 -0
- data/app/models/event_timeline/application_record.rb +7 -0
- data/app/models/event_timeline/session.rb +13 -0
- data/app/views/event_timeline/sessions/show.html.erb +46 -0
- data/app/views/layouts/event_timeline/application.html.erb +117 -0
- data/config/routes.rb +5 -0
- data/db/migrate/20260103180115_create_event_timeline_sessions.rb +20 -0
- data/lib/event_timeline/call_tracker.rb +206 -0
- data/lib/event_timeline/configuration.rb +87 -0
- data/lib/event_timeline/current_correlation.rb +7 -0
- data/lib/event_timeline/engine.rb +17 -0
- data/lib/event_timeline/middleware.rb +20 -0
- data/lib/event_timeline/rotation_service.rb +57 -0
- data/lib/event_timeline/version.rb +5 -0
- data/lib/event_timeline.rb +22 -0
- metadata +75 -0
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,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,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
|
+
<- <%= 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,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,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,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: []
|