active_record_query_counter 3.1.1 → 3.3.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: 2e1c73a61e42bb29aabf511c59f3009a49760a9173eab4686de8ecdf05c9e24b
4
- data.tar.gz: 973f7b3a3ea5d285a023eb3545ff2e86350f51b1bd19db668c3efdda5ce976d4
3
+ metadata.gz: 499ad971fe53532f23bfa674961133b6c39f850b39d89b102d959be760ba386c
4
+ data.tar.gz: 0f2eb66f8d2d274ddd2b173fce6f21ba35a7069d398f0f72e03052905f0b9221
5
5
  SHA512:
6
- metadata.gz: 36fc6f8d28bc037c472fb0090828dc545ed8b386f49fbb2222a60313d560efb521ee407da6fb2f9d9a28880a170c7eb00f1a3a644a6f7a3a7d701575da28b07b
7
- data.tar.gz: 4f5bbb92f7ea0001e5f902537ec3d41a617af9685efc6c3a15b8abb89ceacc9e35969b7ea0f5ecbe2b664fdd9ab71df50aa4e893a199c3aad8abbb891552c8b5
6
+ metadata.gz: 8ead36fbb297cfb37af6ccb6c5b50903c123224c5c0ff72f96cb9ccf4caeac02da8e12b34858f91d7ef4ea153383babe0c6eac37e59bace9491707542ccac48b
7
+ data.tar.gz: d3042dca0d9c1b0624338367461c0277360a676061dd62dd4f2ba101cfdf7831c67a1382637703e4d91d7da51bfc90e0829b2792ff2b20fecfbcee892b48fff4
data/CHANGELOG.md CHANGED
@@ -4,6 +4,26 @@ All notable changes to this project will be documented in this file.
4
4
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
5
5
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
+ ## 3.3.0
8
+
9
+ ### Added
10
+
11
+ - `TransactionInfo#query_count` reports the number of queries executed within the transaction.
12
+ - `TransactionInfo#gc_time` and `TransactionInfo#cpu_time` report the GC time and thread CPU time in seconds that elapsed while the transaction was open.
13
+ - `TransactionInfo#idle_time` estimates the time the transaction was open but not spent executing queries, running Ruby code, or paused for garbage collection. A large value indicates the process was waiting on something other than the database (an HTTP request, a sleep, a lock, etc.) while it held the transaction open.
14
+ - The `transaction_time` notification payload now includes `:queries` along with `:gc_time`, `:cpu_time`, and `:idle_time` in milliseconds. The queries are an array of new `ActiveRecordQueryCounter::QueryInfo` objects; each includes the SQL statement, query name, row count, start and end times, and the GC, CPU, and connection setup times measured for the query. Bind parameter values are not included.
15
+ - The full query details for a transaction only live in a transient `ActiveRecordQueryCounter::TransactionDetails` object that populates the `transaction_time` notification payload and is then released. The `TransactionInfo` objects retained for the duration of a `count_queries` block hold only counts and timings, so memory stays flat even when a process creates many implicit transactions (for example, many saves without an explicit transaction).
16
+
17
+ ## 3.2.0
18
+
19
+ ### Added
20
+
21
+ - Connection setup time (time spent in the adapter's `connect!`, `reconnect!`, and `verify!` methods while running a query) is now measured and subtracted from the reported query time, so a query that triggers a reconnect after an idle period or a database failover is no longer reported as an inexplicably slow query. The time is reported separately as `:connection_time` in the `query_time` and `row_count` notification payloads.
22
+
23
+ ### Removed
24
+
25
+ - Support for ActiveRecord versions prior to 7.1.
26
+
7
27
  ## 3.1.1
8
28
 
9
29
  ### Fixed
data/README.md CHANGED
@@ -14,6 +14,9 @@ It measures database usage within a block of code, including:
14
14
  - The number of transactions used
15
15
  - The total time spent inside transactions
16
16
  - The number of transactions that were rolled back
17
+ - The queries executed inside each transaction, with the time each one took
18
+ - The GC time and CPU time spent inside each transaction
19
+ - The idle time inside each transaction spent waiting on things other than the database
17
20
 
18
21
  This gem is designed to help you:
19
22
 
@@ -57,9 +60,11 @@ end
57
60
 
58
61
  ### Query Time
59
62
 
60
- The query time (`ActiveRecordQueryCounter.query_time` and the duration reported by the notifications) is **not** the raw wall clock time a query took. The wall clock time includes time the thread was not actually waiting on the database, such as GC pauses (which can be triggered by other threads and stop the world) and the Ruby CPU work of building the result objects. On a busy, multi-threaded server these can add up to seconds, making a trivial query look pathologically slow.
63
+ The query time (`ActiveRecordQueryCounter.query_time` and the duration reported by the notifications) is **not** the raw wall clock time a query took. The wall clock time includes time the thread was not actually waiting on the database, such as GC pauses (which can be triggered by other threads and stop the world), the Ruby CPU work of building the result objects, and the time spent establishing or re-establishing the database connection. On a busy, multi-threaded server these can add up to seconds, making a trivial query look pathologically slow.
61
64
 
62
- To report the time actually spent waiting on the database as closely as possible, the GC time and thread CPU time that elapsed while the query ran are subtracted from the wall clock time. The raw wall clock time is still available as `:elapsed_time` in the notification payloads.
65
+ To report the time actually spent waiting on the database as closely as possible, the connection setup time, GC time, and thread CPU time that elapsed while the query ran are subtracted from the wall clock time. The raw wall clock time is still available as `:elapsed_time` in the notification payloads.
66
+
67
+ Connection setup time is the wall clock time spent inside the adapter's `connect!`, `reconnect!`, and `verify!` methods while running the query. ActiveRecord (re)establishes and verifies connections lazily, from within the query execution path, so when a connection has gone stale — after an idle period, or a database failover (common with clustered databases such as Amazon Aurora) — the reconnect (DNS resolution, TCP connect, TLS handshake, and authentication) happens on the next query and is otherwise charged to it. This is reported separately as `:connection_time` in the notification payloads so these events are diagnosable rather than appearing as inexplicably slow queries.
63
68
 
64
69
  > [!NOTE]
65
70
  > Measuring GC time requires Ruby's GC total time measurement, which is enabled by default (`GC.measure_total_time`). Thread CPU time is measured via `Process::CLOCK_THREAD_CPUTIME_ID`; on platforms that do not provide it, CPU time is treated as zero.
@@ -118,8 +123,9 @@ Triggered when a query exceeds the query_time threshold with the payload:
118
123
  - `:elapsed_time` - The raw wall clock time the query took (in milliseconds).
119
124
  - `:gc_time` - The GC time that elapsed while the query ran (in milliseconds).
120
125
  - `:cpu_time` - The thread CPU time spent while the query ran (in milliseconds).
126
+ - `:connection_time` - The time spent establishing, verifying, or reconnecting the database connection while the query ran (in milliseconds).
121
127
 
122
- The duration of the notification event is the query time: the wall clock time with the GC time and CPU time subtracted out (see [Query Time](#query-time)). The raw wall clock time is still available as `:elapsed_time`.
128
+ The duration of the notification event is the query time: the wall clock time with the connection setup time, GC time, and CPU time subtracted out (see [Query Time](#query-time)). The raw wall clock time is still available as `:elapsed_time`.
123
129
 
124
130
  ##### 2. active_record_query_counter.row_count notification
125
131
 
@@ -132,21 +138,29 @@ Triggered when a query exceeds the row_count threshold with the payload:
132
138
  - `:elapsed_time` - The raw wall clock time the query took (in milliseconds).
133
139
  - `:gc_time` - The GC time that elapsed while the query ran (in milliseconds).
134
140
  - `:cpu_time` - The thread CPU time spent while the query ran (in milliseconds).
141
+ - `:connection_time` - The time spent establishing, verifying, or reconnecting the database connection while the query ran (in milliseconds).
135
142
 
136
143
  ##### 3. active_record_query_counter.transaction_time notification
137
144
 
138
145
  Triggered when a transaction exceeds the transaction_time threshold with the payload:
139
146
 
140
147
  - `:trace` - The stack trace of where the transaction was completed.
148
+ - `:queries` - An array of `ActiveRecordQueryCounter::QueryInfo` objects for the queries executed within the transaction. Each object includes the SQL statement, query name, row count, and timing details. Bind parameter values are not included.
149
+ - `:gc_time` - The GC time that elapsed while the transaction was open (in milliseconds).
150
+ - `:cpu_time` - The thread CPU time spent while the transaction was open (in milliseconds).
151
+ - `:idle_time` - The estimated time the transaction was open but not spent executing queries, running Ruby code, or paused for garbage collection (in milliseconds). A large value indicates the process was waiting on something other than the database (an HTTP request, a sleep, a lock, etc.) while it held the transaction open.
141
152
 
142
153
  ##### 4. active_record_query_counter.transaction_count notification
143
154
 
144
155
  Triggered when transactions exceed the transaction_count threshold with the payload:
145
156
 
146
- - `:transactions` - An array of `ActiveRecordQueryCounter::TransactionInfo` objects.
157
+ - `:transactions` - An array of `ActiveRecordQueryCounter::TransactionInfo` objects. Each object includes the number of queries executed within the transaction (`query_count`), the GC time (`gc_time`) and thread CPU time (`cpu_time`) in seconds spent while the transaction was open, and the estimated idle time (`idle_time`) in seconds spent waiting on things other than the database.
147
158
 
148
159
  The duration of the notification event is the time between when the first transaction was started and the last transaction was completed.
149
160
 
161
+ > [!NOTE]
162
+ > The full query details for a transaction are only available in the `transaction_time` notification payload. They are released after the notification fires; the `TransactionInfo` objects retained for the duration of a `count_queries` block hold only counts and timings so that memory stays flat even when many transactions are created. Query details are also only collected while queries are being counted inside a `count_queries` block (or the bundled middleware).
163
+
150
164
  #### Setting Thresholds
151
165
 
152
166
  Thresholds can be configured **globally** in an initializer:
data/VERSION CHANGED
@@ -1 +1 @@
1
- 3.1.1
1
+ 3.3.0
@@ -34,9 +34,7 @@ Gem::Specification.new do |spec|
34
34
 
35
35
  spec.require_paths = ["lib"]
36
36
 
37
- spec.add_dependency "activerecord", ">= 6.0"
38
-
39
- spec.add_development_dependency "bundler"
37
+ spec.add_dependency "activerecord", ">= 7.1"
40
38
 
41
39
  spec.required_ruby_version = ">= 3.1"
42
40
  end
@@ -3,63 +3,76 @@
3
3
  module ActiveRecordQueryCounter
4
4
  # Module to prepend to the connection adapter to inject the counting behavior.
5
5
  module ConnectionAdapterExtension
6
- # Clock used to measure the CPU time consumed by the current thread while a query runs.
7
- # It is not available on every platform (e.g. Windows), in which case CPU time is not
8
- # measured and is treated as zero.
9
- CPU_CLOCK_ID = (Process::CLOCK_THREAD_CPUTIME_ID if defined?(Process::CLOCK_THREAD_CPUTIME_ID))
6
+ # Connection adapter methods that establish, verify, or reconnect the underlying database
7
+ # connection. When these run inside a query (for example when a stale connection is
8
+ # re-established after an idle period or a database failover), the wall clock time they
9
+ # consume is spent setting up the connection rather than executing the query. It is measured
10
+ # separately so it can be subtracted from the reported query time.
11
+ CONNECTION_SETUP_METHODS = %i[connect! reconnect! verify!].freeze
10
12
 
11
13
  class << self
12
14
  def inject(connection_class)
13
- # Rails 7.1+ uses internal_exec_query instead of exec_query.
14
- mod = (connection_class.method_defined?(:internal_exec_query) ? InternalExecQuery : ExecQuery)
15
- unless connection_class.include?(mod)
16
- connection_class.prepend(mod)
15
+ unless connection_class.include?(InternalExecQuery)
16
+ connection_class.prepend(InternalExecQuery)
17
+ end
18
+
19
+ unless connection_class.include?(ConnectionSetupExtension)
20
+ connection_class.prepend(ConnectionSetupExtension)
17
21
  end
18
22
  end
19
23
 
20
24
  # Measure a query by wrapping its execution. In addition to the wall clock time, the GC
21
- # time and thread CPU time spent while the query runs are captured so that the time
22
- # actually spent waiting on the database can be isolated from time lost to garbage
23
- # collection and Ruby VM work.
25
+ # time, thread CPU time, and connection setup time spent while the query runs are captured
26
+ # so that the time actually spent waiting on the database can be isolated from time lost to
27
+ # garbage collection, Ruby VM work, and (re)establishing the database connection.
24
28
  #
25
29
  # @param sql [String] the SQL statement being executed
26
30
  # @param name [String, nil] the name of the query
27
31
  # @param binds [Array] the bind parameters
32
+ # @param connection [Object, nil] the connection adapter the query is being executed on
28
33
  # @yield executes the query and returns its result
29
34
  # @return [Object] the result of the query
30
- def measure_query(sql, name, binds)
35
+ def measure_query(sql, name, binds, connection = nil)
31
36
  gc_start = GC.total_time
32
- cpu_start = current_cpu_time
37
+ cpu_start = ActiveRecordQueryCounter.current_cpu_time
38
+ previous_timer = ActiveRecordQueryCounter.start_connection_timer
33
39
  start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
34
- result = yield
40
+ begin
41
+ result = yield
42
+ ensure
43
+ connection_time = ActiveRecordQueryCounter.stop_connection_timer(previous_timer)
44
+ end
35
45
  if result.is_a?(ActiveRecord::Result)
36
46
  end_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
37
- cpu_time = current_cpu_time - cpu_start
47
+ cpu_time = ActiveRecordQueryCounter.current_cpu_time - cpu_start
38
48
  gc_time = (GC.total_time - gc_start) / 1_000_000_000.0
39
- ActiveRecordQueryCounter.add_query(sql, name, binds, result.length, start_time, end_time, gc_time, cpu_time)
49
+ ActiveRecordQueryCounter.add_query(sql, name, binds, result.length, start_time, end_time, gc_time, cpu_time, connection_time, connection: connection)
40
50
  end
41
51
  result
42
52
  end
53
+ end
43
54
 
44
- private
45
-
46
- # The current thread CPU time in seconds, or 0.0 when the platform does not support it.
47
- #
48
- # @return [Float]
49
- def current_cpu_time
50
- CPU_CLOCK_ID ? Process.clock_gettime(CPU_CLOCK_ID) : 0.0
55
+ module InternalExecQuery
56
+ def internal_exec_query(sql, name = nil, binds = [], **kwargs)
57
+ ConnectionAdapterExtension.measure_query(sql, name, binds, self) { super }
51
58
  end
52
59
  end
53
60
 
54
- module ExecQuery
55
- def exec_query(sql, name = nil, binds = [], **kwargs)
56
- ConnectionAdapterExtension.measure_query(sql, name, binds) { super }
61
+ # Module prepended to the connection adapter to measure the wall clock time spent
62
+ # establishing, verifying, or reconnecting the database connection while a query is running
63
+ # (see {CONNECTION_SETUP_METHODS}). The measured time is accumulated on the current query's
64
+ # connection timer so it can be subtracted from the reported query time.
65
+ module ConnectionSetupExtension
66
+ def connect!(...)
67
+ ActiveRecordQueryCounter.measure_connection_setup { super }
57
68
  end
58
- end
59
69
 
60
- module InternalExecQuery
61
- def internal_exec_query(sql, name = nil, binds = [], **kwargs)
62
- ConnectionAdapterExtension.measure_query(sql, name, binds) { super }
70
+ def reconnect!(...)
71
+ ActiveRecordQueryCounter.measure_connection_setup { super }
72
+ end
73
+
74
+ def verify!(...)
75
+ ActiveRecordQueryCounter.measure_connection_setup { super }
63
76
  end
64
77
  end
65
78
  end
@@ -41,9 +41,14 @@ module ActiveRecordQueryCounter
41
41
  # @param trace [Array<String>] the trace of the transaction
42
42
  # @param start_time [Float] the monotonic time when the transaction began
43
43
  # @param end_time [Float] the monotonic time when the transaction ended
44
+ # @param query_count [Integer] the number of queries executed within the transaction
45
+ # @param gc_time [Float] the GC time in seconds that elapsed while the transaction was open
46
+ # @param cpu_time [Float] the thread CPU time in seconds spent while the transaction was open
47
+ # @param idle_time [Float] the time in seconds the transaction was open but not spent on
48
+ # queries, Ruby code, or garbage collection
44
49
  # @return [void]
45
50
  # @api private
46
- def add_transaction(trace:, start_time:, end_time:)
51
+ def add_transaction(trace:, start_time:, end_time:, query_count: 0, gc_time: 0.0, cpu_time: 0.0, idle_time: 0.0)
47
52
  trace_transactions = @transactions_hash[trace]
48
53
  if trace_transactions
49
54
  # Memory optimization so that we don't store duplicate traces for every transaction in a loop.
@@ -53,7 +58,7 @@ module ActiveRecordQueryCounter
53
58
  @transactions_hash[trace] = trace_transactions
54
59
  end
55
60
 
56
- trace_transactions << TransactionInfo.new(start_time: start_time, end_time: end_time, trace: trace)
61
+ trace_transactions << TransactionInfo.new(start_time: start_time, end_time: end_time, trace: trace, query_count: query_count, gc_time: gc_time, cpu_time: cpu_time, idle_time: idle_time)
57
62
  end
58
63
 
59
64
  # Return the number of transactions that have been tracked by the counter.
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveRecordQueryCounter
4
+ # Data structure for storing information about a query executed within a transaction.
5
+ # Note that the start and end times are monotonic time and not wall clock time. Bind
6
+ # parameter values are deliberately not stored since they can contain sensitive data.
7
+ class QueryInfo
8
+ attr_reader :sql, :name, :row_count, :start_time, :end_time, :gc_time, :cpu_time, :connection_time
9
+
10
+ def initialize(sql:, name:, row_count:, start_time:, end_time:, gc_time: 0.0, cpu_time: 0.0, connection_time: 0.0)
11
+ @sql = sql
12
+ @name = name
13
+ @row_count = row_count
14
+ @start_time = start_time
15
+ @end_time = end_time
16
+ @gc_time = gc_time
17
+ @cpu_time = cpu_time
18
+ @connection_time = connection_time
19
+ end
20
+
21
+ # Return the wall clock time spent executing the query.
22
+ #
23
+ # @return [Float]
24
+ def elapsed_time
25
+ end_time - start_time
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveRecordQueryCounter
4
+ # Data structure with the full details about a transaction, including the queries executed
5
+ # within it. Note that the start and end times are monotonic time and not wall clock time.
6
+ #
7
+ # This object is transient. It is created when a transaction is recorded to build the
8
+ # `transaction_time` notification payload and to derive the retained
9
+ # {ActiveRecordQueryCounter::TransactionInfo}, and is then released so the query details
10
+ # do not accumulate in memory.
11
+ class TransactionDetails
12
+ attr_reader :start_time, :end_time, :trace, :queries, :gc_time, :cpu_time
13
+
14
+ def initialize(start_time:, end_time:, trace:, queries: [], gc_time: 0.0, cpu_time: 0.0)
15
+ @start_time = start_time
16
+ @end_time = end_time
17
+ @trace = trace
18
+ @queries = queries
19
+ @gc_time = gc_time
20
+ @cpu_time = cpu_time
21
+ end
22
+
23
+ # Return the time spent in the transaction.
24
+ #
25
+ # @return [Float]
26
+ def elapsed_time
27
+ end_time - start_time
28
+ end
29
+
30
+ # Return the number of queries executed within the transaction.
31
+ #
32
+ # @return [Integer]
33
+ def query_count
34
+ queries.size
35
+ end
36
+
37
+ # Estimate the time the transaction was open but not spent executing queries, running Ruby
38
+ # code, or paused for garbage collection. A large value indicates the process was waiting
39
+ # on something other than the database (an HTTP request, a sleep, a lock, etc.) while it
40
+ # held the transaction open.
41
+ #
42
+ # The wall clock time of each query is subtracted first since it already includes any GC
43
+ # and CPU time that occurred while the query ran. Only the GC and CPU time that occurred
44
+ # outside of queries is then subtracted from the remainder. GC and CPU time normally cover
45
+ # distinct intervals, but they overlap when this thread triggers a GC; when subtracting
46
+ # both would drive the result negative, only the larger of the two is subtracted so the
47
+ # shared interval is removed once (the same heuristic used for the query time). The result
48
+ # is clamped so it is never negative and never exceeds the elapsed time.
49
+ #
50
+ # @return [Float]
51
+ def idle_time
52
+ return 0.0 if elapsed_time <= 0.0
53
+
54
+ queries_elapsed_time = 0.0
55
+ queries_gc_time = 0.0
56
+ queries_cpu_time = 0.0
57
+ queries.each do |query|
58
+ queries_elapsed_time += query.elapsed_time
59
+ queries_gc_time += query.gc_time
60
+ queries_cpu_time += query.cpu_time
61
+ end
62
+
63
+ remaining_time = elapsed_time - queries_elapsed_time
64
+ return 0.0 if remaining_time <= 0.0
65
+
66
+ other_gc_time = (gc_time - queries_gc_time).clamp(0.0, remaining_time)
67
+ other_cpu_time = (cpu_time - queries_cpu_time).clamp(0.0, remaining_time)
68
+
69
+ idle_time = remaining_time - (other_gc_time + other_cpu_time)
70
+ idle_time = remaining_time - [other_gc_time, other_cpu_time].max if idle_time.negative?
71
+ idle_time.clamp(0.0, remaining_time)
72
+ end
73
+ end
74
+ end
@@ -16,6 +16,9 @@ module ActiveRecordQueryCounter
16
16
  def initialize(...)
17
17
  super
18
18
  @active_record_query_counter_start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
19
+ @active_record_query_counter_gc_start = GC.total_time
20
+ @active_record_query_counter_cpu_start = ActiveRecordQueryCounter.current_cpu_time
21
+ @active_record_query_counter_queries = ActiveRecordQueryCounter.register_transaction_queries(connection)
19
22
  end
20
23
 
21
24
  def commit(...)
@@ -44,8 +47,17 @@ module ActiveRecordQueryCounter
44
47
  return unless start_time
45
48
 
46
49
  @active_record_query_counter_start_time = nil
50
+ ActiveRecordQueryCounter.unregister_transaction_queries(connection)
47
51
  end_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
48
- ActiveRecordQueryCounter.add_transaction(start_time, end_time)
52
+ gc_time = (GC.total_time - @active_record_query_counter_gc_start) / 1_000_000_000.0
53
+ cpu_time = ActiveRecordQueryCounter.current_cpu_time - @active_record_query_counter_cpu_start
54
+ ActiveRecordQueryCounter.add_transaction(
55
+ start_time,
56
+ end_time,
57
+ queries: @active_record_query_counter_queries,
58
+ gc_time: gc_time,
59
+ cpu_time: cpu_time
60
+ )
49
61
  ActiveRecordQueryCounter.increment_rollbacks if rollback
50
62
  end
51
63
  end
@@ -1,15 +1,24 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ActiveRecordQueryCounter
4
- # Data structure for storing information about a transaction. Note that the start and end
5
- # times are monotonic time and not wall clock time.
4
+ # Data structure for storing the counts and timings of a transaction. Note that the start
5
+ # and end times are monotonic time and not wall clock time.
6
+ #
7
+ # This structure deliberately does not hold the queries executed within the transaction so
8
+ # that retaining it for the duration of a `count_queries` block stays cheap. The full query
9
+ # details are only available transiently in the `transaction_time` notification payload
10
+ # (see {ActiveRecordQueryCounter::TransactionDetails}).
6
11
  class TransactionInfo
7
- attr_reader :start_time, :end_time, :trace
12
+ attr_reader :start_time, :end_time, :trace, :query_count, :gc_time, :cpu_time, :idle_time
8
13
 
9
- def initialize(start_time:, end_time:, trace:)
14
+ def initialize(start_time:, end_time:, trace:, query_count: 0, gc_time: 0.0, cpu_time: 0.0, idle_time: 0.0)
10
15
  @start_time = start_time
11
16
  @end_time = end_time
12
17
  @trace = trace
18
+ @query_count = query_count
19
+ @gc_time = gc_time
20
+ @cpu_time = cpu_time
21
+ @idle_time = idle_time
13
22
  end
14
23
 
15
24
  # Return the time spent in the transaction.
@@ -4,7 +4,9 @@ require "securerandom"
4
4
 
5
5
  require_relative "active_record_query_counter/connection_adapter_extension"
6
6
  require_relative "active_record_query_counter/counter"
7
+ require_relative "active_record_query_counter/query_info"
7
8
  require_relative "active_record_query_counter/thresholds"
9
+ require_relative "active_record_query_counter/transaction_details"
8
10
  require_relative "active_record_query_counter/transaction_info"
9
11
  require_relative "active_record_query_counter/transaction_extension"
10
12
 
@@ -26,6 +28,10 @@ module ActiveRecordQueryCounter
26
28
  IGNORED_STATEMENTS = %w[SCHEMA EXPLAIN].freeze
27
29
  private_constant :IGNORED_STATEMENTS
28
30
 
31
+ # Clock used to measure the CPU time consumed by the current thread. It is not available on
32
+ # every platform (e.g. Windows), in which case CPU time is not measured and is treated as zero.
33
+ CPU_CLOCK_ID = (Process::CLOCK_THREAD_CPUTIME_ID if defined?(Process::CLOCK_THREAD_CPUTIME_ID))
34
+
29
35
  @lock = Mutex.new
30
36
  @default_thresholds = Thresholds.new
31
37
 
@@ -72,10 +78,11 @@ module ActiveRecordQueryCounter
72
78
  # Increment the query counters.
73
79
  #
74
80
  # The reported query time is the wall clock time spent executing the query with the GC
75
- # time and Ruby thread CPU time subtracted out so that it reflects the time actually
76
- # spent waiting on the database as closely as possible (see {.database_query_time}). This
77
- # query time, rather than the raw wall clock time, is what is accumulated, compared against
78
- # the threshold, and used as the duration of the emitted notification.
81
+ # time, Ruby thread CPU time, and connection setup time subtracted out so that it reflects
82
+ # the time actually spent waiting on the database as closely as possible (see
83
+ # {.database_query_time}). This query time, rather than the raw wall clock time, is what is
84
+ # accumulated, compared against the threshold, and used as the duration of the emitted
85
+ # notification.
79
86
  #
80
87
  # @param sql [String] the SQL statement that was executed
81
88
  # @param name [String, nil] the name of the query
@@ -85,20 +92,38 @@ module ActiveRecordQueryCounter
85
92
  # @param end_time [Float] the monotonic time when the query ended
86
93
  # @param gc_time [Float] the GC time in seconds that elapsed while the query ran
87
94
  # @param cpu_time [Float] the thread CPU time in seconds spent while the query ran
95
+ # @param connection_time [Float] the time in seconds spent establishing, verifying, or
96
+ # reconnecting the database connection while the query ran
97
+ # @param connection [Object, nil] the connection adapter the query was executed on; used to
98
+ # attach the query to the transaction currently open on that connection, if any
88
99
  # @return [void]
89
100
  # @api private
90
- def add_query(sql, name, binds, row_count, start_time, end_time, gc_time, cpu_time)
101
+ def add_query(sql, name, binds, row_count, start_time, end_time, gc_time, cpu_time, connection_time = 0.0, connection: nil)
91
102
  return if IGNORED_STATEMENTS.include?(name)
92
103
 
93
104
  counter = current_counter
94
105
  return unless counter.is_a?(Counter)
95
106
 
96
107
  elapsed_time = end_time - start_time
97
- query_time = database_query_time(elapsed_time, gc_time, cpu_time)
108
+ query_time = database_query_time(elapsed_time, gc_time, cpu_time, connection_time)
98
109
  counter.query_count += 1
99
110
  counter.row_count += row_count
100
111
  counter.query_time += query_time
101
112
 
113
+ queries = transaction_queries(connection)
114
+ if queries
115
+ queries << QueryInfo.new(
116
+ sql: sql,
117
+ name: name,
118
+ row_count: row_count,
119
+ start_time: start_time,
120
+ end_time: end_time,
121
+ gc_time: gc_time,
122
+ cpu_time: cpu_time,
123
+ connection_time: connection_time
124
+ )
125
+ end
126
+
102
127
  # The notification duration is the database query time, so the event ends that long after
103
128
  # it started rather than at the raw wall clock end time.
104
129
  notification_end_time = start_time + query_time
@@ -107,14 +132,14 @@ module ActiveRecordQueryCounter
107
132
  query_time_threshold = counter.thresholds.query_time || -1
108
133
  if query_time_threshold.between?(0, query_time)
109
134
  trace = backtrace
110
- payload = notification_payload(sql: sql, binds: binds, row_count: row_count, trace: trace, elapsed_time: elapsed_time, gc_time: gc_time, cpu_time: cpu_time)
135
+ payload = notification_payload(sql: sql, binds: binds, row_count: row_count, trace: trace, elapsed_time: elapsed_time, gc_time: gc_time, cpu_time: cpu_time, connection_time: connection_time)
111
136
  send_notification("query_time", start_time, notification_end_time, **payload)
112
137
  end
113
138
 
114
139
  row_count_threshold = counter.thresholds.row_count || -1
115
140
  if row_count_threshold.between?(0, row_count)
116
141
  trace ||= backtrace
117
- payload = notification_payload(sql: sql, binds: binds, row_count: row_count, trace: trace, elapsed_time: elapsed_time, gc_time: gc_time, cpu_time: cpu_time)
142
+ payload = notification_payload(sql: sql, binds: binds, row_count: row_count, trace: trace, elapsed_time: elapsed_time, gc_time: gc_time, cpu_time: cpu_time, connection_time: connection_time)
118
143
  send_notification("row_count", start_time, notification_end_time, **payload)
119
144
  end
120
145
  end
@@ -123,18 +148,50 @@ module ActiveRecordQueryCounter
123
148
  #
124
149
  # @param start_time [Float] the time the transaction started
125
150
  # @param end_time [Float] the time the transaction ended
151
+ # @param queries [Array<ActiveRecordQueryCounter::QueryInfo>] the queries executed within
152
+ # the transaction
153
+ # @param gc_time [Float] the GC time in seconds that elapsed while the transaction was open
154
+ # @param cpu_time [Float] the thread CPU time in seconds spent while the transaction was open
126
155
  # @return [void]
127
156
  # @api private
128
- def add_transaction(start_time, end_time)
157
+ def add_transaction(start_time, end_time, queries: [], gc_time: 0.0, cpu_time: 0.0)
129
158
  counter = current_counter
130
159
  return unless counter.is_a?(Counter)
131
160
 
132
- trace = backtrace
133
- counter.add_transaction(trace: trace, start_time: start_time, end_time: end_time)
161
+ # The details object holds the full query information. Only the counts and timings
162
+ # derived from it are retained on the counter; the details are released when this
163
+ # method returns so the query data does not accumulate in memory.
164
+ details = TransactionDetails.new(
165
+ start_time: start_time,
166
+ end_time: end_time,
167
+ trace: backtrace,
168
+ queries: queries,
169
+ gc_time: gc_time,
170
+ cpu_time: cpu_time
171
+ )
172
+
173
+ counter.add_transaction(
174
+ trace: details.trace,
175
+ start_time: start_time,
176
+ end_time: end_time,
177
+ query_count: details.query_count,
178
+ gc_time: gc_time,
179
+ cpu_time: cpu_time,
180
+ idle_time: details.idle_time
181
+ )
134
182
 
135
183
  transaction_time_threshold = counter.thresholds.transaction_time || -1
136
184
  if transaction_time_threshold.between?(0, end_time - start_time)
137
- send_notification("transaction_time", start_time, end_time, trace: backtrace)
185
+ send_notification(
186
+ "transaction_time",
187
+ start_time,
188
+ end_time,
189
+ trace: details.trace,
190
+ queries: details.queries,
191
+ gc_time: (gc_time * 1000.0).round(6),
192
+ cpu_time: (cpu_time * 1000.0).round(6),
193
+ idle_time: (details.idle_time * 1000.0).round(6)
194
+ )
138
195
  end
139
196
  end
140
197
 
@@ -149,6 +206,91 @@ module ActiveRecordQueryCounter
149
206
  counter.rollback_count += 1
150
207
  end
151
208
 
209
+ # The current thread CPU time in seconds, or 0.0 when the platform does not support it.
210
+ #
211
+ # @return [Float]
212
+ # @api private
213
+ def current_cpu_time
214
+ CPU_CLOCK_ID ? Process.clock_gettime(CPU_CLOCK_ID) : 0.0
215
+ end
216
+
217
+ # Register a new query list for a transaction that was opened on a connection. Queries
218
+ # executed on that connection will be appended to the list until it is unregistered.
219
+ # Registering a connection always replaces any list already registered for it.
220
+ #
221
+ # The list is bound to the counter that is active when the transaction is opened. Queries
222
+ # counted by a different counter (e.g. a nested `count_queries` block) are not appended,
223
+ # so the recorded query count for the transaction stays consistent with the counter that
224
+ # recorded the transaction.
225
+ #
226
+ # @param connection [Object] the connection adapter the transaction was opened on
227
+ # @return [Array<ActiveRecordQueryCounter::QueryInfo>] the registered query list
228
+ # @api private
229
+ def register_transaction_queries(connection)
230
+ registry = ActiveSupport::IsolatedExecutionState[:active_record_query_counter_transaction_queries] ||= {}
231
+ queries = []
232
+ registry[connection] = {counter: current_counter, queries: queries}
233
+ queries
234
+ end
235
+
236
+ # Remove the registered query list for a connection when its transaction has ended.
237
+ #
238
+ # @param connection [Object] the connection adapter the transaction was opened on
239
+ # @return [void]
240
+ # @api private
241
+ def unregister_transaction_queries(connection)
242
+ ActiveSupport::IsolatedExecutionState[:active_record_query_counter_transaction_queries]&.delete(connection)
243
+ nil
244
+ end
245
+
246
+ # Begin measuring the time spent establishing, verifying, or reconnecting the database
247
+ # connection for a single query. Returns the timer that was previously in effect so it can
248
+ # be restored by {.stop_connection_timer}; this keeps nested queries (should they ever
249
+ # occur) from leaking connection time into one another.
250
+ #
251
+ # @return [Object, nil] the previous connection timer
252
+ # @api private
253
+ def start_connection_timer
254
+ previous_timer = connection_timer
255
+ self.connection_timer = {elapsed: 0.0, measuring: false}
256
+ previous_timer
257
+ end
258
+
259
+ # Finish measuring connection setup time for the current query and restore the previously
260
+ # active timer.
261
+ #
262
+ # @param previous_timer [Object, nil] the timer returned by {.start_connection_timer}
263
+ # @return [Float] the connection setup time in seconds accumulated for the query
264
+ # @api private
265
+ def stop_connection_timer(previous_timer)
266
+ timer = connection_timer
267
+ self.connection_timer = previous_timer
268
+ timer ? timer[:elapsed] : 0.0
269
+ end
270
+
271
+ # Measure the wall clock time a connection setup operation (connect, reconnect, or verify)
272
+ # takes and accumulate it onto the current query's connection timer. When no query is being
273
+ # measured, or when a connection setup operation is already being measured (for example when
274
+ # `verify!` delegates to `reconnect!`), the block is yielded without recording so the
275
+ # interval is only counted once.
276
+ #
277
+ # @yield the connection setup operation
278
+ # @return [Object] the result of the block
279
+ # @api private
280
+ def measure_connection_setup
281
+ timer = connection_timer
282
+ return yield if timer.nil? || timer[:measuring]
283
+
284
+ timer[:measuring] = true
285
+ start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
286
+ begin
287
+ yield
288
+ ensure
289
+ timer[:elapsed] += Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time
290
+ timer[:measuring] = false
291
+ end
292
+ end
293
+
152
294
  # Return the number of queries that have been counted within the current block.
153
295
  # Returns nil if not inside a block where queries are being counted.
154
296
  #
@@ -295,24 +437,34 @@ module ActiveRecordQueryCounter
295
437
 
296
438
  private
297
439
 
298
- # The counter is stored in ActiveSupport::IsolatedExecutionState when available so that
299
- # it follows the application's configured isolation level (thread or fiber). The fallback
300
- # for ActiveSupport 6.x uses Thread.current, which is fiber-local, so on those versions
301
- # queries executed in a fiber spawned inside the block are not counted.
440
+ # The counter is stored in ActiveSupport::IsolatedExecutionState so that it follows the
441
+ # application's configured isolation level (thread or fiber).
302
442
  def current_counter
303
- if defined?(ActiveSupport::IsolatedExecutionState)
304
- ActiveSupport::IsolatedExecutionState[:active_record_query_counter]
305
- else
306
- Thread.current[:active_record_query_counter]
307
- end
443
+ ActiveSupport::IsolatedExecutionState[:active_record_query_counter]
308
444
  end
309
445
 
310
446
  def current_counter=(counter)
311
- if defined?(ActiveSupport::IsolatedExecutionState)
312
- ActiveSupport::IsolatedExecutionState[:active_record_query_counter] = counter
313
- else
314
- Thread.current[:active_record_query_counter] = counter
315
- end
447
+ ActiveSupport::IsolatedExecutionState[:active_record_query_counter] = counter
448
+ end
449
+
450
+ # The connection timer accumulates the connection setup time for the query currently being
451
+ # measured. It is stored with the same isolation as the counter (see {#current_counter}).
452
+ def connection_timer
453
+ ActiveSupport::IsolatedExecutionState[:active_record_query_counter_connection_timer]
454
+ end
455
+
456
+ def connection_timer=(timer)
457
+ ActiveSupport::IsolatedExecutionState[:active_record_query_counter_connection_timer] = timer
458
+ end
459
+
460
+ # The query list registered for the transaction currently open on a connection, or nil
461
+ # when the connection is unknown, has no open transaction, or the transaction was opened
462
+ # under a different counter than the current one.
463
+ def transaction_queries(connection)
464
+ return nil if connection.nil?
465
+
466
+ entry = ActiveSupport::IsolatedExecutionState[:active_record_query_counter_transaction_queries]&.[](connection)
467
+ entry[:queries] if entry && entry[:counter].equal?(current_counter)
316
468
  end
317
469
 
318
470
  def send_notification(name, start_time, end_time, payload = {})
@@ -320,7 +472,7 @@ module ActiveRecordQueryCounter
320
472
  ActiveSupport::Notifications.publish("active_record_query_counter.#{name}", start_time, end_time, id, payload)
321
473
  end
322
474
 
323
- def notification_payload(sql:, binds:, row_count:, trace:, elapsed_time:, gc_time:, cpu_time:)
475
+ def notification_payload(sql:, binds:, row_count:, trace:, elapsed_time:, gc_time:, cpu_time:, connection_time:)
324
476
  {
325
477
  sql: sql,
326
478
  binds: binds,
@@ -328,12 +480,18 @@ module ActiveRecordQueryCounter
328
480
  trace: trace,
329
481
  elapsed_time: (elapsed_time * 1000.0).round(6),
330
482
  gc_time: (gc_time * 1000.0).round(6),
331
- cpu_time: (cpu_time * 1000.0).round(6)
483
+ cpu_time: (cpu_time * 1000.0).round(6),
484
+ connection_time: (connection_time * 1000.0).round(6)
332
485
  }
333
486
  end
334
487
 
335
- # Estimate the time spent waiting on the database by subtracting the GC time and thread CPU
336
- # time from the wall clock time the query took.
488
+ # Estimate the time spent waiting on the database by subtracting the connection setup time,
489
+ # GC time, and thread CPU time from the wall clock time the query took.
490
+ #
491
+ # The connection setup time is a measured sub-interval of the wall clock time that was spent
492
+ # establishing, verifying, or reconnecting the database connection rather than executing the
493
+ # query, so it is removed first. This is the time that inflates a trivial query into a
494
+ # multi-second one after an idle period or a database failover.
337
495
  #
338
496
  # The GC time and CPU time normally measure distinct, non-overlapping intervals: a GC pause
339
497
  # triggered by another thread happens while this thread is parked waiting on the database
@@ -347,13 +505,18 @@ module ActiveRecordQueryCounter
347
505
  # @param elapsed_time [Float] the wall clock time the query took in seconds
348
506
  # @param gc_time [Float] the GC time in seconds that elapsed while the query ran
349
507
  # @param cpu_time [Float] the thread CPU time in seconds spent while the query ran
508
+ # @param connection_time [Float] the time in seconds spent establishing, verifying, or
509
+ # reconnecting the database connection while the query ran
350
510
  # @return [Float] the estimated database time in seconds
351
- def database_query_time(elapsed_time, gc_time, cpu_time)
511
+ def database_query_time(elapsed_time, gc_time, cpu_time, connection_time = 0.0)
352
512
  return 0.0 if elapsed_time <= 0.0
353
513
 
354
- query_time = elapsed_time - (gc_time + cpu_time)
355
- query_time = elapsed_time - [gc_time, cpu_time].max if query_time.negative?
356
- query_time.clamp(0.0, elapsed_time)
514
+ wait_time = (elapsed_time - connection_time).clamp(0.0, elapsed_time)
515
+ return 0.0 if wait_time <= 0.0
516
+
517
+ query_time = wait_time - (gc_time + cpu_time)
518
+ query_time = wait_time - [gc_time, cpu_time].max if query_time.negative?
519
+ query_time.clamp(0.0, wait_time)
357
520
  end
358
521
 
359
522
  def backtrace
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: active_record_query_counter
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.1.1
4
+ version: 3.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Brian Durand
@@ -15,28 +15,14 @@ dependencies:
15
15
  requirements:
16
16
  - - ">="
17
17
  - !ruby/object:Gem::Version
18
- version: '6.0'
18
+ version: '7.1'
19
19
  type: :runtime
20
20
  prerelease: false
21
21
  version_requirements: !ruby/object:Gem::Requirement
22
22
  requirements:
23
23
  - - ">="
24
24
  - !ruby/object:Gem::Version
25
- version: '6.0'
26
- - !ruby/object:Gem::Dependency
27
- name: bundler
28
- requirement: !ruby/object:Gem::Requirement
29
- requirements:
30
- - - ">="
31
- - !ruby/object:Gem::Version
32
- version: '0'
33
- type: :development
34
- prerelease: false
35
- version_requirements: !ruby/object:Gem::Requirement
36
- requirements:
37
- - - ">="
38
- - !ruby/object:Gem::Version
39
- version: '0'
25
+ version: '7.1'
40
26
  email:
41
27
  - bbdurand@gmail.com
42
28
  executables: []
@@ -51,9 +37,11 @@ files:
51
37
  - lib/active_record_query_counter.rb
52
38
  - lib/active_record_query_counter/connection_adapter_extension.rb
53
39
  - lib/active_record_query_counter/counter.rb
40
+ - lib/active_record_query_counter/query_info.rb
54
41
  - lib/active_record_query_counter/rack_middleware.rb
55
42
  - lib/active_record_query_counter/sidekiq_middleware.rb
56
43
  - lib/active_record_query_counter/thresholds.rb
44
+ - lib/active_record_query_counter/transaction_details.rb
57
45
  - lib/active_record_query_counter/transaction_extension.rb
58
46
  - lib/active_record_query_counter/transaction_info.rb
59
47
  homepage: https://github.com/bdurand/active_record_query_counter