request_metrics 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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: c84c65399cbb0e8e971274bfff55901eb2c6dbd9be6f57ec8d58513aef27ef82
4
+ data.tar.gz: bb78391705582bf316869b46d6c218782147aaef1b86ef7ecfa0d2c078dc4590
5
+ SHA512:
6
+ metadata.gz: daeae6fe180094f619ee6d847bcab51760d5cd194ed6f077ea6bfe4f339aafe97853fc49ada48784a22012feb5d646cd1892a1f0b23a2286f90616c1a498d0dd
7
+ data.tar.gz: 8ee55f06673430f59fdf3c6dba3bf20eb621d5af75b2ce41cd5dd9f9feb7a26e95e50e1261a3d675b2ed3645d1c2a445374501da07619a420ae922760e92a1b2
data/CHANGELOG.md ADDED
@@ -0,0 +1,5 @@
1
+ ## [Unreleased]
2
+
3
+ ## [0.1.0] - 2026-05-25
4
+
5
+ - Initial release
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 Elia Schito
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,241 @@
1
+ # RequestMetrics
2
+
3
+ Per-request metric tracking and structured log summaries for Rails controllers.
4
+
5
+ Subclass `RequestMetrics::Base`, declare counters with `metric_accessor`, implement `#log` to record each event, and Rails will append a summary to every `process_action` log line — zero boilerplate.
6
+
7
+ ```
8
+ Completed 200 OK in 142ms (Views: 0.5ms | GQL: 87.3ms, 44 cost | Loop API: 31.1ms)
9
+ ```
10
+
11
+ ## Installation
12
+
13
+ Add to your Gemfile:
14
+
15
+ ```ruby
16
+ gem "request_metrics"
17
+ ```
18
+
19
+ No initializer needed. A Railtie installs all registered subclasses into `ActionController::Base` automatically.
20
+
21
+ ## Usage
22
+
23
+ ### 1. Subclass `RequestMetrics::Base`
24
+
25
+ ```ruby
26
+ class MyServiceMetrics < RequestMetrics::Base
27
+ metric_accessor :my_service_runtime # declares counter + thread-safe accessors
28
+
29
+ # Called on each tracked event — add to the counter and log the line
30
+ def log(ms:, url:, status:)
31
+ add_my_service_runtime(ms)
32
+ name = color(" MyService (#{ms.round(1)}ms)", YELLOW, bold: true)
33
+ debug "#{name} #{url} (status: #{status})"
34
+ end
35
+
36
+ # Optional — return a string to append to the controller summary log line
37
+ def self.summary_log(payload)
38
+ ms = payload[:my_service_runtime] || 0
39
+ "MyService: #{ms.round(1)}ms" if ms > 0
40
+ end
41
+ end
42
+ ```
43
+
44
+ ### 2. Call `.log` from your HTTP client or wherever the event occurs
45
+
46
+ ```ruby
47
+ ms = ActiveSupport::Benchmark.realtime(:float_millisecond) { response = do_request }
48
+ MyServiceMetrics.log(ms:, url:, status: response.code)
49
+ ```
50
+
51
+ ### 3. Filter noise from source location output
52
+
53
+ `verbose_query_logs` (default `true`) appends a `↳ app/...` caller hint below each log line. Add silencers to suppress framework internals:
54
+
55
+ ```ruby
56
+ class MyServiceMetrics < RequestMetrics::Base
57
+ backtrace_cleaner.add_silencer { |line| line.include?("app/clients/") }
58
+ backtrace_cleaner.add_silencer { |line| line.include?("app/models/concerns/") }
59
+ # ...
60
+ end
61
+ ```
62
+
63
+ ---
64
+
65
+ ## Real-world examples
66
+
67
+ These are the two metrics classes from a Rails + Shopify app.
68
+
69
+ ### Loop API (HTTP client tracking)
70
+
71
+ Tracks every outbound call to the [Loop Subscriptions](https://loopsubscriptions.com/) API, including cached responses.
72
+
73
+ ```ruby
74
+ class LoopControllerMetrics < RequestMetrics::Base
75
+ backtrace_cleaner.add_silencer { |line| line.include?(__FILE__) }
76
+ backtrace_cleaner.add_silencer { |line| line.include?("app/clients/") }
77
+ backtrace_cleaner.add_silencer { |line| line.include?("app/models/concerns/") }
78
+ backtrace_cleaner.add_silencer { |line| line.include?("config/initializers/") }
79
+
80
+ metric_accessor :loop_runtime
81
+
82
+ def log(method:, url:, ms:, status:, data: nil, cached: false)
83
+ add_loop_runtime(ms)
84
+
85
+ http_color =
86
+ case status.to_i
87
+ when 200..299 then GREEN
88
+ when 300..399 then CYAN
89
+ when 400..499 then YELLOW
90
+ when 500..599 then RED
91
+ else MAGENTA
92
+ end
93
+
94
+ name = color(" #{cached ? "CACHE " : ""}Loop API (#{ms.round(1)}ms)", YELLOW, bold: true)
95
+ request = color("#{method} #{url}", http_color, bold: true)
96
+
97
+ debug "#{name} #{request} #{data&.to_json} (status: #{status})"
98
+ end
99
+
100
+ def self.summary_log(payload)
101
+ ms = payload[:loop_runtime] || 0
102
+ "Loop API: #{ms.round(1)}ms" if ms > 0
103
+ end
104
+ end
105
+ ```
106
+
107
+ Called from the HTTP client:
108
+
109
+ ```ruby
110
+ ms = ActiveSupport::Benchmark.realtime(:float_millisecond) { perform.call }
111
+ LoopControllerMetrics.log(method: request.method, url: uri.to_s, ms:, status: response.code, data:)
112
+ ```
113
+
114
+ Cached hits are logged with zero ms and `CACHE` prefix:
115
+
116
+ ```ruby
117
+ LoopControllerMetrics.log(method: method.upcase, url:, ms: 0, status: 200, cached: true)
118
+ ```
119
+
120
+ ### Shopify GraphQL (API client patching)
121
+
122
+ Tracks every Shopify Admin API GraphQL call, including query cost. Patches the official `shopify_api` gem's client via `prepend`.
123
+
124
+ ```ruby
125
+ class ShopifyGraphqlMetrics < RequestMetrics::Base
126
+ backtrace_cleaner.add_silencer { |line| line.include?(__FILE__) }
127
+ backtrace_cleaner.add_silencer { |line| line.include?("app/models/shop.rb") }
128
+ backtrace_cleaner.add_silencer { |line| line.include?("app/models/concerns/") }
129
+ backtrace_cleaner.add_silencer { |line| line.include?("config/initializers/") }
130
+
131
+ metric_accessor :graphql_runtime
132
+ metric_accessor :graphql_cost
133
+
134
+ def self.install!
135
+ super
136
+ require "shopify_api/clients/graphql/admin"
137
+ ShopifyAPI::Clients::Graphql::Client.prepend(ShopifyAPIClientLoggingPatch)
138
+ end
139
+
140
+ module ShopifyAPIClientLoggingPatch
141
+ def query(query:, variables: nil, headers: nil, tries: 1, response_as_struct: ShopifyAPI::Context.response_as_struct, debug: false)
142
+ response = nil
143
+ ms = ActiveSupport::Benchmark.realtime(:float_millisecond) { response = super }
144
+ cost = response.body.dig("extensions", "cost")
145
+ ShopifyGraphqlMetrics.log(query:, ms:, cost:, variables:)
146
+ response
147
+ end
148
+ end
149
+
150
+ def log(query:, ms:, cost:, variables:, cached: false)
151
+ add_graphql_runtime(ms)
152
+ add_graphql_cost(cost.dig("requestedQueryCost")) if cost
153
+
154
+ graphql_color =
155
+ case query
156
+ when /\A\s*mutation/i then GREEN
157
+ when /\A\s*query/i then BLUE
158
+ when /\A\s*subscription/i then CYAN
159
+ else MAGENTA
160
+ end
161
+
162
+ name = color(" #{cached ? "CACHE " : ""}GraphQL (#{ms.round(1)}ms)", YELLOW, bold: true)
163
+ colored_query = color(query.gsub(/\s+/, " ").strip, graphql_color, bold: true)
164
+ binds = variables.present? ? " #{variables.inspect}" : ""
165
+ cost_info = "\n ↳ cost: #{cost}" if cost
166
+
167
+ debug "#{name} #{colored_query}#{binds}#{cost_info}"
168
+ end
169
+
170
+ def self.summary_log(payload)
171
+ runtime = payload[:graphql_runtime]
172
+ cost = payload[:graphql_cost]
173
+
174
+ if runtime && runtime > 0
175
+ cost_info = cost && cost > 0 ? ", #{cost.round} cost" : ""
176
+ "GQL: #{runtime.round(1)}ms#{cost_info}"
177
+ end
178
+ end
179
+ end
180
+ ```
181
+
182
+ Result in logs:
183
+
184
+ ```
185
+ GraphQL (87.3ms) query GetSubscription { ... } { id: "gid://shopify/..." }
186
+ ↳ cost: {"requestedQueryCost"=>44, ...}
187
+ ↳ app/models/concerns/loop/subscription/persistence.rb:23:in `find'
188
+
189
+ Completed 200 OK in 142ms (Views: 0.5ms | GQL: 87.3ms, 44 cost | Loop API: 31.1ms)
190
+ ```
191
+
192
+ ---
193
+
194
+ ## API reference
195
+
196
+ ### `metric_accessor(name)`
197
+
198
+ Declares a per-request counter stored in a thread-local. Generates:
199
+
200
+ | Method | Description |
201
+ |---|---|
202
+ | `MyMetrics.my_metric` | Read current value (default: `0`) |
203
+ | `MyMetrics.my_metric = n` | Set value |
204
+ | `MyMetrics.add_my_metric(delta)` | Increment |
205
+ | `MyMetrics.reset_my_metric` | Return current value and reset to `0` |
206
+
207
+ Thread-local keys are namespaced by subclass name, so two subclasses can both declare `metric_accessor :runtime` without collision.
208
+
209
+ ### `#log(**kwargs)` (instance, delegated to class)
210
+
211
+ Called per event. Must be implemented by subclasses. Raise `NotImplementedError` if not.
212
+
213
+ ### `.summary_log(payload)` (class)
214
+
215
+ Called once per request after `process_action`. Return a `String` to append to the log summary, or `nil` to skip. Default implementation returns `nil`.
216
+
217
+ ### `.install!` (class)
218
+
219
+ Called automatically by the Railtie. Can be overridden to do additional setup (e.g., patching a third-party client) — call `super` to preserve the `ActionController` hook.
220
+
221
+ ### `backtrace_cleaner`
222
+
223
+ Each subclass gets its own `ActiveSupport::BacktraceCleaner` instance (empty by default). Add silencers to filter which stack frame appears in the `↳` hint.
224
+
225
+ ### `verbose_query_logs`
226
+
227
+ Boolean (default: `true`). Set to `false` to suppress the `↳ source` hint entirely.
228
+
229
+ ---
230
+
231
+ ## Development
232
+
233
+ ```bash
234
+ bin/setup # install dependencies
235
+ rake test # run tests
236
+ bin/console # interactive prompt
237
+ ```
238
+
239
+ ## License
240
+
241
+ MIT.
data/Rakefile ADDED
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "minitest/test_task"
5
+
6
+ Minitest::TestTask.create
7
+
8
+ task default: :test
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/log_subscriber"
4
+ require "active_support/backtrace_cleaner"
5
+ require "active_support/core_ext/class/attribute"
6
+ require "active_support/concern"
7
+
8
+ module RequestMetrics
9
+ class Base < ActiveSupport::LogSubscriber
10
+ class_attribute :backtrace_cleaner, default: ActiveSupport::BacktraceCleaner.new
11
+ class_attribute :verbose_query_logs, default: true
12
+ class_attribute :metrics, default: []
13
+
14
+ def self.metric_accessor(name)
15
+ metrics << name
16
+
17
+ subclass = self
18
+ key = :"#{subclass.object_id}/#{name}"
19
+
20
+ define_singleton_method(name) { Thread.current[key] ||= 0 }
21
+ define_singleton_method("#{name}=") { |value| Thread.current[key] = value }
22
+ define_singleton_method("reset_#{name}") { send(name).tap { send("#{name}=", 0) } }
23
+ define_singleton_method("add_#{name}") { |delta| send("#{name}=", send(name) + delta) }
24
+
25
+ define_method(name) { subclass.send(name) }
26
+ define_method("#{name}=") { |v| subclass.send("#{name}=", v) }
27
+ define_method("reset_#{name}") { subclass.send("reset_#{name}") }
28
+ define_method("add_#{name}") { |delta| subclass.send("add_#{name}", delta) }
29
+ end
30
+
31
+ def self.inherited(subclass)
32
+ super
33
+
34
+ subclass.backtrace_cleaner = ActiveSupport::BacktraceCleaner.new
35
+ subclass.verbose_query_logs = verbose_query_logs
36
+ subclass.metrics = []
37
+
38
+ RequestMetrics.register(subclass)
39
+
40
+ controller_runtime_module = Module.new { extend ActiveSupport::Concern }
41
+
42
+ controller_runtime_module.class_methods do
43
+ define_method :log_process_action do |payload|
44
+ messages = super(payload)
45
+ subclass.summary_log(payload)&.then { messages << it }
46
+ messages
47
+ end
48
+ end
49
+
50
+ controller_runtime_module.define_method :append_info_to_payload do |payload|
51
+ super(payload)
52
+ subclass.metrics.each { |metric| payload[metric] = subclass.send("reset_#{metric}") }
53
+ end
54
+
55
+ subclass.const_set :ControllerRuntime, controller_runtime_module
56
+ end
57
+
58
+ def self.install!
59
+ runtime_module = const_get(:ControllerRuntime)
60
+ ActiveSupport.on_load(:action_controller) { include runtime_module }
61
+ end
62
+
63
+ def self.summary_log(payload)
64
+ nil
65
+ end
66
+
67
+ def log(**options)
68
+ raise NotImplementedError, "#{self.class} must implement #log"
69
+ end
70
+
71
+ singleton_class.delegate :log, to: :new
72
+
73
+ def debug(message)
74
+ logger.debug(message)
75
+ log_query_source if verbose_query_logs
76
+ end
77
+
78
+ private
79
+
80
+ def log_query_source
81
+ source = query_source_location
82
+ logger.debug(" ↳ #{source}") if source
83
+ end
84
+
85
+ def query_source_location
86
+ backtrace_cleaner.first_clean_frame
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/railtie"
4
+
5
+ module RequestMetrics
6
+ class Railtie < Rails::Railtie
7
+ initializer "request_metrics.install" do
8
+ ActiveSupport.on_load(:action_controller) do
9
+ RequestMetrics.registry.each(&:install!)
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RequestMetrics
4
+ VERSION = "0.1.0"
5
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "request_metrics/version"
4
+ require_relative "request_metrics/base"
5
+ require_relative "request_metrics/railtie" if defined?(Rails::Railtie)
6
+
7
+ module RequestMetrics
8
+ class << self
9
+ def registry
10
+ @registry ||= []
11
+ end
12
+
13
+ def register(subclass)
14
+ registry << subclass
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,4 @@
1
+ module RequestMetrics
2
+ VERSION: String
3
+ # See the writing guide of rbs: https://github.com/ruby/rbs#guides
4
+ end
metadata ADDED
@@ -0,0 +1,70 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: request_metrics
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Elia Schito
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: activesupport
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '7.0'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '7.0'
26
+ description: |
27
+ RequestMetrics provides a base class for attaching per-request counters and
28
+ timing metrics to Rails controller log lines. Subclass RequestMetrics::Base,
29
+ declare metrics with metric_accessor, implement #log and .summary_log, and
30
+ the gem wires everything into ActionController via a Railtie automatically.
31
+ email:
32
+ - elia@schito.me
33
+ executables: []
34
+ extensions: []
35
+ extra_rdoc_files: []
36
+ files:
37
+ - CHANGELOG.md
38
+ - LICENSE.txt
39
+ - README.md
40
+ - Rakefile
41
+ - lib/request_metrics.rb
42
+ - lib/request_metrics/base.rb
43
+ - lib/request_metrics/railtie.rb
44
+ - lib/request_metrics/version.rb
45
+ - sig/request_metrics.rbs
46
+ homepage: https://github.com/nebulab/request_metrics
47
+ licenses:
48
+ - MIT
49
+ metadata:
50
+ homepage_uri: https://github.com/nebulab/request_metrics
51
+ source_code_uri: https://github.com/nebulab/request_metrics
52
+ changelog_uri: https://github.com/nebulab/request_metrics/blob/main/CHANGELOG.md
53
+ rdoc_options: []
54
+ require_paths:
55
+ - lib
56
+ required_ruby_version: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - ">="
59
+ - !ruby/object:Gem::Version
60
+ version: 3.2.0
61
+ required_rubygems_version: !ruby/object:Gem::Requirement
62
+ requirements:
63
+ - - ">="
64
+ - !ruby/object:Gem::Version
65
+ version: '0'
66
+ requirements: []
67
+ rubygems_version: 4.0.10
68
+ specification_version: 4
69
+ summary: Per-request metric tracking and log summaries for Rails controllers
70
+ test_files: []