event_timeline 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +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 +166 -158
- data/lib/event_timeline/configuration.rb +13 -6
- data/lib/event_timeline/engine.rb +4 -6
- data/lib/event_timeline/middleware.rb +12 -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: e6bb353fe95dc4e1add0e9b01de0f938d1e9feb7dcffddf4501d9c4fc6d74a3d
|
|
4
|
+
data.tar.gz: af1804a78347c235856b8a950bd843d412a5fe7724573a139653a5aef689460b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 3f58e34396e514e0f4affb1f71198b14711081147618d631c82b33b2ab49b8b8e15e5efe9b5a60bcc63e5f012aad80cb6b59da7263af2ca4969f2dd924d2bebf
|
|
7
|
+
data.tar.gz: a21b2da8e8e4a60351238484b5927b02634c37f3f49b79ea053b6b64107528931cf94cf22c825b7c781640c5b31ac6a362bc528049624099c49032701e5197b8
|
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,209 @@
|
|
|
2
2
|
|
|
3
3
|
module EventTimeline
|
|
4
4
|
class CallTracker
|
|
5
|
+
THREAD_KEY_METHOD_STACK = :event_timeline_method_stack
|
|
6
|
+
THREAD_KEY_EVENT_BUFFER = :event_timeline_event_buffer
|
|
7
|
+
|
|
5
8
|
class << self
|
|
9
|
+
attr_reader :call_trace, :return_trace
|
|
10
|
+
|
|
6
11
|
def install!
|
|
7
|
-
|
|
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
|
+
Session.insert_all(buffer)
|
|
37
|
+
|
|
38
|
+
RotationService.enforce_correlation_limit(correlation_id)
|
|
39
|
+
RotationService.cleanup_if_needed
|
|
97
40
|
rescue StandardError => e
|
|
98
|
-
|
|
41
|
+
Rails.logger.error "EventTimeline flush failed: #{e.message}" if defined?(Rails.logger)
|
|
99
42
|
end
|
|
100
43
|
|
|
101
|
-
def
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
44
|
+
def cleanup_thread_state(correlation_id)
|
|
45
|
+
thread_method_stack.delete(correlation_id)
|
|
46
|
+
Thread.current[THREAD_KEY_EVENT_BUFFER] = []
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def record_exception(exception, correlation_id)
|
|
50
|
+
backtrace = clean_backtrace(exception.backtrace || [])
|
|
51
|
+
source_location = extract_source_location(backtrace)
|
|
52
|
+
|
|
53
|
+
buffer_event(
|
|
54
|
+
name: "EXCEPTION: #{exception.class.name}",
|
|
55
|
+
severity: 'error',
|
|
56
|
+
category: 'exception',
|
|
57
|
+
payload: {
|
|
58
|
+
exception_class: exception.class.name,
|
|
59
|
+
message: exception.message,
|
|
60
|
+
backtrace: backtrace.first(10),
|
|
61
|
+
source_file: source_location[:file],
|
|
62
|
+
source_line: source_location[:line],
|
|
63
|
+
source_method: source_location[:method]
|
|
64
|
+
},
|
|
65
|
+
correlation_id: correlation_id,
|
|
66
|
+
occurred_at: Time.current
|
|
67
|
+
)
|
|
118
68
|
rescue StandardError => e
|
|
119
|
-
"
|
|
69
|
+
Rails.logger.error "EventTimeline exception recording failed: #{e.message}" if defined?(Rails.logger)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
private
|
|
73
|
+
|
|
74
|
+
def clean_backtrace(backtrace)
|
|
75
|
+
# Filter out gem internals, keep app code
|
|
76
|
+
backtrace.reject { |line| line.include?('/gems/') || line.include?('/ruby/') }
|
|
120
77
|
end
|
|
121
78
|
|
|
122
|
-
def
|
|
123
|
-
if
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
79
|
+
def extract_source_location(backtrace)
|
|
80
|
+
return { file: nil, line: nil, method: nil } if backtrace.empty?
|
|
81
|
+
|
|
82
|
+
# Parse first line: "/path/to/file.rb:123:in `method_name'"
|
|
83
|
+
if backtrace.first =~ /\A(.+):(\d+):in `(.+)'\z/
|
|
84
|
+
{ file: ::Regexp.last_match(1), line: ::Regexp.last_match(2).to_i, method: ::Regexp.last_match(3) }
|
|
127
85
|
else
|
|
128
|
-
|
|
86
|
+
{ file: backtrace.first, line: nil, method: nil }
|
|
129
87
|
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def handle_call(tp)
|
|
91
|
+
return unless EventTimeline.configuration&.watched?(tp.path)
|
|
92
|
+
|
|
93
|
+
correlation_id = CurrentCorrelation.id || determine_correlation_id
|
|
94
|
+
event_id = SecureRandom.uuid
|
|
95
|
+
|
|
96
|
+
push_to_stack(correlation_id, event_id, tp)
|
|
97
|
+
buffer_call_event(correlation_id, event_id, tp)
|
|
130
98
|
rescue StandardError => e
|
|
131
|
-
"#{
|
|
99
|
+
Rails.logger.error "EventTimeline call tracking failed: #{e.message}" if defined?(Rails.logger)
|
|
132
100
|
end
|
|
133
101
|
|
|
134
|
-
def
|
|
135
|
-
|
|
102
|
+
def handle_return(tp)
|
|
103
|
+
return unless EventTimeline.configuration&.watched?(tp.path)
|
|
104
|
+
|
|
105
|
+
correlation_id = CurrentCorrelation.id || determine_correlation_id
|
|
106
|
+
method_info = pop_from_stack(correlation_id, tp)
|
|
107
|
+
return unless method_info
|
|
108
|
+
|
|
109
|
+
buffer_return_event(correlation_id, method_info, tp)
|
|
136
110
|
rescue StandardError => e
|
|
137
|
-
"#{
|
|
111
|
+
Rails.logger.error "EventTimeline return tracking failed: #{e.message}" if defined?(Rails.logger)
|
|
138
112
|
end
|
|
139
113
|
|
|
140
|
-
def
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
end
|
|
114
|
+
def push_to_stack(correlation_id, event_id, tp)
|
|
115
|
+
stack = thread_method_stack[correlation_id] ||= []
|
|
116
|
+
stack.push(
|
|
117
|
+
event_id: event_id,
|
|
118
|
+
method: method_signature(tp)
|
|
119
|
+
)
|
|
147
120
|
end
|
|
148
121
|
|
|
149
|
-
def
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
else
|
|
161
|
-
'<FILTERED>'
|
|
162
|
-
end
|
|
163
|
-
else
|
|
164
|
-
safe_inspect(value)
|
|
165
|
-
end
|
|
122
|
+
def pop_from_stack(correlation_id, tp)
|
|
123
|
+
stack = thread_method_stack[correlation_id]
|
|
124
|
+
return unless stack
|
|
125
|
+
|
|
126
|
+
signature = method_signature(tp)
|
|
127
|
+
|
|
128
|
+
# Find the LAST matching entry (proper LIFO for recursion)
|
|
129
|
+
index = stack.rindex { |m| m[:method] == signature }
|
|
130
|
+
return unless index
|
|
131
|
+
|
|
132
|
+
stack.delete_at(index)
|
|
166
133
|
end
|
|
167
134
|
|
|
168
|
-
def
|
|
169
|
-
|
|
170
|
-
hash.each do |key, value|
|
|
171
|
-
filtered[key] = filter_sensitive_data(key, value, { context: :hash_value })
|
|
172
|
-
end
|
|
173
|
-
filtered
|
|
135
|
+
def method_signature(tp)
|
|
136
|
+
"#{tp.defined_class}##{tp.method_id}"
|
|
174
137
|
end
|
|
175
138
|
|
|
176
|
-
def
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
139
|
+
def buffer_call_event(correlation_id, event_id, tp)
|
|
140
|
+
buffer_event(
|
|
141
|
+
name: method_signature(tp),
|
|
142
|
+
severity: 'info',
|
|
143
|
+
category: 'method_call',
|
|
144
|
+
payload: {
|
|
145
|
+
event_id: event_id,
|
|
146
|
+
file: tp.path,
|
|
147
|
+
line: tp.lineno,
|
|
148
|
+
class: tp.defined_class.to_s,
|
|
149
|
+
method: tp.method_id.to_s,
|
|
150
|
+
params: capture_params(tp)
|
|
151
|
+
},
|
|
152
|
+
correlation_id: correlation_id,
|
|
153
|
+
occurred_at: Time.current
|
|
154
|
+
)
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def buffer_return_event(correlation_id, method_info, tp)
|
|
158
|
+
buffer_event(
|
|
159
|
+
name: "#{method_signature(tp)}_return",
|
|
160
|
+
severity: 'info',
|
|
161
|
+
category: 'method_return',
|
|
162
|
+
payload: {
|
|
163
|
+
event_id: method_info[:event_id],
|
|
164
|
+
return_value: ValueFilter.filter(:return_value, tp.return_value, { context: :return_value }),
|
|
165
|
+
class: tp.defined_class.to_s,
|
|
166
|
+
method: tp.method_id.to_s
|
|
167
|
+
},
|
|
168
|
+
correlation_id: correlation_id,
|
|
169
|
+
occurred_at: Time.current
|
|
170
|
+
)
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def thread_method_stack
|
|
174
|
+
Thread.current[THREAD_KEY_METHOD_STACK] ||= {}
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def thread_event_buffer
|
|
178
|
+
Thread.current[THREAD_KEY_EVENT_BUFFER] ||= []
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def buffer_event(event)
|
|
182
|
+
thread_event_buffer << event
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def capture_params(tp)
|
|
186
|
+
method = tp.self.method(tp.method_id)
|
|
187
|
+
params = {}
|
|
188
|
+
|
|
189
|
+
method.parameters.each do |_type, name|
|
|
190
|
+
next unless name
|
|
191
|
+
|
|
192
|
+
if tp.binding.local_variable_defined?(name)
|
|
193
|
+
value = tp.binding.local_variable_get(name)
|
|
194
|
+
params[name] = ValueFilter.filter(name, value, { context: :parameter })
|
|
188
195
|
end
|
|
189
|
-
"#{inspect_activerecord(record)} {#{filtered_attrs.map { |k, v| "#{k}: #{v}" }.join(', ')}}"
|
|
190
196
|
end
|
|
197
|
+
|
|
198
|
+
params
|
|
191
199
|
rescue StandardError => e
|
|
192
|
-
"#{
|
|
200
|
+
{ error: "Failed to capture params: #{e.message}" }
|
|
193
201
|
end
|
|
194
202
|
|
|
195
203
|
def determine_correlation_id
|
|
196
204
|
if Thread.current[:request_id]
|
|
197
205
|
Thread.current[:request_id]
|
|
198
|
-
elsif
|
|
199
|
-
|
|
206
|
+
elsif Thread.current[:active_job_id]
|
|
207
|
+
Thread.current[:active_job_id]
|
|
200
208
|
else
|
|
201
209
|
SecureRandom.uuid.tap { |id| CurrentCorrelation.id = id }
|
|
202
210
|
end
|
|
@@ -3,15 +3,18 @@
|
|
|
3
3
|
module EventTimeline
|
|
4
4
|
class Configuration
|
|
5
5
|
attr_accessor :narrator_proc, :watched_paths, :pii_filter_proc, :filtered_attributes,
|
|
6
|
-
:max_events_per_correlation, :max_total_events, :cleanup_threshold, :max_event_age
|
|
6
|
+
:max_events_per_correlation, :max_total_events, :cleanup_threshold, :max_event_age,
|
|
7
|
+
:max_string_length, :max_inspect_length
|
|
7
8
|
|
|
8
9
|
def initialize
|
|
9
10
|
@watched_paths = []
|
|
10
11
|
@filtered_attributes = default_filtered_attributes
|
|
11
|
-
@max_events_per_correlation = 500
|
|
12
|
-
@max_total_events = 10_000
|
|
13
|
-
@cleanup_threshold = 0.8
|
|
14
|
-
@max_event_age = 1.month
|
|
12
|
+
@max_events_per_correlation = 500 # Max events per correlation_id
|
|
13
|
+
@max_total_events = 10_000 # Max total events before cleanup
|
|
14
|
+
@cleanup_threshold = 0.8 # Start cleanup at 80% of max
|
|
15
|
+
@max_event_age = 1.month # Delete events older than this
|
|
16
|
+
@max_string_length = 100 # Truncate strings longer than this
|
|
17
|
+
@max_inspect_length = 200 # Truncate inspected values longer than this
|
|
15
18
|
end
|
|
16
19
|
|
|
17
20
|
def narrator(&block)
|
|
@@ -81,7 +84,11 @@ module EventTimeline
|
|
|
81
84
|
def normalize_path(path)
|
|
82
85
|
path = path.to_s
|
|
83
86
|
path = "#{Rails.root}/#{path}" unless path.start_with?('/')
|
|
84
|
-
|
|
87
|
+
if path.include?('*')
|
|
88
|
+
path
|
|
89
|
+
else
|
|
90
|
+
"#{path.gsub(%r{/$}, '')}/**/*.rb"
|
|
91
|
+
end
|
|
85
92
|
end
|
|
86
93
|
end
|
|
87
94
|
end
|
|
@@ -4,14 +4,12 @@ module EventTimeline
|
|
|
4
4
|
class Engine < ::Rails::Engine
|
|
5
5
|
isolate_namespace EventTimeline
|
|
6
6
|
|
|
7
|
-
initializer '
|
|
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
|
|
@@ -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.
|
|
4
|
+
version: 0.2.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- svn-arv
|
|
8
|
+
autorequire:
|
|
8
9
|
bindir: bin
|
|
9
10
|
cert_chain: []
|
|
10
|
-
date:
|
|
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: []
|