event_timeline 0.1.0 → 0.2.1
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 +4 -4
- data/README.md +43 -76
- data/Rakefile +3 -1
- data/app/views/event_timeline/sessions/show.html.erb +69 -27
- data/app/views/layouts/event_timeline/application.html.erb +59 -0
- data/lib/event_timeline/call_tracker.rb +168 -158
- data/lib/event_timeline/configuration.rb +23 -8
- data/lib/event_timeline/engine.rb +4 -6
- data/lib/event_timeline/middleware.rb +12 -2
- data/lib/event_timeline/rotation_service.rb +29 -2
- data/lib/event_timeline/value_filter.rb +85 -0
- data/lib/event_timeline/value_inspector.rb +66 -0
- data/lib/event_timeline/version.rb +1 -1
- data/lib/event_timeline.rb +2 -0
- data/lib/generators/event_timeline/install/install_generator.rb +42 -0
- data/{db/migrate/20260103180115_create_event_timeline_sessions.rb → lib/generators/event_timeline/install/templates/create_event_timeline_sessions.rb} +2 -2
- data/lib/generators/event_timeline/install/templates/initializer.rb +45 -0
- metadata +42 -6
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 7f9b39a082143ef828a48c1ba89377103062e5fd2d356658d257964727402c22
|
|
4
|
+
data.tar.gz: f332374e2650e04ae7cfe4dee1ae27d98b11d268486284afbd02ddba8def7576
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: c7ff33b22475ea362d891023c7fc942e9ee85878d13fba5cf113426e3aea0ba1b48e10ad9a6f0ea101b5dd740900beaacd65b021a2a0903fc70f8d51ba2bc9b1
|
|
7
|
+
data.tar.gz: 44ccc4f17fdb6e65f247a5209cec9cea0e6f458593ad113522b1da7056f9ca846db487a1f0d6cbbbcd5faca12c6be9be5ca1c917d17ae52674f23a58d43b25e3
|
data/README.md
CHANGED
|
@@ -1,128 +1,95 @@
|
|
|
1
1
|
# EventTimeline
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
A Rails engine that records method calls during requests so you can see what actually happened.
|
|
4
4
|
|
|
5
|
-
##
|
|
5
|
+
## Why?
|
|
6
6
|
|
|
7
|
-
|
|
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
|
-
|
|
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
|
-
##
|
|
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
|
-
##
|
|
23
|
+
## Setup
|
|
32
24
|
|
|
33
|
-
|
|
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
|
|
41
|
-
config.watch 'lib/payment_processor'
|
|
31
|
+
config.watch 'app/models'
|
|
42
32
|
end
|
|
43
33
|
```
|
|
44
34
|
|
|
45
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
45
|
+
## Configuration
|
|
72
46
|
|
|
73
47
|
```ruby
|
|
74
48
|
EventTimeline.configure do |config|
|
|
75
|
-
#
|
|
76
|
-
config.
|
|
49
|
+
# What to track
|
|
50
|
+
config.watch 'app/services'
|
|
51
|
+
config.watch 'lib/payments'
|
|
77
52
|
|
|
78
|
-
#
|
|
79
|
-
config.
|
|
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
|
-
#
|
|
84
|
-
config.
|
|
85
|
-
|
|
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
|
-
#
|
|
94
|
-
config.max_events_per_correlation = 500
|
|
95
|
-
config.max_total_events = 10_000
|
|
96
|
-
config.max_event_age = 1.month
|
|
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
|
-
##
|
|
72
|
+
## Runtime control
|
|
101
73
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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
|
-
|
|
80
|
+
## Custom correlation IDs
|
|
113
81
|
|
|
114
|
-
|
|
82
|
+
For background jobs or anything outside a request:
|
|
115
83
|
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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
|
|
124
|
-
- TracePoint
|
|
125
|
-
- Large
|
|
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,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
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
+
<- <%= 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
|
-
|
|
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,211 @@
|
|
|
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
|
-
|
|
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
|
-
|
|
53
|
-
|
|
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
|
-
|
|
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
|
|
86
|
-
|
|
87
|
-
|
|
28
|
+
def installed?
|
|
29
|
+
@call_trace&.enabled? || @return_trace&.enabled?
|
|
30
|
+
end
|
|
88
31
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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
|
-
|
|
36
|
+
buffer_size = buffer.size
|
|
37
|
+
Session.insert_all(buffer)
|
|
38
|
+
|
|
39
|
+
RotationService.enforce_correlation_limit(correlation_id, buffer_size)
|
|
40
|
+
RotationService.cleanup_if_needed
|
|
97
41
|
rescue StandardError => e
|
|
98
|
-
|
|
42
|
+
Rails.logger.error "EventTimeline flush failed: #{e.message}" if defined?(Rails.logger)
|
|
99
43
|
end
|
|
100
44
|
|
|
101
|
-
def
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
45
|
+
def cleanup_thread_state(correlation_id)
|
|
46
|
+
thread_method_stack.delete(correlation_id)
|
|
47
|
+
Thread.current[THREAD_KEY_EVENT_BUFFER] = []
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def record_exception(exception, correlation_id)
|
|
51
|
+
backtrace = clean_backtrace(exception.backtrace || [])
|
|
52
|
+
source_location = extract_source_location(backtrace)
|
|
53
|
+
|
|
54
|
+
buffer_event(
|
|
55
|
+
name: "EXCEPTION: #{exception.class.name}",
|
|
56
|
+
severity: 'error',
|
|
57
|
+
category: 'exception',
|
|
58
|
+
payload: {
|
|
59
|
+
exception_class: exception.class.name,
|
|
60
|
+
message: exception.message,
|
|
61
|
+
backtrace: backtrace.first(10),
|
|
62
|
+
source_file: source_location[:file],
|
|
63
|
+
source_line: source_location[:line],
|
|
64
|
+
source_method: source_location[:method]
|
|
65
|
+
},
|
|
66
|
+
correlation_id: correlation_id,
|
|
67
|
+
occurred_at: Time.current
|
|
68
|
+
)
|
|
118
69
|
rescue StandardError => e
|
|
119
|
-
"
|
|
70
|
+
Rails.logger.error "EventTimeline exception recording failed: #{e.message}" if defined?(Rails.logger)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
private
|
|
74
|
+
|
|
75
|
+
def clean_backtrace(backtrace)
|
|
76
|
+
# Filter out gem internals, keep app code
|
|
77
|
+
backtrace.reject { |line| line.include?('/gems/') || line.include?('/ruby/') }
|
|
120
78
|
end
|
|
121
79
|
|
|
122
|
-
def
|
|
123
|
-
if
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
80
|
+
def extract_source_location(backtrace)
|
|
81
|
+
return { file: nil, line: nil, method: nil } if backtrace.empty?
|
|
82
|
+
|
|
83
|
+
# Parse first line: "/path/to/file.rb:123:in `method_name'"
|
|
84
|
+
if backtrace.first =~ /\A(.+):(\d+):in `(.+)'\z/
|
|
85
|
+
{ file: ::Regexp.last_match(1), line: ::Regexp.last_match(2).to_i, method: ::Regexp.last_match(3) }
|
|
127
86
|
else
|
|
128
|
-
|
|
87
|
+
{ file: backtrace.first, line: nil, method: nil }
|
|
129
88
|
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def handle_call(tp)
|
|
92
|
+
return unless EventTimeline.configuration&.watched?(tp.path)
|
|
93
|
+
|
|
94
|
+
correlation_id = CurrentCorrelation.id || determine_correlation_id
|
|
95
|
+
event_id = SecureRandom.uuid
|
|
96
|
+
|
|
97
|
+
push_to_stack(correlation_id, event_id, tp)
|
|
98
|
+
buffer_call_event(correlation_id, event_id, tp)
|
|
130
99
|
rescue StandardError => e
|
|
131
|
-
"#{
|
|
100
|
+
Rails.logger.error "EventTimeline call tracking failed: #{e.message}" if defined?(Rails.logger)
|
|
132
101
|
end
|
|
133
102
|
|
|
134
|
-
def
|
|
135
|
-
|
|
103
|
+
def handle_return(tp)
|
|
104
|
+
# Skip watched? check - if we didn't track the call, pop_from_stack returns nil
|
|
105
|
+
correlation_id = CurrentCorrelation.id
|
|
106
|
+
return unless correlation_id
|
|
107
|
+
|
|
108
|
+
method_info = pop_from_stack(correlation_id, tp)
|
|
109
|
+
return unless method_info
|
|
110
|
+
|
|
111
|
+
buffer_return_event(correlation_id, method_info, tp)
|
|
136
112
|
rescue StandardError => e
|
|
137
|
-
"#{
|
|
113
|
+
Rails.logger.error "EventTimeline return tracking failed: #{e.message}" if defined?(Rails.logger)
|
|
138
114
|
end
|
|
139
115
|
|
|
140
|
-
def
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
end
|
|
116
|
+
def push_to_stack(correlation_id, event_id, tp)
|
|
117
|
+
stack = thread_method_stack[correlation_id] ||= []
|
|
118
|
+
stack.push(
|
|
119
|
+
event_id: event_id,
|
|
120
|
+
method: method_signature(tp)
|
|
121
|
+
)
|
|
147
122
|
end
|
|
148
123
|
|
|
149
|
-
def
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
else
|
|
161
|
-
'<FILTERED>'
|
|
162
|
-
end
|
|
163
|
-
else
|
|
164
|
-
safe_inspect(value)
|
|
165
|
-
end
|
|
124
|
+
def pop_from_stack(correlation_id, tp)
|
|
125
|
+
stack = thread_method_stack[correlation_id]
|
|
126
|
+
return unless stack
|
|
127
|
+
|
|
128
|
+
signature = method_signature(tp)
|
|
129
|
+
|
|
130
|
+
# Find the LAST matching entry (proper LIFO for recursion)
|
|
131
|
+
index = stack.rindex { |m| m[:method] == signature }
|
|
132
|
+
return unless index
|
|
133
|
+
|
|
134
|
+
stack.delete_at(index)
|
|
166
135
|
end
|
|
167
136
|
|
|
168
|
-
def
|
|
169
|
-
|
|
170
|
-
hash.each do |key, value|
|
|
171
|
-
filtered[key] = filter_sensitive_data(key, value, { context: :hash_value })
|
|
172
|
-
end
|
|
173
|
-
filtered
|
|
137
|
+
def method_signature(tp)
|
|
138
|
+
"#{tp.defined_class}##{tp.method_id}"
|
|
174
139
|
end
|
|
175
140
|
|
|
176
|
-
def
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
141
|
+
def buffer_call_event(correlation_id, event_id, tp)
|
|
142
|
+
buffer_event(
|
|
143
|
+
name: method_signature(tp),
|
|
144
|
+
severity: 'info',
|
|
145
|
+
category: 'method_call',
|
|
146
|
+
payload: {
|
|
147
|
+
event_id: event_id,
|
|
148
|
+
file: tp.path,
|
|
149
|
+
line: tp.lineno,
|
|
150
|
+
class: tp.defined_class.to_s,
|
|
151
|
+
method: tp.method_id.to_s,
|
|
152
|
+
params: capture_params(tp)
|
|
153
|
+
},
|
|
154
|
+
correlation_id: correlation_id,
|
|
155
|
+
occurred_at: Time.current
|
|
156
|
+
)
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def buffer_return_event(correlation_id, method_info, tp)
|
|
160
|
+
buffer_event(
|
|
161
|
+
name: "#{method_signature(tp)}_return",
|
|
162
|
+
severity: 'info',
|
|
163
|
+
category: 'method_return',
|
|
164
|
+
payload: {
|
|
165
|
+
event_id: method_info[:event_id],
|
|
166
|
+
return_value: ValueFilter.filter(:return_value, tp.return_value, { context: :return_value }),
|
|
167
|
+
class: tp.defined_class.to_s,
|
|
168
|
+
method: tp.method_id.to_s
|
|
169
|
+
},
|
|
170
|
+
correlation_id: correlation_id,
|
|
171
|
+
occurred_at: Time.current
|
|
172
|
+
)
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def thread_method_stack
|
|
176
|
+
Thread.current[THREAD_KEY_METHOD_STACK] ||= {}
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def thread_event_buffer
|
|
180
|
+
Thread.current[THREAD_KEY_EVENT_BUFFER] ||= []
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def buffer_event(event)
|
|
184
|
+
thread_event_buffer << event
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def capture_params(tp)
|
|
188
|
+
method = tp.self.method(tp.method_id)
|
|
189
|
+
params = {}
|
|
190
|
+
|
|
191
|
+
method.parameters.each do |_type, name|
|
|
192
|
+
next unless name
|
|
193
|
+
|
|
194
|
+
if tp.binding.local_variable_defined?(name)
|
|
195
|
+
value = tp.binding.local_variable_get(name)
|
|
196
|
+
params[name] = ValueFilter.filter(name, value, { context: :parameter })
|
|
188
197
|
end
|
|
189
|
-
"#{inspect_activerecord(record)} {#{filtered_attrs.map { |k, v| "#{k}: #{v}" }.join(', ')}}"
|
|
190
198
|
end
|
|
199
|
+
|
|
200
|
+
params
|
|
191
201
|
rescue StandardError => e
|
|
192
|
-
"#{
|
|
202
|
+
{ error: "Failed to capture params: #{e.message}" }
|
|
193
203
|
end
|
|
194
204
|
|
|
195
205
|
def determine_correlation_id
|
|
196
206
|
if Thread.current[:request_id]
|
|
197
207
|
Thread.current[:request_id]
|
|
198
|
-
elsif
|
|
199
|
-
|
|
208
|
+
elsif Thread.current[:active_job_id]
|
|
209
|
+
Thread.current[:active_job_id]
|
|
200
210
|
else
|
|
201
211
|
SecureRandom.uuid.tap { |id| CurrentCorrelation.id = id }
|
|
202
212
|
end
|
|
@@ -3,15 +3,19 @@
|
|
|
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 = []
|
|
11
|
+
@watched_cache = {} # Cache for watched? lookups
|
|
10
12
|
@filtered_attributes = default_filtered_attributes
|
|
11
|
-
@max_events_per_correlation = 500
|
|
12
|
-
@max_total_events = 10_000
|
|
13
|
-
@cleanup_threshold = 0.8
|
|
14
|
-
@max_event_age = 1.month
|
|
13
|
+
@max_events_per_correlation = 500 # Max events per correlation_id
|
|
14
|
+
@max_total_events = 10_000 # Max total events before cleanup
|
|
15
|
+
@cleanup_threshold = 0.8 # Start cleanup at 80% of max
|
|
16
|
+
@max_event_age = 1.month # Delete events older than this
|
|
17
|
+
@max_string_length = 100 # Truncate strings longer than this
|
|
18
|
+
@max_inspect_length = 200 # Truncate inspected values longer than this
|
|
15
19
|
end
|
|
16
20
|
|
|
17
21
|
def narrator(&block)
|
|
@@ -20,16 +24,23 @@ module EventTimeline
|
|
|
20
24
|
|
|
21
25
|
def watch(path)
|
|
22
26
|
@watched_paths << normalize_path(path)
|
|
27
|
+
@watched_cache.clear # Invalidate cache when paths change
|
|
23
28
|
end
|
|
24
29
|
|
|
25
30
|
def watched?(file_path)
|
|
26
31
|
return false if @watched_paths.empty?
|
|
27
32
|
|
|
28
|
-
@
|
|
29
|
-
|
|
33
|
+
@watched_cache.fetch(file_path) do
|
|
34
|
+
@watched_cache[file_path] = @watched_paths.any? do |pattern|
|
|
35
|
+
File.fnmatch?(pattern, file_path, File::FNM_PATHNAME)
|
|
36
|
+
end
|
|
30
37
|
end
|
|
31
38
|
end
|
|
32
39
|
|
|
40
|
+
def clear_watched_cache!
|
|
41
|
+
@watched_cache.clear
|
|
42
|
+
end
|
|
43
|
+
|
|
33
44
|
def filter_pii(&block)
|
|
34
45
|
@pii_filter_proc = block if block_given?
|
|
35
46
|
end
|
|
@@ -81,7 +92,11 @@ module EventTimeline
|
|
|
81
92
|
def normalize_path(path)
|
|
82
93
|
path = path.to_s
|
|
83
94
|
path = "#{Rails.root}/#{path}" unless path.start_with?('/')
|
|
84
|
-
|
|
95
|
+
if path.include?('*')
|
|
96
|
+
path
|
|
97
|
+
else
|
|
98
|
+
"#{path.gsub(%r{/$}, '')}/**/*.rb"
|
|
99
|
+
end
|
|
85
100
|
end
|
|
86
101
|
end
|
|
87
102
|
end
|
|
@@ -4,14 +4,12 @@ module EventTimeline
|
|
|
4
4
|
class Engine < ::Rails::Engine
|
|
5
5
|
isolate_namespace EventTimeline
|
|
6
6
|
|
|
7
|
-
initializer '
|
|
8
|
-
|
|
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
|
-
|
|
14
|
-
|
|
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
|
-
|
|
12
|
-
|
|
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
|
|
@@ -2,10 +2,19 @@
|
|
|
2
2
|
|
|
3
3
|
module EventTimeline
|
|
4
4
|
class RotationService
|
|
5
|
+
CLEANUP_CHECK_INTERVAL = 100 # Only check every N flushes
|
|
6
|
+
|
|
5
7
|
class << self
|
|
6
|
-
def cleanup_if_needed
|
|
8
|
+
def cleanup_if_needed(force: false)
|
|
7
9
|
return unless EventTimeline.configuration
|
|
8
10
|
|
|
11
|
+
unless force
|
|
12
|
+
# Probabilistic check - only run expensive Session.count every N requests
|
|
13
|
+
@flush_counter ||= 0
|
|
14
|
+
@flush_counter += 1
|
|
15
|
+
return unless (@flush_counter % CLEANUP_CHECK_INTERVAL).zero?
|
|
16
|
+
end
|
|
17
|
+
|
|
9
18
|
total_count = Session.count
|
|
10
19
|
max_total = EventTimeline.configuration.max_total_events
|
|
11
20
|
threshold = (max_total * EventTimeline.configuration.cleanup_threshold).to_i
|
|
@@ -15,11 +24,23 @@ module EventTimeline
|
|
|
15
24
|
perform_cleanup(total_count, max_total)
|
|
16
25
|
end
|
|
17
26
|
|
|
18
|
-
def enforce_correlation_limit(correlation_id)
|
|
27
|
+
def enforce_correlation_limit(correlation_id, buffer_size = 0, force: false)
|
|
19
28
|
return unless EventTimeline.configuration
|
|
20
29
|
|
|
21
30
|
max_per_correlation = EventTimeline.configuration.max_events_per_correlation
|
|
31
|
+
|
|
32
|
+
unless force
|
|
33
|
+
# Track in-memory counts to avoid DB query on every flush
|
|
34
|
+
@correlation_counts ||= {}
|
|
35
|
+
@correlation_counts[correlation_id] ||= 0
|
|
36
|
+
@correlation_counts[correlation_id] += buffer_size
|
|
37
|
+
|
|
38
|
+
# Only hit DB when we think we might be near the limit
|
|
39
|
+
return unless @correlation_counts[correlation_id] >= (max_per_correlation * 0.9)
|
|
40
|
+
end
|
|
41
|
+
|
|
22
42
|
current_count = Session.where(correlation_id: correlation_id).count
|
|
43
|
+
@correlation_counts[correlation_id] = current_count if @correlation_counts # Sync with reality
|
|
23
44
|
|
|
24
45
|
return unless current_count >= max_per_correlation
|
|
25
46
|
|
|
@@ -30,10 +51,16 @@ module EventTimeline
|
|
|
30
51
|
.limit(current_count - keep_count)
|
|
31
52
|
|
|
32
53
|
Session.where(id: oldest_events.pluck(:id)).delete_all
|
|
54
|
+
@correlation_counts[correlation_id] = keep_count if @correlation_counts
|
|
33
55
|
|
|
34
56
|
Rails.logger.info "EventTimeline: Rotated #{current_count - keep_count} events for correlation #{correlation_id}" if defined?(Rails.logger)
|
|
35
57
|
end
|
|
36
58
|
|
|
59
|
+
def reset_counters!
|
|
60
|
+
@flush_counter = 0
|
|
61
|
+
@correlation_counts = {}
|
|
62
|
+
end
|
|
63
|
+
|
|
37
64
|
private
|
|
38
65
|
|
|
39
66
|
def perform_cleanup(current_count, max_count)
|
|
@@ -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
|
data/lib/event_timeline.rb
CHANGED
|
@@ -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[
|
|
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.
|
|
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
|
|
4
|
+
version: 0.2.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- svn-arv
|
|
8
|
+
autorequire:
|
|
8
9
|
bindir: bin
|
|
9
10
|
cert_chain: []
|
|
10
|
-
date:
|
|
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
|
-
-
|
|
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.
|
|
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: []
|