request_trail 0.2.0 → 0.4.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 170ab890527afbaf7d18005ceca802f6a561795724cce97e1f9740f100dcc1ea
4
- data.tar.gz: 91e793b4cf8bf1b086ac2908177bd618e8aabd71a37f239e45b115c9f986290e
3
+ metadata.gz: ad0751f86ff717630ea62f478707c758fc9d45540721c54016a55e19c86030be
4
+ data.tar.gz: 6192f331657a761ee7d537c29ded481629cfedfb1da74eb819e9181677010036
5
5
  SHA512:
6
- metadata.gz: 59cceaa66b3ff236a3a9bab1637f78db87455c5beb1e8687bc89f459053803fd12b52131862923b1d26561cb6c349dee2c970e0cdd421216445758cd1ea6fc21
7
- data.tar.gz: e8969dd6df420ff10c3e3a2879ace0a187797633e98764ddf152b28e1d435a4a0099825afb8af64859d8dd1eff6d9403251e9b88c4f249251efe12995f1dc0af
6
+ metadata.gz: 45d23c714ab2cebe3d5d33db4e8832301e69e496d3cd27419c370eef734bf8581c7d9f815485ab595acf7ce95d9c82c07d580eca805386526edbcd21bfffc74b
7
+ data.tar.gz: da3154bce7516976fdbff6437b877d639cf9b235a3308cb7101462d63e368b5b64530b932f101975f785cbb18365448e0ef18fbad84c2a5e2d8a3c9db126249c
data/CHANGELOG.md CHANGED
@@ -1,5 +1,26 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.4.0] - 2026-06-12
4
+
5
+ ### Added
6
+
7
+ - `RequestTrail::Formatters::FlameGraph` — opt-in ASCII flame-graph formatter with proportional `█` bars and per-layer ANSI colour (auto-detected via TTY)
8
+ - Opt in via `config.formatter = RequestTrail::Formatters::FlameGraph.new`
9
+
10
+ ## [0.3.0] - 2026-06-12
11
+
12
+ ### Added
13
+
14
+ - Controller and view tracing via `process_action.action_controller` — records total controller duration and view runtime per request
15
+ - Formatter switches to tiered multi-line output when controller data is present:
16
+ ```
17
+ [RequestTrail] GET /orders 142ms
18
+ controller 104ms
19
+ sql 38ms (7 queries)
20
+ cache 2ms (4 hits, 1 miss)
21
+ view 22ms
22
+ ```
23
+
3
24
  ## [0.2.0] - 2026-06-11
4
25
 
5
26
  ### Added
@@ -19,6 +40,8 @@
19
40
  - `RequestTrail::Subscriber` — attach/detach API for notification subscriptions
20
41
  - `RequestTrail::Collector` — thread-safe per-request event accumulator
21
42
 
22
- [Unreleased]: https://github.com/eclectic-coding/request-trail/compare/v0.2.0...HEAD
43
+ [Unreleased]: https://github.com/eclectic-coding/request-trail/compare/v0.4.0...HEAD
44
+ [0.4.0]: https://github.com/eclectic-coding/request-trail/releases/tag/v0.4.0
45
+ [0.3.0]: https://github.com/eclectic-coding/request-trail/releases/tag/v0.3.0
23
46
  [0.2.0]: https://github.com/eclectic-coding/request-trail/releases/tag/v0.2.0
24
47
  [0.1.0]: https://github.com/eclectic-coding/request-trail/releases/tag/v0.1.0
data/README.md CHANGED
@@ -36,16 +36,45 @@ gem install request_trail
36
36
 
37
37
  ### Rails
38
38
 
39
- RequestTrail auto-inserts itself via a Railtie. No manual middleware configuration is needed — just add the gem to your `Gemfile` and it will log a summary after every request:
39
+ RequestTrail auto-inserts itself via a Railtie. No manual middleware configuration is needed — just add the gem to your `Gemfile` and it will log a summary after every request.
40
+
41
+ When controller tracing is active, output is tiered:
42
+
43
+ ```
44
+ [RequestTrail] GET /orders 142ms
45
+ controller 104ms
46
+ sql 38ms (7 queries)
47
+ cache 2ms (4 hits, 1 miss)
48
+ view 22ms
49
+ ```
50
+
51
+ Without controller data (plain Rack apps), a single-line summary is emitted:
40
52
 
41
53
  ```
42
54
  [RequestTrail] GET /orders 142ms | SQL: 7/38.3ms | Cache: 4 hits, 1 miss, 2.0ms
43
55
  ```
44
56
 
45
- The summary line shows:
46
- - **Total request time** in milliseconds
47
- - **SQL** query count and cumulative Active Record time
48
- - **Cache** — hit/miss/write counts and cumulative cache time
57
+ ### Flame graph formatter
58
+
59
+ Opt into the ASCII flame-graph formatter for a visual proportional breakdown:
60
+
61
+ ```ruby
62
+ RequestTrail.configure do |config|
63
+ config.formatter = RequestTrail::Formatters::FlameGraph.new
64
+ end
65
+ ```
66
+
67
+ Output (with ANSI colour when stdout is a TTY):
68
+
69
+ ```
70
+ [RequestTrail] GET /orders 142ms ████████████████████████████████████
71
+ controller 104ms ████████████████████████████
72
+ sql 38ms █████████
73
+ cache 2ms
74
+ view 22ms █████
75
+ ```
76
+
77
+ Colour scheme: controller = blue, sql = yellow, cache = green, view = magenta. Plain bars are emitted when stdout is not a TTY (e.g. log files, CI).
49
78
 
50
79
  ### Configuration
51
80
 
@@ -58,6 +87,7 @@ RequestTrail.configure do |config|
58
87
  config.log_level = :info # Rails logger level (:debug, :info, :warn)
59
88
  config.threshold_ms = 200 # only log requests slower than this (0 = log all)
60
89
  config.logger = nil # defaults to Rails.logger
90
+ config.formatter = RequestTrail::Formatters::FlameGraph.new # optional
61
91
  end
62
92
  ```
63
93
 
@@ -78,9 +108,42 @@ run MyApp
78
108
 
79
109
  ## Development
80
110
 
81
- After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
111
+ After checking out the repo, run `bin/setup` to install dependencies. Then, run `bundle exec rake` to run the full CI suite (audit + lint + tests). You can also run `bin/console` for an interactive prompt.
112
+
113
+ ### Running tests
82
114
 
83
- To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org).
115
+ ```bash
116
+ bundle exec rake spec # full test suite
117
+ bundle exec rspec spec/path/to/file_spec.rb # single file
118
+ bundle exec rspec spec/path/to/file_spec.rb:42 # single example
119
+ ```
120
+
121
+ ### Dummy app
122
+
123
+ A minimal Rails app lives in `spec/dummy` for manual end-to-end testing. It mounts a single `GET /ping` endpoint and logs RequestTrail output to `spec/dummy/log/request_trail.log`.
124
+
125
+ Start the server:
126
+
127
+ ```bash
128
+ bundle exec rackup spec/dummy/config.ru --port 3000
129
+ ```
130
+
131
+ Then make a request and tail the log:
132
+
133
+ ```bash
134
+ curl http://localhost:3000/ping
135
+ tail -f spec/dummy/log/request_trail.log
136
+ ```
137
+
138
+ You should see tiered output like:
139
+
140
+ ```
141
+ [RequestTrail] GET /ping 33ms
142
+ controller 3ms
143
+ sql 0.0ms (0 queries)
144
+ cache 0.0ms (0 hits, 0 misses)
145
+ view 2.8ms
146
+ ```
84
147
 
85
148
  [Back to top](#requesttrail)
86
149
 
data/ROADMAP.md CHANGED
@@ -2,38 +2,14 @@
2
2
 
3
3
  `request_trail` traces a Rails request through every processing layer — middleware, controller, ActiveRecord, cache — and emits a flame-graph-style summary to the log. This roadmap describes the incremental path to a stable 1.0.0.
4
4
 
5
- ## 0.3.0 — Controller & View Tracing
6
-
7
- - Subscribe to `process_action.action_controller` and `render_template.action_view`
8
- - Tiered multi-line breakdown showing time spent in each layer:
9
- ```
10
- [RequestTrail] GET /orders 142ms
11
- controller 104ms
12
- sql 38ms (7 queries)
13
- cache 2ms (4 hits, 1 miss)
14
- view 22ms
15
- ```
16
-
17
- ## 0.4.0 — Flame Graph Output
18
-
19
- - Indented ASCII flame-graph renderer with proportional timing bars
20
- - Optional ANSI colour with automatic TTY detection
21
- - Ships as `Request::Trail::Formatters::FlameGraph` alongside the existing plain-text formatter:
22
- ```
23
- [RequestTrail] GET /orders 142ms ████████████████████████████████████
24
- middleware 4ms █
25
- controller 100ms ████████████████████████
26
- sql 38ms █████████
27
- cache 2ms
28
- view 22ms █████
29
- ```
30
-
31
5
  ## 0.5.0 — Filtering & Sampling
32
6
 
33
7
  - Path filters: skip tracing for `/assets`, `/health`, or custom regex patterns
34
8
  - Slow-request mode: only emit summaries above `threshold_ms`
35
9
  - Sampling: trace only N% of requests (useful in production)
36
10
  - Custom formatter API: `config.formatter = MyFormatter`
11
+ - FlameGraph colour overrides: `FlameGraph.new(colorize: true, colors: { controller: "\e[36m" })`
12
+ - Rails generator to scaffold the config initializer (`rails generate request_trail:install`)
37
13
 
38
14
  ## 0.6.0 — Structured Output & Integrations
39
15
 
@@ -5,7 +5,8 @@ module RequestTrail
5
5
  THREAD_KEY = :request_trail_collector
6
6
 
7
7
  attr_reader :sql_count, :sql_duration_ms,
8
- :cache_hits, :cache_misses, :cache_writes, :cache_duration_ms
8
+ :cache_hits, :cache_misses, :cache_writes, :cache_duration_ms,
9
+ :action_duration_ms, :view_duration_ms
9
10
 
10
11
  def self.current
11
12
  Thread.current[THREAD_KEY]
@@ -27,6 +28,8 @@ module RequestTrail
27
28
  @cache_misses = 0
28
29
  @cache_writes = 0
29
30
  @cache_duration_ms = 0.0
31
+ @action_duration_ms = 0.0
32
+ @view_duration_ms = 0.0
30
33
  end
31
34
 
32
35
  def record_sql(duration_ms)
@@ -44,6 +47,11 @@ module RequestTrail
44
47
  @cache_duration_ms += duration_ms
45
48
  end
46
49
 
50
+ def record_action(duration_ms:, view_duration_ms:)
51
+ @action_duration_ms = duration_ms
52
+ @view_duration_ms = view_duration_ms
53
+ end
54
+
47
55
  def elapsed_ms
48
56
  elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - @started_at
49
57
  (elapsed * 1000).round(2)
@@ -4,7 +4,7 @@ require "logger"
4
4
 
5
5
  module RequestTrail
6
6
  class Configuration
7
- attr_writer :logger
7
+ attr_writer :logger, :formatter
8
8
  attr_accessor :enabled, :log_level, :threshold_ms
9
9
 
10
10
  def initialize
@@ -17,6 +17,10 @@ module RequestTrail
17
17
  @logger ||= rails_logger || Logger.new($stdout)
18
18
  end
19
19
 
20
+ def formatter
21
+ @formatter ||= RequestTrail::Formatter.new
22
+ end
23
+
20
24
  private
21
25
 
22
26
  def rails_logger
@@ -3,12 +3,24 @@
3
3
  module RequestTrail
4
4
  class Formatter
5
5
  def format(request, collector)
6
- header = "[RequestTrail] #{request.request_method} #{request.path}"
7
- "#{header} #{collector.elapsed_ms.round}ms | #{sql_summary(collector)} | #{cache_summary(collector)}"
6
+ header = "[RequestTrail] #{request.request_method} #{request.path} #{collector.elapsed_ms.round}ms"
7
+ return tiered_format(header, collector) if collector.action_duration_ms.positive?
8
+
9
+ "#{header} | #{sql_summary(collector)} | #{cache_summary(collector)}"
8
10
  end
9
11
 
10
12
  private
11
13
 
14
+ def tiered_format(header, collector)
15
+ [
16
+ header,
17
+ " controller #{collector.action_duration_ms.round}ms",
18
+ " sql #{collector.sql_duration_ms.round(1)}ms (#{sql_count_label(collector)})",
19
+ " cache #{collector.cache_duration_ms.round(1)}ms (#{cache_detail(collector)})",
20
+ " view #{collector.view_duration_ms.round(1)}ms"
21
+ ].join("\n")
22
+ end
23
+
12
24
  def sql_summary(collector)
13
25
  "SQL: #{collector.sql_count}/#{collector.sql_duration_ms.round(1)}ms"
14
26
  end
@@ -19,5 +31,15 @@ module RequestTrail
19
31
  duration = collector.cache_duration_ms.round(1)
20
32
  "Cache: #{collector.cache_hits} #{hit_label}, #{collector.cache_misses} #{miss_label}, #{duration}ms"
21
33
  end
34
+
35
+ def sql_count_label(collector)
36
+ collector.sql_count == 1 ? "1 query" : "#{collector.sql_count} queries"
37
+ end
38
+
39
+ def cache_detail(collector)
40
+ hit_label = collector.cache_hits == 1 ? "hit" : "hits"
41
+ miss_label = collector.cache_misses == 1 ? "miss" : "misses"
42
+ "#{collector.cache_hits} #{hit_label}, #{collector.cache_misses} #{miss_label}"
43
+ end
22
44
  end
23
45
  end
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RequestTrail
4
+ module Formatters
5
+ class FlameGraph
6
+ BAR_WIDTH = 36
7
+ BAR_CHAR = "█"
8
+
9
+ COLORS = {
10
+ header: "\e[1m",
11
+ controller: "\e[34m",
12
+ sql: "\e[33m",
13
+ cache: "\e[32m",
14
+ view: "\e[35m"
15
+ }.freeze
16
+ RESET = "\e[0m"
17
+
18
+ def initialize(colorize: false)
19
+ @colorize = colorize
20
+ end
21
+
22
+ def format(request, collector)
23
+ total = collector.elapsed_ms.to_f
24
+ lines = [header_line(request, collector)] + detail_rows(collector, total)
25
+ lines.join("\n")
26
+ end
27
+
28
+ private
29
+
30
+ def detail_rows(collector, total)
31
+ return tiered_rows(collector, total) if collector.action_duration_ms.positive?
32
+
33
+ flat_rows(collector, total)
34
+ end
35
+
36
+ def tiered_rows(collector, total)
37
+ [
38
+ row(" ", "controller", collector.action_duration_ms, total, :controller),
39
+ row(" ", "sql", collector.sql_duration_ms, total, :sql),
40
+ row(" ", "cache", collector.cache_duration_ms, total, :cache),
41
+ row(" ", "view", collector.view_duration_ms, total, :view)
42
+ ]
43
+ end
44
+
45
+ def flat_rows(collector, total)
46
+ [
47
+ row(" ", "sql", collector.sql_duration_ms, total, :sql),
48
+ row(" ", "cache", collector.cache_duration_ms, total, :cache)
49
+ ]
50
+ end
51
+
52
+ def header_line(request, collector)
53
+ elapsed = collector.elapsed_ms.round
54
+ bar = BAR_CHAR * BAR_WIDTH
55
+ line = "[RequestTrail] #{request.request_method} #{request.path} #{elapsed}ms #{bar}"
56
+ return line unless colorize?
57
+
58
+ "#{COLORS[:header]}#{line}#{RESET}"
59
+ end
60
+
61
+ def row(indent, label, duration_ms, total_ms, color_key)
62
+ ms = duration_ms.round
63
+ bar = colorized_bar(duration_ms, total_ms, color_key)
64
+ "#{indent}#{label.ljust(11)}#{ms.to_s.rjust(4)}ms #{bar}"
65
+ end
66
+
67
+ def colorized_bar(duration_ms, total_ms, color_key)
68
+ width = total_ms.positive? ? ((duration_ms.to_f / total_ms) * BAR_WIDTH).round : 0
69
+ bar = BAR_CHAR * width
70
+ return bar unless colorize? && width.positive?
71
+
72
+ "#{COLORS[color_key]}#{bar}#{RESET}"
73
+ end
74
+
75
+ def colorize?
76
+ @colorize
77
+ end
78
+ end
79
+ end
80
+ end
@@ -5,9 +5,10 @@ module RequestTrail
5
5
  SQL_EVENT = "sql.active_record"
6
6
  CACHE_READ_EVENT = "cache_read.active_support"
7
7
  CACHE_WRITE_EVENT = "cache_write.active_support"
8
+ ACTION_EVENT = "process_action.action_controller"
8
9
 
9
10
  def self.attach
10
- @attach ||= [sql_subscription, cache_read_subscription, cache_write_subscription]
11
+ @attach ||= [sql_subscription, cache_read_subscription, cache_write_subscription, action_subscription]
11
12
  end
12
13
 
13
14
  def self.detach
@@ -40,5 +41,15 @@ module RequestTrail
40
41
  Collector.current&.record_cache_write(duration_ms: event.duration)
41
42
  end
42
43
  end
44
+
45
+ private_class_method def self.action_subscription
46
+ ActiveSupport::Notifications.subscribe(ACTION_EVENT) do |*args|
47
+ event = ActiveSupport::Notifications::Event.new(*args)
48
+ Collector.current&.record_action(
49
+ duration_ms: event.duration,
50
+ view_duration_ms: event.payload.fetch(:view_runtime, 0.0)
51
+ )
52
+ end
53
+ end
43
54
  end
44
55
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RequestTrail
4
- VERSION = "0.2.0"
4
+ VERSION = "0.4.0"
5
5
  end
data/lib/request_trail.rb CHANGED
@@ -5,6 +5,7 @@ require_relative "request_trail/configuration"
5
5
  require_relative "request_trail/collector"
6
6
  require_relative "request_trail/subscriber"
7
7
  require_relative "request_trail/formatter"
8
+ require_relative "request_trail/formatters/flame_graph"
8
9
  require_relative "request_trail/middleware"
9
10
  require_relative "request_trail/railtie" if defined?(Rails::Railtie)
10
11
 
@@ -21,12 +22,11 @@ module RequestTrail
21
22
  end
22
23
 
23
24
  def formatter
24
- @formatter ||= Formatter.new
25
+ configuration.formatter
25
26
  end
26
27
 
27
28
  def reset!
28
29
  @configuration = nil
29
- @formatter = nil
30
30
  end
31
31
  end
32
32
  end
@@ -9,6 +9,8 @@ module RequestTrail
9
9
  @cache_misses: Integer
10
10
  @cache_writes: Integer
11
11
  @cache_duration_ms: Float
12
+ @action_duration_ms: Float
13
+ @view_duration_ms: Float
12
14
 
13
15
  attr_reader sql_count: Integer
14
16
  attr_reader sql_duration_ms: Float
@@ -16,6 +18,8 @@ module RequestTrail
16
18
  attr_reader cache_misses: Integer
17
19
  attr_reader cache_writes: Integer
18
20
  attr_reader cache_duration_ms: Float
21
+ attr_reader action_duration_ms: Float
22
+ attr_reader view_duration_ms: Float
19
23
 
20
24
  def self.current: () -> Collector?
21
25
  def self.start: () -> Collector
@@ -25,6 +29,7 @@ module RequestTrail
25
29
  def record_sql: (Float duration_ms) -> Float
26
30
  def record_cache_read: (hit: bool, duration_ms: Float) -> Float
27
31
  def record_cache_write: (duration_ms: Float) -> Float
32
+ def record_action: (duration_ms: Float, view_duration_ms: Float) -> Float
28
33
  def elapsed_ms: () -> (Float | Integer)
29
34
  end
30
35
  end
@@ -1,11 +1,13 @@
1
1
  module RequestTrail
2
2
  class Configuration
3
3
  attr_writer logger: ::Logger
4
+ attr_writer formatter: untyped
4
5
  attr_accessor enabled: bool
5
6
  attr_accessor log_level: Symbol
6
7
  attr_accessor threshold_ms: Numeric
7
8
 
8
9
  def initialize: () -> void
9
10
  def logger: () -> ::Logger
11
+ def formatter: () -> untyped
10
12
  end
11
13
  end
@@ -1,5 +1,13 @@
1
1
  module RequestTrail
2
2
  class Formatter
3
3
  def format: (::Rack::Request request, Collector collector) -> String
4
+
5
+ private
6
+
7
+ def tiered_format: (String header, Collector collector) -> String
8
+ def sql_summary: (Collector collector) -> String
9
+ def cache_summary: (Collector collector) -> String
10
+ def sql_count_label: (Collector collector) -> String
11
+ def cache_detail: (Collector collector) -> String
4
12
  end
5
13
  end
@@ -0,0 +1,23 @@
1
+ module RequestTrail
2
+ module Formatters
3
+ class FlameGraph
4
+ BAR_WIDTH: Integer
5
+ BAR_CHAR: String
6
+ COLORS: Hash[Symbol, String]
7
+ RESET: String
8
+
9
+ def initialize: (?colorize: bool) -> void
10
+ def format: (::Rack::Request request, Collector collector) -> String
11
+
12
+ private
13
+
14
+ def detail_rows: (Collector collector, Float total) -> Array[String]
15
+ def tiered_rows: (Collector collector, Float total) -> Array[String]
16
+ def flat_rows: (Collector collector, Float total) -> Array[String]
17
+ def header_line: (::Rack::Request request, Collector collector) -> String
18
+ def row: (String indent, String label, Float duration_ms, Float total_ms, Symbol color_key) -> String
19
+ def colorized_bar: (Float duration_ms, Float total_ms, Symbol color_key) -> String
20
+ def colorize?: () -> bool
21
+ end
22
+ end
23
+ end
@@ -3,6 +3,7 @@ module RequestTrail
3
3
  SQL_EVENT: String
4
4
  CACHE_READ_EVENT: String
5
5
  CACHE_WRITE_EVENT: String
6
+ ACTION_EVENT: String
6
7
 
7
8
  self.@attach: Array[untyped]?
8
9
 
@@ -14,5 +15,6 @@ module RequestTrail
14
15
  def self.sql_subscription: () -> untyped
15
16
  def self.cache_read_subscription: () -> untyped
16
17
  def self.cache_write_subscription: () -> untyped
18
+ def self.action_subscription: () -> untyped
17
19
  end
18
20
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: request_trail
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Chuck Smith
@@ -58,6 +58,7 @@ files:
58
58
  - lib/request_trail/collector.rb
59
59
  - lib/request_trail/configuration.rb
60
60
  - lib/request_trail/formatter.rb
61
+ - lib/request_trail/formatters/flame_graph.rb
61
62
  - lib/request_trail/middleware.rb
62
63
  - lib/request_trail/railtie.rb
63
64
  - lib/request_trail/subscriber.rb
@@ -66,6 +67,7 @@ files:
66
67
  - sig/request_trail/collector.rbs
67
68
  - sig/request_trail/configuration.rbs
68
69
  - sig/request_trail/formatter.rbs
70
+ - sig/request_trail/formatters/flame_graph.rbs
69
71
  - sig/request_trail/middleware.rbs
70
72
  - sig/request_trail/subscriber.rbs
71
73
  homepage: https://github.com/eclectic-coding/request-trail