callback_tracer 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/CHANGELOG.md +13 -0
- data/LICENSE.txt +21 -0
- data/README.md +48 -0
- data/lib/callback_tracer/configuration.rb +44 -0
- data/lib/callback_tracer/log_formatter.rb +78 -0
- data/lib/callback_tracer/middleware.rb +15 -0
- data/lib/callback_tracer/railtie.rb +13 -0
- data/lib/callback_tracer/source_locator.rb +59 -0
- data/lib/callback_tracer/tracer.rb +218 -0
- data/lib/callback_tracer/version.rb +3 -0
- data/lib/callback_tracer.rb +72 -0
- data/lib/generators/callback_tracer/install_generator.rb +27 -0
- metadata +119 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: dc2f5bb8dbc7993f062b1a9278293e8927e3003b57ffd8393679e1fa378184e2
|
|
4
|
+
data.tar.gz: 12b226db470343333e74512e8c29ca39437eccdc75821ecd2516b1747a32bf83
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 8758991c2da82f688267e8b0bda7f029be6a74171aaa7b9d8050abd8b9e8dd7008669d40d5d81edabf866330f2844062ed9b25a76e210b61f3653d67137ccf95
|
|
7
|
+
data.tar.gz: fcfb5d5b1202b7183654a1d111c533ce7bd7a03b8a8bc968870051aca8965dfb183bbe880bcefee710e7eb63438cd554c65af6f0f20fb0af4fc9b9bd5c26d018
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.1.0 (2026-03-24)
|
|
4
|
+
|
|
5
|
+
- Initial release
|
|
6
|
+
- Trace all ActiveRecord callback chains (validation, save, create, update, destroy, commit, rollback, initialize, find, touch)
|
|
7
|
+
- Source location and timing for each callback
|
|
8
|
+
- Around callback enter/exit tracing
|
|
9
|
+
- Colorized terminal output
|
|
10
|
+
- Configurable: enable/disable, exclude models, custom logger
|
|
11
|
+
- Rails generator for initializer setup
|
|
12
|
+
- Automatic production safety (disabled in production)
|
|
13
|
+
- Rack middleware for per-request buffer flushing
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Shehab Mohamed
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
|
13
|
+
all copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# CallbackTracer
|
|
2
|
+
|
|
3
|
+
Trace ActiveRecord callback execution order with source locations and timing. See exactly which callbacks fire, in what order, and how long each takes.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
Add to your Gemfile:
|
|
8
|
+
|
|
9
|
+
```ruby
|
|
10
|
+
gem "callback_tracer", group: [:development, :test]
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Run the install generator:
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
rails generate callback_tracer:install
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Configuration
|
|
20
|
+
|
|
21
|
+
```ruby
|
|
22
|
+
# config/initializers/callback_tracer.rb
|
|
23
|
+
CallbackTracer.configure do |config|
|
|
24
|
+
config.enabled = true
|
|
25
|
+
config.excluded_models = ["ApplicationRecord"]
|
|
26
|
+
config.colorize = true
|
|
27
|
+
config.logger = nil # defaults to puts
|
|
28
|
+
end
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## Output
|
|
32
|
+
|
|
33
|
+
```
|
|
34
|
+
[CallbackTracer] Post before_validation (app/models/post.rb:11) 0.02ms
|
|
35
|
+
[CallbackTracer] Post after_validation (app/models/post.rb:12) 0.01ms
|
|
36
|
+
[CallbackTracer] Post before_save (app/models/post.rb:16) 0.03ms
|
|
37
|
+
[CallbackTracer] Post before_create (app/models/post.rb:21) 0.15ms
|
|
38
|
+
[CallbackTracer] Post after_create (app/models/post.rb:23) 0.02ms
|
|
39
|
+
[CallbackTracer] Post after_save (app/models/post.rb:18) 0.01ms
|
|
40
|
+
[CallbackTracer] Post after_commit (app/models/post.rb:31) 0.04ms
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## Requirements
|
|
44
|
+
|
|
45
|
+
- Ruby 3.1+
|
|
46
|
+
- Rails 7.0+
|
|
47
|
+
|
|
48
|
+
Automatically disabled in production.
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
module CallbackTracer
|
|
2
|
+
class Configuration
|
|
3
|
+
attr_reader :enabled, :excluded_models, :colorize, :logger
|
|
4
|
+
|
|
5
|
+
def initialize
|
|
6
|
+
@enabled = false
|
|
7
|
+
@excluded_models = []
|
|
8
|
+
@colorize = true
|
|
9
|
+
@logger = nil
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def enabled=(value)
|
|
13
|
+
@enabled = !!value
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def colorize=(value)
|
|
17
|
+
@colorize = !!value
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def excluded_models=(value)
|
|
21
|
+
raise ArgumentError, "excluded_models must be an Array" unless value.is_a?(Array)
|
|
22
|
+
|
|
23
|
+
@excluded_models = value
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def logger=(value)
|
|
27
|
+
if value && !value.respond_to?(:info)
|
|
28
|
+
raise ArgumentError, "logger must respond to #info"
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
@logger = value
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def excluded?(model_class)
|
|
35
|
+
model_name = model_class.name
|
|
36
|
+
return false unless model_name
|
|
37
|
+
|
|
38
|
+
excluded_models.any? do |name|
|
|
39
|
+
model_name == name ||
|
|
40
|
+
model_class.ancestors.any? { |a| a.name == name && name != "ActiveRecord::Base" }
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
module CallbackTracer
|
|
2
|
+
module LogFormatter
|
|
3
|
+
COLORS = {
|
|
4
|
+
prefix: "\e[36m", # cyan
|
|
5
|
+
model: "\e[33m", # yellow
|
|
6
|
+
callback: "\e[32m", # green
|
|
7
|
+
location: "\e[90m", # gray
|
|
8
|
+
timing: "\e[35m", # magenta
|
|
9
|
+
around: "\e[34m", # blue
|
|
10
|
+
reset: "\e[0m"
|
|
11
|
+
}.freeze
|
|
12
|
+
|
|
13
|
+
# Control characters that could be used for log injection or terminal escape attacks
|
|
14
|
+
CONTROL_CHAR_PATTERN = /[\r\n\e\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/
|
|
15
|
+
|
|
16
|
+
module_function
|
|
17
|
+
|
|
18
|
+
def sanitize(str)
|
|
19
|
+
str.to_s.gsub(CONTROL_CHAR_PATTERN, "")
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def format_callback(model_name:, callback_kind:, callback_type:, location:, duration_ms:, method_name: nil, colorize: true)
|
|
23
|
+
model_name = sanitize(model_name)
|
|
24
|
+
kind_label = sanitize("#{callback_type}_#{callback_kind}")
|
|
25
|
+
kind_label = "#{kind_label} :#{method_name}" if method_name
|
|
26
|
+
loc_str = location ? "(#{sanitize(location)})" : "(unknown)"
|
|
27
|
+
time_str = "%.2fms" % duration_ms
|
|
28
|
+
|
|
29
|
+
if colorize
|
|
30
|
+
"#{c(:prefix)}[CallbackTracer]#{c(:reset)} " \
|
|
31
|
+
"#{c(:model)}#{model_name}#{c(:reset)} " \
|
|
32
|
+
"#{c(:callback)}#{kind_label.ljust(25)}#{c(:reset)} " \
|
|
33
|
+
"#{c(:location)}#{loc_str}#{c(:reset)} " \
|
|
34
|
+
"#{c(:timing)}#{time_str}#{c(:reset)}"
|
|
35
|
+
else
|
|
36
|
+
"[CallbackTracer] #{model_name} #{kind_label.ljust(25)} #{loc_str} #{time_str}"
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def format_around_enter(model_name:, callback_kind:, location:, method_name: nil, colorize: true)
|
|
41
|
+
model_name = sanitize(model_name)
|
|
42
|
+
loc_str = location ? "(#{sanitize(location)})" : "(unknown)"
|
|
43
|
+
label = sanitize("around_#{callback_kind} [enter]")
|
|
44
|
+
label = "#{label} :#{method_name}" if method_name
|
|
45
|
+
|
|
46
|
+
if colorize
|
|
47
|
+
"#{c(:prefix)}[CallbackTracer]#{c(:reset)} " \
|
|
48
|
+
"#{c(:model)}#{model_name}#{c(:reset)} " \
|
|
49
|
+
"#{c(:around)}#{label.ljust(25)}#{c(:reset)} " \
|
|
50
|
+
"#{c(:location)}#{loc_str}#{c(:reset)}"
|
|
51
|
+
else
|
|
52
|
+
"[CallbackTracer] #{model_name} #{label.ljust(25)} #{loc_str}"
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def format_around_exit(model_name:, callback_kind:, location:, duration_ms:, method_name: nil, colorize: true)
|
|
57
|
+
model_name = sanitize(model_name)
|
|
58
|
+
loc_str = location ? "(#{sanitize(location)})" : "(unknown)"
|
|
59
|
+
time_str = "%.2fms" % duration_ms
|
|
60
|
+
label = sanitize("around_#{callback_kind} [exit]")
|
|
61
|
+
label = "#{label} :#{method_name}" if method_name
|
|
62
|
+
|
|
63
|
+
if colorize
|
|
64
|
+
"#{c(:prefix)}[CallbackTracer]#{c(:reset)} " \
|
|
65
|
+
"#{c(:model)}#{model_name}#{c(:reset)} " \
|
|
66
|
+
"#{c(:around)}#{label.ljust(25)}#{c(:reset)} " \
|
|
67
|
+
"#{c(:location)}#{loc_str}#{c(:reset)} " \
|
|
68
|
+
"#{c(:timing)}#{time_str}#{c(:reset)}"
|
|
69
|
+
else
|
|
70
|
+
"[CallbackTracer] #{model_name} #{label.ljust(25)} #{loc_str} #{time_str}"
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def c(name)
|
|
75
|
+
COLORS[name]
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
module CallbackTracer
|
|
2
|
+
class Railtie < Rails::Railtie
|
|
3
|
+
initializer "callback_tracer.setup" do
|
|
4
|
+
ActiveSupport.on_load(:active_record) do
|
|
5
|
+
CallbackTracer.setup! unless Rails.env.production?
|
|
6
|
+
end
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
initializer "callback_tracer.middleware" do |app|
|
|
10
|
+
app.middleware.use CallbackTracer::Middleware unless Rails.env.production?
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
module CallbackTracer
|
|
2
|
+
module SourceLocator
|
|
3
|
+
module_function
|
|
4
|
+
|
|
5
|
+
def locate(filter, model_class)
|
|
6
|
+
location = extract_location(filter, model_class)
|
|
7
|
+
return nil unless location
|
|
8
|
+
|
|
9
|
+
file, line = location
|
|
10
|
+
shorten_path(file, line)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def extract_location(filter, model_class)
|
|
14
|
+
case filter
|
|
15
|
+
when Symbol
|
|
16
|
+
if model_class.method_defined?(filter, false) || model_class.private_method_defined?(filter, false)
|
|
17
|
+
model_class.instance_method(filter).source_location
|
|
18
|
+
elsif model_class.method_defined?(filter) || model_class.private_method_defined?(filter)
|
|
19
|
+
model_class.instance_method(filter).source_location
|
|
20
|
+
end
|
|
21
|
+
when Proc
|
|
22
|
+
filter.source_location
|
|
23
|
+
when Object
|
|
24
|
+
# Callable object — try common callback method names
|
|
25
|
+
[:before, :after, :around, :call].each do |m|
|
|
26
|
+
return filter.method(m).source_location if filter.respond_to?(m)
|
|
27
|
+
end
|
|
28
|
+
nil
|
|
29
|
+
end
|
|
30
|
+
rescue NameError, TypeError
|
|
31
|
+
nil
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Matches bundled gem paths like /gems/activerecord-7.1.0/ or /gems/railties-7.1.0/
|
|
35
|
+
BUNDLED_GEM_PATH_PATTERN = %r{/gems/[a-zA-Z0-9_-]+-\d+}
|
|
36
|
+
|
|
37
|
+
def framework_source?(filter, model_class)
|
|
38
|
+
location = extract_location(filter, model_class)
|
|
39
|
+
return false unless location
|
|
40
|
+
|
|
41
|
+
file = location[0].to_s
|
|
42
|
+
return true if file.match?(BUNDLED_GEM_PATH_PATTERN)
|
|
43
|
+
return true if defined?(Rails) && Rails.respond_to?(:root) && Rails.root && !file.start_with?(Rails.root.to_s)
|
|
44
|
+
|
|
45
|
+
false
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def shorten_path(file, line)
|
|
49
|
+
shortened = if defined?(Rails) && Rails.respond_to?(:root) && Rails.root
|
|
50
|
+
file.sub("#{Rails.root}/", "")
|
|
51
|
+
elsif defined?(Bundler) && Bundler.respond_to?(:root)
|
|
52
|
+
file.sub("#{Bundler.root}/", "")
|
|
53
|
+
else
|
|
54
|
+
File.basename(File.dirname(file)) + "/" + File.basename(file)
|
|
55
|
+
end
|
|
56
|
+
"#{shortened}:#{line}"
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
require "logger"
|
|
2
|
+
require "monitor"
|
|
3
|
+
|
|
4
|
+
module CallbackTracer
|
|
5
|
+
module Tracer
|
|
6
|
+
TRACED_KINDS = %i[
|
|
7
|
+
validation save create update destroy
|
|
8
|
+
commit rollback initialize find touch
|
|
9
|
+
].freeze
|
|
10
|
+
|
|
11
|
+
TRACE_MONITOR = Monitor.new
|
|
12
|
+
|
|
13
|
+
def run_callbacks(kind, &block)
|
|
14
|
+
unless CallbackTracer.enabled? &&
|
|
15
|
+
TRACED_KINDS.include?(kind) &&
|
|
16
|
+
!CallbackTracer.configuration.excluded?(self.class)
|
|
17
|
+
return super
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
callbacks = self.class.__callbacks[kind]
|
|
21
|
+
return super unless callbacks
|
|
22
|
+
|
|
23
|
+
chain = callbacks.send(:chain).dup
|
|
24
|
+
colorize = CallbackTracer.configuration.colorize
|
|
25
|
+
model_name = LogFormatter.sanitize(self.class.name.to_s)
|
|
26
|
+
logger = CallbackTracer.configuration.logger
|
|
27
|
+
|
|
28
|
+
# Build wrappers for each callback in the chain, skipping framework-internal ones
|
|
29
|
+
wrappers = chain.filter_map do |cb|
|
|
30
|
+
filter = cb.filter
|
|
31
|
+
next if SourceLocator.framework_source?(filter, self.class)
|
|
32
|
+
|
|
33
|
+
location = SourceLocator.locate(filter, self.class)
|
|
34
|
+
cb_kind = cb.kind # :before, :after, :around
|
|
35
|
+
method_name = filter.is_a?(Symbol) ? filter : nil
|
|
36
|
+
|
|
37
|
+
{ callback: cb, filter: filter, location: location, cb_kind: cb_kind, method_name: method_name }
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Install temporary method wrappers to trace individual callbacks
|
|
41
|
+
install_method_wrappers(wrappers, model_name, kind, colorize, logger)
|
|
42
|
+
|
|
43
|
+
# For proc filters, wrap them thread-safely without mutating shared state
|
|
44
|
+
trace_proc_filters(wrappers, model_name, kind, colorize, logger) do
|
|
45
|
+
super(kind, &block)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
def install_method_wrappers(wrappers, model_name, callback_kind, colorize, logger)
|
|
52
|
+
# Track which methods we've already wrapped on this instance to avoid accumulation
|
|
53
|
+
@_callback_tracer_wrapped ||= Set.new
|
|
54
|
+
|
|
55
|
+
wrappers.each do |w|
|
|
56
|
+
filter = w[:filter]
|
|
57
|
+
next unless filter.is_a?(Symbol)
|
|
58
|
+
next if @_callback_tracer_wrapped.include?(filter)
|
|
59
|
+
next unless self.class.method_defined?(filter, false) || self.class.private_method_defined?(filter, false)
|
|
60
|
+
|
|
61
|
+
location = w[:location]
|
|
62
|
+
cb_kind = w[:cb_kind]
|
|
63
|
+
method_name = w[:method_name]
|
|
64
|
+
|
|
65
|
+
wrapper_module = Module.new do
|
|
66
|
+
if cb_kind == :around
|
|
67
|
+
define_method(filter) do |*args, &blk|
|
|
68
|
+
output_enter = LogFormatter.format_around_enter(
|
|
69
|
+
model_name: model_name,
|
|
70
|
+
callback_kind: callback_kind,
|
|
71
|
+
location: location,
|
|
72
|
+
method_name: method_name,
|
|
73
|
+
colorize: colorize
|
|
74
|
+
)
|
|
75
|
+
log_trace(output_enter, logger)
|
|
76
|
+
|
|
77
|
+
start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
78
|
+
result = super(*args, &blk)
|
|
79
|
+
duration = (Process.clock_gettime(Process::CLOCK_MONOTONIC) - start) * 1000
|
|
80
|
+
|
|
81
|
+
output_exit = LogFormatter.format_around_exit(
|
|
82
|
+
model_name: model_name,
|
|
83
|
+
callback_kind: callback_kind,
|
|
84
|
+
location: location,
|
|
85
|
+
method_name: method_name,
|
|
86
|
+
duration_ms: duration,
|
|
87
|
+
colorize: colorize
|
|
88
|
+
)
|
|
89
|
+
log_trace(output_exit, logger)
|
|
90
|
+
result
|
|
91
|
+
end
|
|
92
|
+
else
|
|
93
|
+
define_method(filter) do |*args, &blk|
|
|
94
|
+
start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
95
|
+
result = super(*args, &blk)
|
|
96
|
+
duration = (Process.clock_gettime(Process::CLOCK_MONOTONIC) - start) * 1000
|
|
97
|
+
|
|
98
|
+
output = LogFormatter.format_callback(
|
|
99
|
+
model_name: model_name,
|
|
100
|
+
callback_kind: callback_kind,
|
|
101
|
+
callback_type: cb_kind,
|
|
102
|
+
location: location,
|
|
103
|
+
method_name: method_name,
|
|
104
|
+
duration_ms: duration,
|
|
105
|
+
colorize: colorize
|
|
106
|
+
)
|
|
107
|
+
log_trace(output, logger)
|
|
108
|
+
result
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
self.singleton_class.prepend(wrapper_module)
|
|
114
|
+
@_callback_tracer_wrapped.add(filter)
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def trace_proc_filters(wrappers, model_name, callback_kind, colorize, logger)
|
|
119
|
+
proc_wrappers = wrappers.select { |w| w[:filter].is_a?(Proc) }
|
|
120
|
+
|
|
121
|
+
if proc_wrappers.empty?
|
|
122
|
+
return yield
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Thread-safe approach: duplicate the callback objects before mutating,
|
|
126
|
+
# then swap the entire chain under a mutex so no shared state is modified.
|
|
127
|
+
callbacks_set = self.class.__callbacks[callback_kind]
|
|
128
|
+
original_chain = callbacks_set.send(:chain)
|
|
129
|
+
|
|
130
|
+
# Deep-copy the callback objects we need to wrap
|
|
131
|
+
cloned_cbs = {}
|
|
132
|
+
proc_wrappers.each do |w|
|
|
133
|
+
cb = w[:callback]
|
|
134
|
+
cloned = cb.dup
|
|
135
|
+
cloned_cbs[cb.object_id] = { original: cb, clone: cloned }
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# Build wrapped procs on the cloned copies (no shared state mutation)
|
|
139
|
+
cloned_cbs.each_value do |entry|
|
|
140
|
+
cloned_cb = entry[:clone]
|
|
141
|
+
w = proc_wrappers.find { |pw| pw[:callback].object_id == entry[:original].object_id }
|
|
142
|
+
filter = w[:filter]
|
|
143
|
+
location = w[:location]
|
|
144
|
+
cb_kind = w[:cb_kind]
|
|
145
|
+
|
|
146
|
+
wrapped_proc = build_wrapped_proc(filter, cb_kind, model_name, callback_kind, location, colorize, logger)
|
|
147
|
+
cloned_cb.instance_variable_set(:@filter, wrapped_proc)
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# Hold mutex for the entire swap-yield-restore cycle to prevent
|
|
151
|
+
# TOCTOU race conditions between concurrent threads.
|
|
152
|
+
TRACE_MONITOR.synchronize do
|
|
153
|
+
cloned_cbs.each_value do |entry|
|
|
154
|
+
idx = original_chain.index(entry[:original])
|
|
155
|
+
original_chain[idx] = entry[:clone] if idx
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
begin
|
|
159
|
+
yield
|
|
160
|
+
ensure
|
|
161
|
+
cloned_cbs.each_value do |entry|
|
|
162
|
+
idx = original_chain.index(entry[:clone])
|
|
163
|
+
original_chain[idx] = entry[:original] if idx
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def build_wrapped_proc(filter, cb_kind, model_name, callback_kind, location, colorize, logger)
|
|
170
|
+
if cb_kind == :around
|
|
171
|
+
proc do |record, &blk|
|
|
172
|
+
output_enter = LogFormatter.format_around_enter(
|
|
173
|
+
model_name: model_name,
|
|
174
|
+
callback_kind: callback_kind,
|
|
175
|
+
location: location,
|
|
176
|
+
colorize: colorize
|
|
177
|
+
)
|
|
178
|
+
record.send(:log_trace, output_enter, logger)
|
|
179
|
+
|
|
180
|
+
start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
181
|
+
result = filter.call(record, &blk)
|
|
182
|
+
duration = (Process.clock_gettime(Process::CLOCK_MONOTONIC) - start) * 1000
|
|
183
|
+
|
|
184
|
+
output_exit = LogFormatter.format_around_exit(
|
|
185
|
+
model_name: model_name,
|
|
186
|
+
callback_kind: callback_kind,
|
|
187
|
+
location: location,
|
|
188
|
+
duration_ms: duration,
|
|
189
|
+
colorize: colorize
|
|
190
|
+
)
|
|
191
|
+
record.send(:log_trace, output_exit, logger)
|
|
192
|
+
result
|
|
193
|
+
end
|
|
194
|
+
else
|
|
195
|
+
proc do |record, &blk|
|
|
196
|
+
start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
197
|
+
result = record.instance_exec(&filter)
|
|
198
|
+
duration = (Process.clock_gettime(Process::CLOCK_MONOTONIC) - start) * 1000
|
|
199
|
+
|
|
200
|
+
output = LogFormatter.format_callback(
|
|
201
|
+
model_name: model_name,
|
|
202
|
+
callback_kind: callback_kind,
|
|
203
|
+
callback_type: cb_kind,
|
|
204
|
+
location: location,
|
|
205
|
+
duration_ms: duration,
|
|
206
|
+
colorize: colorize
|
|
207
|
+
)
|
|
208
|
+
record.send(:log_trace, output, logger)
|
|
209
|
+
result
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def log_trace(message, _logger = nil)
|
|
215
|
+
CallbackTracer.buffer_message(message)
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
end
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
require "active_support"
|
|
2
|
+
require "active_record"
|
|
3
|
+
|
|
4
|
+
require_relative "callback_tracer/version"
|
|
5
|
+
require_relative "callback_tracer/configuration"
|
|
6
|
+
require_relative "callback_tracer/source_locator"
|
|
7
|
+
require_relative "callback_tracer/log_formatter"
|
|
8
|
+
require_relative "callback_tracer/tracer"
|
|
9
|
+
require_relative "callback_tracer/middleware"
|
|
10
|
+
require_relative "callback_tracer/railtie" if defined?(Rails::Railtie)
|
|
11
|
+
|
|
12
|
+
module CallbackTracer
|
|
13
|
+
SETUP_MUTEX = Mutex.new
|
|
14
|
+
|
|
15
|
+
class << self
|
|
16
|
+
def configuration
|
|
17
|
+
@configuration ||= Configuration.new
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def configure
|
|
21
|
+
yield(configuration)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def setup!
|
|
25
|
+
SETUP_MUTEX.synchronize do
|
|
26
|
+
return if @setup_done
|
|
27
|
+
|
|
28
|
+
if defined?(Rails) && Rails.respond_to?(:env) && Rails.env.production?
|
|
29
|
+
warn "[CallbackTracer] WARNING: CallbackTracer.setup! called in production. Tracing is disabled in production by default."
|
|
30
|
+
return
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
ActiveRecord::Base.prepend(Tracer)
|
|
34
|
+
@setup_done = true
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def reset!
|
|
39
|
+
SETUP_MUTEX.synchronize do
|
|
40
|
+
@configuration = Configuration.new
|
|
41
|
+
@setup_done = false
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def enabled?
|
|
46
|
+
configuration.enabled
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def buffer
|
|
50
|
+
Thread.current[:callback_tracer_buffer] ||= []
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def buffer_message(message)
|
|
54
|
+
buffer << message
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def clear_buffer!
|
|
58
|
+
Thread.current[:callback_tracer_buffer] = []
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def flush_buffer!
|
|
62
|
+
messages = buffer
|
|
63
|
+
return if messages.empty?
|
|
64
|
+
|
|
65
|
+
logger = configuration.logger
|
|
66
|
+
output_logger = logger || Logger.new($stdout, formatter: proc { |_, _, _, msg| "#{msg}\n" })
|
|
67
|
+
messages.each { |msg| output_logger.info(msg) }
|
|
68
|
+
ensure
|
|
69
|
+
clear_buffer!
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
require "rails/generators"
|
|
2
|
+
|
|
3
|
+
module CallbackTracer
|
|
4
|
+
module Generators
|
|
5
|
+
class InstallGenerator < Rails::Generators::Base
|
|
6
|
+
desc "Creates a CallbackTracer initializer file"
|
|
7
|
+
|
|
8
|
+
def create_initializer
|
|
9
|
+
create_file "config/initializers/callback_tracer.rb", <<~RUBY
|
|
10
|
+
CallbackTracer.configure do |config|
|
|
11
|
+
# Enable or disable tracing (automatically disabled in production)
|
|
12
|
+
# config.enabled = true
|
|
13
|
+
|
|
14
|
+
# Models to exclude from tracing
|
|
15
|
+
# config.excluded_models = ["ApplicationRecord", "ActiveRecord::SchemaMigration"]
|
|
16
|
+
|
|
17
|
+
# Enable colorized output
|
|
18
|
+
# config.colorize = true
|
|
19
|
+
|
|
20
|
+
# Custom logger (defaults to puts)
|
|
21
|
+
# config.logger = Rails.logger
|
|
22
|
+
end
|
|
23
|
+
RUBY
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: callback_tracer
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Shehab Mohamed
|
|
8
|
+
autorequire:
|
|
9
|
+
bindir: bin
|
|
10
|
+
cert_chain: []
|
|
11
|
+
date: 2026-03-24 00:00:00.000000000 Z
|
|
12
|
+
dependencies:
|
|
13
|
+
- !ruby/object:Gem::Dependency
|
|
14
|
+
name: activerecord
|
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
|
16
|
+
requirements:
|
|
17
|
+
- - ">="
|
|
18
|
+
- !ruby/object:Gem::Version
|
|
19
|
+
version: '7.0'
|
|
20
|
+
- - "<"
|
|
21
|
+
- !ruby/object:Gem::Version
|
|
22
|
+
version: '9'
|
|
23
|
+
type: :runtime
|
|
24
|
+
prerelease: false
|
|
25
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
26
|
+
requirements:
|
|
27
|
+
- - ">="
|
|
28
|
+
- !ruby/object:Gem::Version
|
|
29
|
+
version: '7.0'
|
|
30
|
+
- - "<"
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: '9'
|
|
33
|
+
- !ruby/object:Gem::Dependency
|
|
34
|
+
name: activesupport
|
|
35
|
+
requirement: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - ">="
|
|
38
|
+
- !ruby/object:Gem::Version
|
|
39
|
+
version: '7.0'
|
|
40
|
+
- - "<"
|
|
41
|
+
- !ruby/object:Gem::Version
|
|
42
|
+
version: '9'
|
|
43
|
+
type: :runtime
|
|
44
|
+
prerelease: false
|
|
45
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
46
|
+
requirements:
|
|
47
|
+
- - ">="
|
|
48
|
+
- !ruby/object:Gem::Version
|
|
49
|
+
version: '7.0'
|
|
50
|
+
- - "<"
|
|
51
|
+
- !ruby/object:Gem::Version
|
|
52
|
+
version: '9'
|
|
53
|
+
- !ruby/object:Gem::Dependency
|
|
54
|
+
name: railties
|
|
55
|
+
requirement: !ruby/object:Gem::Requirement
|
|
56
|
+
requirements:
|
|
57
|
+
- - ">="
|
|
58
|
+
- !ruby/object:Gem::Version
|
|
59
|
+
version: '7.0'
|
|
60
|
+
- - "<"
|
|
61
|
+
- !ruby/object:Gem::Version
|
|
62
|
+
version: '9'
|
|
63
|
+
type: :runtime
|
|
64
|
+
prerelease: false
|
|
65
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
66
|
+
requirements:
|
|
67
|
+
- - ">="
|
|
68
|
+
- !ruby/object:Gem::Version
|
|
69
|
+
version: '7.0'
|
|
70
|
+
- - "<"
|
|
71
|
+
- !ruby/object:Gem::Version
|
|
72
|
+
version: '9'
|
|
73
|
+
description: Instruments all ActiveRecord callbacks and prints their execution order,
|
|
74
|
+
source location, and timing to the terminal during development and test.
|
|
75
|
+
email:
|
|
76
|
+
- shehab.mohamed2104@gmail.com
|
|
77
|
+
executables: []
|
|
78
|
+
extensions: []
|
|
79
|
+
extra_rdoc_files: []
|
|
80
|
+
files:
|
|
81
|
+
- CHANGELOG.md
|
|
82
|
+
- LICENSE.txt
|
|
83
|
+
- README.md
|
|
84
|
+
- lib/callback_tracer.rb
|
|
85
|
+
- lib/callback_tracer/configuration.rb
|
|
86
|
+
- lib/callback_tracer/log_formatter.rb
|
|
87
|
+
- lib/callback_tracer/middleware.rb
|
|
88
|
+
- lib/callback_tracer/railtie.rb
|
|
89
|
+
- lib/callback_tracer/source_locator.rb
|
|
90
|
+
- lib/callback_tracer/tracer.rb
|
|
91
|
+
- lib/callback_tracer/version.rb
|
|
92
|
+
- lib/generators/callback_tracer/install_generator.rb
|
|
93
|
+
homepage: https://github.com/ShehabMohamed21/callback_tracer_gem
|
|
94
|
+
licenses:
|
|
95
|
+
- MIT
|
|
96
|
+
metadata:
|
|
97
|
+
homepage_uri: https://github.com/ShehabMohamed21/callback_tracer_gem
|
|
98
|
+
source_code_uri: https://github.com/ShehabMohamed21/callback_tracer_gem
|
|
99
|
+
changelog_uri: https://github.com/ShehabMohamed21/callback_tracer_gem/blob/main/CHANGELOG.md
|
|
100
|
+
post_install_message:
|
|
101
|
+
rdoc_options: []
|
|
102
|
+
require_paths:
|
|
103
|
+
- lib
|
|
104
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
105
|
+
requirements:
|
|
106
|
+
- - ">="
|
|
107
|
+
- !ruby/object:Gem::Version
|
|
108
|
+
version: 3.1.0
|
|
109
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
110
|
+
requirements:
|
|
111
|
+
- - ">="
|
|
112
|
+
- !ruby/object:Gem::Version
|
|
113
|
+
version: '0'
|
|
114
|
+
requirements: []
|
|
115
|
+
rubygems_version: 3.4.19
|
|
116
|
+
signing_key:
|
|
117
|
+
specification_version: 4
|
|
118
|
+
summary: Trace ActiveRecord callback execution order with source locations and timing
|
|
119
|
+
test_files: []
|