forge_ops_tracker 0.13.0 → 0.15.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: ccbeed25a3172994d20455c19fa79a8336f3c73324ed667fd2f31d2cc05a19f5
4
- data.tar.gz: 3cb7e1562daf2ccb8f09226bc915b7f00f6e8486ebbf361f8adf0a16c9851afe
3
+ metadata.gz: 2c05c6f94c7c080f7c148cc315097b8e177d34dec772d580e21cf89a7120f1b3
4
+ data.tar.gz: d486796b0f26b752872118cfaad2c9c184671bc596bd6c32a04cfb6a754dc171
5
5
  SHA512:
6
- metadata.gz: 6287470f8bc768e95d1d1afe55f2c7a0e640a4d66ded8a0e3c518e0248031a7f5f76621adf0436d2de3bc7da029733b9debab07e31d2f1b37c7867cfd3aee7e5
7
- data.tar.gz: b7c996fbf89c2c0a90f6c39c848542cde734242a3fa4efaf450a36b1e7362815322417ceafc89d05a71a453a9965d7b4ab0c4deb2eedad0fdafd6b21808ad790
6
+ metadata.gz: e99dddf991705420ceb4d69f21ad8d5e39885f5f2f782e0747976aa2c8cb2463a4d8f56652f23f6944c40d38bc89e95475e61ecaf2079b0f106ca401d4e25d06
7
+ data.tar.gz: 1e5e0e69a95ddecb9411a024dcbf1817b688312131bbffc7359cb917f86d416e7d2811b1215feeb355b4ff4df3b1a2757bafe747ddc425e057e3248bb3572287
data/CHANGELOG.md CHANGED
@@ -1,5 +1,28 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.15.0 (2026-09-29)
4
+
5
+ - SQL masking now also catches strings with a backslash-escaped quote (`'o\'brien'`,
6
+ `E'o\'brien'`), prefixed strings (`E''`, `X''`, `N''`, `B''`, `U&''`, prefix included), hex,
7
+ binary and exponent numbers (`0x1F`, `0b101`, `3e10`, `1.5E-3`, `.5`), and, on MySQL and
8
+ MariaDB, `"double-quoted"` strings. `SqlStatement.mask` takes an optional `system:`, and
9
+ database spans (including `ForgeOpsTracker.span`'s `db_system:`) pass their `db.system` to it.
10
+
11
+ ## 0.14.0 (2026-09-25)
12
+
13
+ - Every Active Record database span now carries the query's SQL (`db.statement`), masked so every
14
+ string and number is `?` and bind values are never sent, and the database it ran on
15
+ (`db.system`: `postgresql`, `mysql`, `sqlite`, `mssql`, `oracle`, or the adapter's own name).
16
+ - `ForgeOpsTracker.span` takes optional `statement:` and `db_system:` on a `kind: "database"`
17
+ span, masked the same way.
18
+ - New opt-in `config.explain_slow_queries` (default false) and `config.explain_threshold_ms`
19
+ (default 500). On PostgreSQL, a single plain `SELECT` at least that slow gets a plain
20
+ `EXPLAIN (FORMAT JSON)` (never `EXPLAIN ANALYZE`), run on a background thread with its own
21
+ pooled connection, in a read-only transaction with a 2 second statement timeout that is always
22
+ rolled back. The plan is sent with every string in it masked. Limited to once per 10 minutes per
23
+ distinct query and 10 per minute per process; plans over 64 KB are dropped; any failure is
24
+ logged at debug and never raised.
25
+
3
26
  ## 0.13.0 (2026-09-25)
4
27
 
5
28
  - New `ForgeOpsTracker.record_change(kind:, title:, details: {}, environment: nil, service: nil,
data/README.md CHANGED
@@ -411,6 +411,73 @@ Requires a ForgeOps plan that includes distributed tracing; on a plan that doesn
411
411
  simply rejected server-side and dropped, exactly like any other delivery failure. The `trace_id`
412
412
  on error events is sent either way.
413
413
 
414
+ ### The SQL behind each database span
415
+
416
+ Every Active Record query span carries the query's SQL and the database it ran on, so a slow trace
417
+ shows exactly which query was slow. The SQL is masked before it leaves your process: every string
418
+ and number is replaced by `?`, so `WHERE email = 'a@b.co' AND id = 42` is sent as
419
+ `WHERE email = ? AND id = ?`, and bind values are never sent at all. That includes escaped and
420
+ prefixed strings (`E'o\'brien'`, `X'DEADBEEF'`), hex, binary and exponent numbers (`0x1F`,
421
+ `0b101`, `1.5E-3`), and, on MySQL and MariaDB (where `"double quotes"` hold a string, not a name),
422
+ double-quoted strings. ForgeOps masks it again on arrival. The database comes from the
423
+ connection's adapter (`postgresql`, `mysql`, `sqlite`, `mssql`, `oracle`, or the adapter's own
424
+ name otherwise).
425
+
426
+ Spans you create yourself can carry SQL too. Pass `statement:` (and optionally `db_system:`) on a
427
+ `kind: "database"` span; it's masked the same way:
428
+
429
+ ```ruby
430
+ sql = ActiveRecord::Base.sanitize_sql_array(
431
+ [ "SELECT * FROM orders WHERE customer_id = ? AND status = ?", customer.id, "open" ]
432
+ )
433
+
434
+ ForgeOpsTracker.span("Load open orders", kind: "database", statement: sql, db_system: "postgresql") do
435
+ ReportingDatabase.connection.select_all(sql)
436
+ end
437
+ # Sent as "SELECT * FROM orders WHERE customer_id = ? AND status = ?"
438
+ ```
439
+
440
+ ### Query plans for slow queries (PostgreSQL)
441
+
442
+ Opt in, and a slow `SELECT` also sends Postgres's plan for it, so ForgeOps can show why it was
443
+ slow (a sequential scan over a big table, a missing index, a bad join order):
444
+
445
+ ```ruby
446
+ ForgeOpsTracker.configure do |config|
447
+ config.explain_slow_queries = true # default false
448
+ config.explain_threshold_ms = 500 # default 500; only queries at least this slow
449
+ end
450
+ ```
451
+
452
+ What it does:
453
+
454
+ - For a query that took at least `explain_threshold_ms`, runs `EXPLAIN (FORMAT JSON)` with the
455
+ same SQL and bind values, and sends the plan with the query's masked SQL. Every string inside the
456
+ plan (a filter like `(email = 'a@b.co'::text)`) is masked the same way before it's sent. A plan
457
+ over 64 KB is dropped rather than sent.
458
+ - Runs on the gem's own background thread, on a connection it checks out of your app's pool itself,
459
+ inside its own `READ ONLY` transaction with a 2 second `statement_timeout`, always rolled back.
460
+ Never on the connection your request is using, and never inside your request's transaction.
461
+ - At most once per 10 minutes for each distinct (masked) query, and at most 10 per minute per
462
+ process overall.
463
+
464
+ What it doesn't do:
465
+
466
+ - It never runs `EXPLAIN ANALYZE`, so your query is planned again but never executed again.
467
+ - It only explains a single plain `SELECT`: nothing that starts with anything else (including
468
+ `WITH`), nothing with a second statement, no `FOR UPDATE`/`FOR SHARE` (or `FOR NO KEY UPDATE`/
469
+ `FOR KEY SHARE`), and nothing with `INSERT`, `UPDATE`, `DELETE` or `MERGE` anywhere in it.
470
+ - It only runs on PostgreSQL. Other databases are left alone.
471
+ - It never raises into your app. A timeout, a permission error, an exhausted connection pool or
472
+ anything else is logged at debug level and skipped.
473
+ - The original SQL and its bind values are only held in memory until the `EXPLAIN` runs. Only the
474
+ masked SQL and the masked plan are sent.
475
+
476
+ It does use one pooled connection, briefly, for each plan. With the limits above that's at most 10
477
+ short checkouts a minute, but on a pool that's already at capacity, leave it off or raise
478
+ `explain_threshold_ms`. Plans are part of performance monitoring; on a plan without it, they're
479
+ rejected server-side and dropped.
480
+
414
481
  ## What changed
415
482
 
416
483
  ForgeOps can show what changed in your system next to the errors and slowdowns that followed it.
@@ -71,6 +71,12 @@ module ForgeOpsTracker
71
71
  post(configuration.change_snapshots_uri, payload)
72
72
  end
73
73
 
74
+ # Same again; see Configuration#query_plans_uri and QueryExplainer. One plan per call. A plan
75
+ # without performance monitoring answers 403, which is just a false here.
76
+ def deliver_query_plan(payload)
77
+ post(configuration.query_plans_uri, payload)
78
+ end
79
+
74
80
  private
75
81
  attr_reader :configuration
76
82
 
@@ -17,6 +17,7 @@ module ForgeOpsTracker
17
17
  attr_accessor :metric_flush_interval, :infrastructure_metric_flush_interval
18
18
  attr_accessor :gauge_poll_interval
19
19
  attr_accessor :track_tracing, :trace_capture_threshold_ms
20
+ attr_accessor :explain_slow_queries, :explain_threshold_ms
20
21
  attr_accessor :propagate_traces, :trace_propagation_targets
21
22
  attr_accessor :detect_changes, :track_env_var_names
22
23
 
@@ -126,6 +127,13 @@ module ForgeOpsTracker
126
127
  # PerformanceSample-derived threshold elsewhere in this gem: those describe a trend across
127
128
  # many requests, this describes a single one being outright slow.
128
129
  @trace_capture_threshold_ms = 1_000
130
+ # Opt-in: for a SELECT on PostgreSQL that took at least explain_threshold_ms, ask Postgres for
131
+ # its plan (plain EXPLAIN, never EXPLAIN ANALYZE, so the query is never run again) and send
132
+ # it, masked, so ForgeOps can show why the query was slow. Off by default because it runs one
133
+ # extra statement against the app's own database. See QueryExplainer for exactly what it
134
+ # does and doesn't do, and its rate limits.
135
+ @explain_slow_queries = false
136
+ @explain_threshold_ms = 500
129
137
  # Adds a W3C `traceparent` header (https://www.w3.org/TR/trace-context/) to every outbound
130
138
  # Net::HTTP request made during a request, so the service on the other end can continue this
131
139
  # request's trace instead of starting its own (see Integrations::NetHTTP). On by default, the
@@ -241,6 +249,10 @@ module ForgeOpsTracker
241
249
  uri
242
250
  end
243
251
 
252
+ def query_plans_uri
253
+ sibling_uri("/query_plans")
254
+ end
255
+
244
256
  def changes_uri
245
257
  sibling_uri("/changes")
246
258
  end
@@ -70,6 +70,39 @@ module ForgeOpsTracker
70
70
  name.nil? || name.empty? ? "SQL" : name
71
71
  end
72
72
 
73
+ # Active Record's adapter names, as ForgeOps' "db.system" values. Anything not listed here is
74
+ # sent as its own adapter name, lowercased.
75
+ DB_SYSTEMS = {
76
+ "postgresql" => "postgresql", "postgis" => "postgresql",
77
+ "mysql" => "mysql", "mysql2" => "mysql", "trilogy" => "mysql",
78
+ "sqlite" => "sqlite", "sqlite3" => "sqlite",
79
+ "sqlserver" => "mssql",
80
+ "oracle" => "oracle", "oracleenhanced" => "oracle"
81
+ }.freeze
82
+
83
+ # The "data" a database span carries: the query's SQL, masked by SqlStatement (every string
84
+ # and number literal becomes "?", so no value the app queried with is ever sent), and which
85
+ # database it ran on, from the connection's adapter. Either is left out when it isn't known.
86
+ def database_span_data(payload)
87
+ system = db_system(payload[:connection])
88
+ {
89
+ "db.statement" => SqlStatement.mask(payload[:sql].to_s, system: system),
90
+ "db.system" => system
91
+ }.compact
92
+ end
93
+
94
+ # payload[:connection] is the adapter instance the query ran on (Rails 6.0 and later).
95
+ def db_system(connection)
96
+ return nil unless connection.respond_to?(:adapter_name)
97
+
98
+ name = connection.adapter_name.to_s.downcase
99
+ return nil if name.empty?
100
+
101
+ DB_SYSTEMS.fetch(name, name)
102
+ rescue StandardError
103
+ nil
104
+ end
105
+
73
106
  def job_transaction_name(payload)
74
107
  payload[:job].class.name
75
108
  end
@@ -0,0 +1,250 @@
1
+ require "json"
2
+ require "thread"
3
+ require "time"
4
+
5
+ module ForgeOpsTracker
6
+ # Opt-in (configuration.explain_slow_queries, default false): asks PostgreSQL for the plan of a
7
+ # slow SELECT and sends it, masked, to ForgeOps so a slow query shows why it was slow.
8
+ #
9
+ # What it never does, by design:
10
+ # - EXPLAIN ANALYZE. Plain EXPLAIN only plans the statement, it never executes it.
11
+ # - Touch the app's own connection or transaction. #consider only reads the notification payload
12
+ # and remembers the connection's pool; the EXPLAIN runs later, on this class's own background
13
+ # thread, on a connection it checks out of that pool itself, inside its own READ ONLY
14
+ # transaction with a 2 second statement_timeout, always rolled back.
15
+ # - Explain anything but a single plain SELECT (see .explainable?).
16
+ # - Raise. Every failure (a timeout, a permission error, an exhausted pool, a statement Postgres
17
+ # can't parse) is logged at debug and dropped.
18
+ # - Send the original statement or its bind values anywhere. Both are held in memory only until
19
+ # the EXPLAIN runs; what goes out is the masked statement (exactly as sent on the span) and the
20
+ # plan with every string in it masked the same way, since plan conditions quote literals
21
+ # ("Filter": "(email = 'a@b.co'::text)").
22
+ #
23
+ # Rate limited per process: each distinct masked statement at most once per 10 minutes, and at
24
+ # most 10 EXPLAINs per minute overall. Both are reserved when a query is accepted, not when its
25
+ # EXPLAIN succeeds, so a statement whose EXPLAIN keeps failing still can't be retried more often.
26
+ class QueryExplainer
27
+ STATEMENT_INTERVAL = 600.0
28
+ GLOBAL_WINDOW = 60.0
29
+ GLOBAL_LIMIT = 10
30
+ MAX_PLAN_BYTES = 64 * 1024
31
+ STATEMENT_TIMEOUT = "2s".freeze
32
+ CHECKOUT_TIMEOUT = 1
33
+ QUEUE_SIZE = GLOBAL_LIMIT
34
+ # Past this many remembered statements, the ones whose 10 minutes are up are forgotten, so an
35
+ # app with endless distinct statements can't grow this without bound.
36
+ MAX_REMEMBERED_STATEMENTS = 1_000
37
+
38
+ # One pass, left to right, so a quote inside a comment or comment syntax inside a string can't
39
+ # confuse the other: string literals (single-quoted and dollar-quoted) become "?", comments
40
+ # become a space.
41
+ LITERALS_AND_COMMENTS = %r{
42
+ '(?:[^']|'')*(?:'|\z)
43
+ | (?<tag>\$[A-Za-z_]*\$).*?(?:\k<tag>|\z)
44
+ | /\*.*?(?:\*/|\z)
45
+ | --[^\n]*
46
+ }mx
47
+ LOCKING_CLAUSE = /\bFOR\s+(?:NO\s+KEY\s+UPDATE|KEY\s+SHARE|UPDATE|SHARE)\b/i
48
+ DATA_MODIFYING = /\b(?:INSERT|UPDATE|DELETE|MERGE)\b/i
49
+
50
+ Job = Struct.new(:sql, :binds, :pool, :statement, :duration_ms, :captured_at, keyword_init: true)
51
+
52
+ class << self
53
+ # True on this class's own worker thread while it's running an EXPLAIN. Railtie's
54
+ # sql.active_record subscriber skips those queries entirely, so the EXPLAIN (and its BEGIN,
55
+ # SET LOCAL and ROLLBACK) never shows up as the app's own query timing, breadcrumbs or spans.
56
+ def explaining?
57
+ Thread.current[:forge_ops_tracker_explaining] == true
58
+ end
59
+
60
+ # Whether statement is a single plain SELECT: after dropping comments and string literals,
61
+ # it starts with SELECT, has no ";" other than one trailing one, no locking clause (FOR
62
+ # UPDATE, FOR SHARE, FOR NO KEY UPDATE, FOR KEY SHARE), and no INSERT, UPDATE, DELETE or
63
+ # MERGE anywhere (so no data-modifying CTE either).
64
+ def explainable?(statement)
65
+ return false unless statement.is_a?(String)
66
+
67
+ sql = statement.gsub(LITERALS_AND_COMMENTS) { |match| match.start_with?("'", "$") ? "?" : " " }.strip
68
+ return false unless sql.match?(/\ASELECT\b/i)
69
+ return false if sql.delete_suffix(";").include?(";")
70
+
71
+ !sql.match?(LOCKING_CLAUSE) && !sql.match?(DATA_MODIFYING)
72
+ end
73
+
74
+ # Masks every String inside a parsed EXPLAIN (FORMAT JSON) result with SqlStatement, leaving
75
+ # keys, numbers, booleans, arrays and the structure itself exactly as they were.
76
+ def mask_plan(value)
77
+ case value
78
+ when Hash then value.transform_values { |inner| mask_plan(inner) }
79
+ when Array then value.map { |inner| mask_plan(inner) }
80
+ when String then SqlStatement.mask(value) || value
81
+ else value
82
+ end
83
+ end
84
+ end
85
+
86
+ def initialize(configuration, client: Client.new(configuration), clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) })
87
+ @configuration = configuration
88
+ @client = client
89
+ @clock = clock
90
+ @queue = SizedQueue.new(QUEUE_SIZE)
91
+ @mutex = Mutex.new
92
+ @thread = nil
93
+ @last_explained = {}
94
+ @recent = []
95
+ end
96
+
97
+ # Called from the sql.active_record subscriber with that event's payload and duration. Cheap
98
+ # checks first, so a fast query costs one comparison. Returns true only when an EXPLAIN was
99
+ # queued; never raises.
100
+ def consider(payload, duration_ms:)
101
+ return false unless configuration.explain_slow_queries
102
+ return false unless duration_ms && duration_ms >= configuration.explain_threshold_ms.to_f
103
+
104
+ connection = payload[:connection]
105
+ system = PerformanceInstrumentation.db_system(connection)
106
+ return false unless system == "postgresql"
107
+
108
+ sql = payload[:sql]
109
+ return false unless self.class.explainable?(sql)
110
+
111
+ pool = connection.respond_to?(:pool) ? connection.pool : nil
112
+ return false unless pool
113
+
114
+ statement = SqlStatement.mask(sql, system: system)
115
+ return false unless reserve(statement)
116
+
117
+ enqueue(Job.new(
118
+ sql: sql.dup, binds: bind_values(payload), pool: pool, statement: statement,
119
+ duration_ms: duration_ms.to_f.round(1), captured_at: Time.now.utc.iso8601(3)
120
+ ))
121
+ rescue StandardError => e
122
+ log { "EXPLAIN skipped: #{e.class}: #{e.message}" }
123
+ false
124
+ end
125
+
126
+ # Runs one queued EXPLAIN and delivers its plan. Normally called on the worker thread; public so
127
+ # a test can run a job synchronously. Never raises.
128
+ def perform(job)
129
+ plan = explain(job)
130
+ return false unless plan
131
+
132
+ masked_plan = self.class.mask_plan(plan)
133
+ if JSON.generate(masked_plan).bytesize > MAX_PLAN_BYTES
134
+ log { "EXPLAIN plan over #{MAX_PLAN_BYTES} bytes, dropped" }
135
+ return false
136
+ end
137
+
138
+ client.deliver_query_plan(
139
+ statement: job.statement, db_system: "postgresql", plan: masked_plan,
140
+ duration_ms: job.duration_ms, environment: configuration.environment, captured_at: job.captured_at
141
+ )
142
+ rescue StandardError => e
143
+ log { "EXPLAIN failed: #{e.class}: #{e.message}" }
144
+ false
145
+ end
146
+
147
+ private
148
+ attr_reader :configuration, :client
149
+
150
+ # Both rate limits at once, under one lock, so two threads finishing the same slow query
151
+ # together still only explain it once.
152
+ def reserve(statement)
153
+ return false if statement.nil?
154
+
155
+ @mutex.synchronize do
156
+ now = @clock.call
157
+ @recent.reject! { |at| now - at >= GLOBAL_WINDOW }
158
+ return false if @recent.size >= GLOBAL_LIMIT
159
+
160
+ last = @last_explained[statement]
161
+ return false if last && now - last < STATEMENT_INTERVAL
162
+
163
+ if @last_explained.size >= MAX_REMEMBERED_STATEMENTS
164
+ @last_explained.reject! { |_, at| now - at >= STATEMENT_INTERVAL }
165
+ end
166
+ @last_explained[statement] = now
167
+ @recent << now
168
+ true
169
+ end
170
+ end
171
+
172
+ # The values the app's own query was run with, already type cast for the database. Rails
173
+ # passes these either as an Array or as a lambda that builds one.
174
+ def bind_values(payload)
175
+ binds = payload[:type_casted_binds]
176
+ binds = binds.call if binds.respond_to?(:call)
177
+ Array(binds).dup
178
+ end
179
+
180
+ def enqueue(job)
181
+ ensure_worker_started
182
+ @queue.push(job, true)
183
+ true
184
+ rescue ThreadError
185
+ log { "EXPLAIN queue full, dropping" }
186
+ false
187
+ end
188
+
189
+ def ensure_worker_started
190
+ return if @thread&.alive?
191
+
192
+ @mutex.synchronize do
193
+ return if @thread&.alive?
194
+
195
+ @thread = Thread.new { run }
196
+ @thread.abort_on_exception = false
197
+ end
198
+ end
199
+
200
+ def run
201
+ Thread.current[:forge_ops_tracker_explaining] = true
202
+ loop do
203
+ perform(@queue.pop)
204
+ rescue StandardError => e
205
+ log { "EXPLAIN thread error: #{e.class}: #{e.message}" }
206
+ end
207
+ end
208
+
209
+ # The parsed plan (the JSON array Postgres returns), or nil. The connection is checked out
210
+ # explicitly rather than borrowed with with_connection, so it's never one this thread (or
211
+ # any other) already holds. Checked back in only once its transaction is rolled back; if
212
+ # even the ROLLBACK fails, the connection is removed from the pool and closed instead, so a
213
+ # connection in an unknown state is never handed to the app.
214
+ def explain(job)
215
+ connection = job.pool.checkout(CHECKOUT_TIMEOUT)
216
+ begin
217
+ connection.execute("BEGIN READ ONLY")
218
+ connection.execute("SET LOCAL statement_timeout = '#{STATEMENT_TIMEOUT}'")
219
+ result = connection.exec_query("EXPLAIN (FORMAT JSON) #{job.sql}", "EXPLAIN", job.binds)
220
+ value = result.rows.first&.first
221
+ value.is_a?(String) ? JSON.parse(value) : value
222
+ ensure
223
+ release(job.pool, connection, rollback(connection))
224
+ end
225
+ end
226
+
227
+ def rollback(connection)
228
+ connection.execute("ROLLBACK")
229
+ true
230
+ rescue StandardError => e
231
+ log { "EXPLAIN rollback failed: #{e.class}: #{e.message}" }
232
+ false
233
+ end
234
+
235
+ def release(pool, connection, healthy)
236
+ if healthy
237
+ pool.checkin(connection)
238
+ else
239
+ pool.remove(connection)
240
+ connection.disconnect!
241
+ end
242
+ rescue StandardError => e
243
+ log { "EXPLAIN connection release failed: #{e.class}: #{e.message}" }
244
+ end
245
+
246
+ def log
247
+ configuration.logger&.debug { "[ForgeOpsTracker] #{yield}" }
248
+ end
249
+ end
250
+ end
@@ -28,6 +28,11 @@ module ForgeOpsTracker
28
28
  def span_queue
29
29
  @span_queue ||= ForgeOpsTracker::SpanQueue.new(ForgeOpsTracker.configuration)
30
30
  end
31
+
32
+ # One per process, so its rate limits (see QueryExplainer) are per process too.
33
+ def query_explainer
34
+ @query_explainer ||= ForgeOpsTracker::QueryExplainer.new(ForgeOpsTracker.configuration)
35
+ end
31
36
  end
32
37
 
33
38
  initializer "forge_ops_tracker.subscribe_error_reporter" do |app|
@@ -167,6 +172,9 @@ module ForgeOpsTracker
167
172
  end
168
173
 
169
174
  ActiveSupport::Notifications.subscribe("sql.active_record") do |*args|
175
+ # QueryExplainer's own EXPLAIN (and its BEGIN/SET LOCAL/ROLLBACK), on its own thread: not
176
+ # the app's query, so never timed, traced or explained itself.
177
+ next if QueryExplainer.explaining?
170
178
  next unless configuration.enabled?
171
179
 
172
180
  event = ActiveSupport::Notifications::Event.new(*args)
@@ -184,13 +192,19 @@ module ForgeOpsTracker
184
192
  # A leaf span, parented onto whatever's currently open (the controller root, or a
185
193
  # manually-wrapped ForgeOpsTracker.span block around it): a query has no children of its
186
194
  # own to nest anything under, so unlike the controller root above, this never touches the
187
- # stack itself, just records against whatever's already on top of it.
195
+ # stack itself, just records against whatever's already on top of it. Carries the masked
196
+ # SQL and the database system (see PerformanceInstrumentation.database_span_data).
188
197
  if configuration.track_tracing && (buffer = Thread.current[:forge_ops_tracker_spans])
189
198
  buffer.record(
190
199
  span_id: SecureRandom.hex(8), name: transaction_name, kind: "database",
191
- started_at: Time.at(event.time), duration_ms: event.duration
200
+ started_at: Time.at(event.time), duration_ms: event.duration,
201
+ data: PerformanceInstrumentation.database_span_data(event.payload)
192
202
  )
193
203
  end
204
+
205
+ # Opt-in, PostgreSQL only; a no-op unless config.explain_slow_queries is on. Only reads the
206
+ # payload here; the EXPLAIN itself runs later on QueryExplainer's own thread and connection.
207
+ Railtie.query_explainer.consider(event.payload, duration_ms: event.duration) if configuration.explain_slow_queries
194
208
  end
195
209
 
196
210
  ActiveSupport::Notifications.subscribe("perform.active_job") do |*args|
@@ -15,11 +15,19 @@ module ForgeOpsTracker
15
15
  MAX_NAME_LENGTH = 200
16
16
  MAX_CAUSE_DEPTH = 5
17
17
 
18
- LITERAL = /
19
- '(?:[^']|'')*(?:'|\z) # string literal, or one cut off by truncation
20
- | (?<tag>\$[A-Za-z_]*\$).*?(?:\k<tag>|\z) # dollar-quoted string
21
- | (?<![\w$.])\d+(?:\.\d+)?(?!\w) # number, not part of an identifier or placeholder
22
- /mx
18
+ # db.system values where "double quotes" are a string, not a name.
19
+ DOUBLE_QUOTED_STRING_SYSTEMS = %w[mysql mariadb].freeze
20
+
21
+ # The prefix only counts as one when it isn't the end of a word, but the quote always does:
22
+ # LIKE'%x%', with no space, is still a string. A backslash can be the statement's last character
23
+ # ('secret\ cut off mid-escape), and the string is still masked to the end.
24
+ STRING = /(?:(?<![\w$])(?:[EeXxNnBb]|[Uu]&))?'(?:[^'\\]|\\(?:.|\z)|'')*(?:'|\z)/m
25
+ DOUBLE_QUOTED_STRING = /"(?:[^"\\]|\\(?:.|\z)|"")*(?:"|\z)/m
26
+ DOLLAR_QUOTED = /(?<tag>\$[A-Za-z_]*\$).*?(?:\k<tag>|\z)/m
27
+ NUMBER = /(?<![\w$.])(?:0[xX]\h+|0[bB][01]+|(?:\d+(?:\.\d+)?|\.\d+)(?:[eE][+-]?\d+)?)(?!\w)/
28
+
29
+ LITERAL = Regexp.union(STRING, DOLLAR_QUOTED, NUMBER)
30
+ LITERAL_WITH_DOUBLE_QUOTES = Regexp.union(STRING, DOUBLE_QUOTED_STRING, DOLLAR_QUOTED, NUMBER)
23
31
 
24
32
  PART = /(?:[\w$#@]+|"[^"]+"|\[[^\]]+\]|`[^`]+`)/
25
33
  NAME = /#{PART}(?:\.#{PART})*/
@@ -51,10 +59,13 @@ module ForgeOpsTracker
51
59
  nil
52
60
  end
53
61
 
54
- def mask(statement)
62
+ # system is the query's db.system, when known: on MySQL and MariaDB "double quotes" are a
63
+ # string too, so they're masked there and left alone everywhere else.
64
+ def mask(statement, system: nil)
55
65
  return nil if statement.nil? || statement.strip.empty?
56
66
 
57
- masked = statement.gsub(LITERAL, MASK)
67
+ pattern = DOUBLE_QUOTED_STRING_SYSTEMS.include?(system.to_s.downcase) ? LITERAL_WITH_DOUBLE_QUOTES : LITERAL
68
+ masked = statement.gsub(pattern, MASK)
58
69
  masked.length > MAX_LENGTH ? "#{masked[0, MAX_LENGTH]}..." : masked
59
70
  end
60
71
 
@@ -1,3 +1,3 @@
1
1
  module ForgeOpsTracker
2
- VERSION = "0.13.0"
2
+ VERSION = "0.15.0"
3
3
  end
@@ -20,6 +20,7 @@ require "forge_ops_tracker/periodic_poller"
20
20
  require "forge_ops_tracker/breadcrumb_buffer"
21
21
  require "forge_ops_tracker/span_buffer"
22
22
  require "forge_ops_tracker/span_queue"
23
+ require "forge_ops_tracker/query_explainer"
23
24
  require "forge_ops_tracker/trace_parent"
24
25
  require "forge_ops_tracker/request_state"
25
26
  require "forge_ops_tracker/middleware/session_tracking"
@@ -128,12 +129,18 @@ module ForgeOpsTracker
128
129
  # a standalone buffer for itself: a lone span with no request-level trace around it, and no
129
130
  # middleware left running to ever flush it, would just accumulate in this thread's memory
130
131
  # forever with nothing to ever send it, which is worse than never recording it at all.
131
- def span(name, kind: "service", data: {})
132
+ #
133
+ # A span with kind: "database" can also carry the SQL it ran and which database it ran on, e.g.
134
+ # `ForgeOpsTracker.span("Load orders", kind: "database", statement: sql, db_system: "postgresql")`.
135
+ # The statement is masked by SqlStatement before it's kept (every string and number becomes
136
+ # "?"), so no value it was run with is ever sent. Both are ignored on any other kind of span.
137
+ def span(name, kind: "service", data: {}, statement: nil, db_system: nil)
132
138
  return yield unless configuration.enabled? && configuration.track_tracing
133
139
 
134
140
  buffer = Thread.current[:forge_ops_tracker_spans]
135
141
  return yield unless buffer
136
142
 
143
+ data = database_span_data(data, statement, db_system) if kind.to_s == "database"
137
144
  span_id = SecureRandom.hex(8)
138
145
  started_at = Time.now.utc
139
146
  buffer.push(span_id)
@@ -147,6 +154,15 @@ module ForgeOpsTracker
147
154
  end
148
155
 
149
156
  private
157
+ def database_span_data(data, statement, db_system)
158
+ system = (db_system.to_s.strip.downcase unless db_system.nil? || db_system.to_s.strip.empty?)
159
+ extra = {
160
+ "db.statement" => (SqlStatement.mask(statement.to_s, system: system) unless statement.nil?),
161
+ "db.system" => system
162
+ }.compact
163
+ extra.empty? ? data : data.merge(extra)
164
+ end
165
+
150
166
  def change_queue
151
167
  @change_queue ||= DeliveryQueue.new(configuration, deliver_with: :deliver_change)
152
168
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: forge_ops_tracker
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.13.0
4
+ version: 0.15.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - ForgeOps
@@ -29,14 +29,14 @@ dependencies:
29
29
  requirements:
30
30
  - - "~>"
31
31
  - !ruby/object:Gem::Version
32
- version: '7.0'
32
+ version: '8.1'
33
33
  type: :development
34
34
  prerelease: false
35
35
  version_requirements: !ruby/object:Gem::Requirement
36
36
  requirements:
37
37
  - - "~>"
38
38
  - !ruby/object:Gem::Version
39
- version: '7.0'
39
+ version: '8.1'
40
40
  - !ruby/object:Gem::Dependency
41
41
  name: puma
42
42
  requirement: !ruby/object:Gem::Requirement
@@ -116,6 +116,7 @@ files:
116
116
  - lib/forge_ops_tracker/performance_instrumentation.rb
117
117
  - lib/forge_ops_tracker/periodic_poller.rb
118
118
  - lib/forge_ops_tracker/pii_scrubber.rb
119
+ - lib/forge_ops_tracker/query_explainer.rb
119
120
  - lib/forge_ops_tracker/railtie.rb
120
121
  - lib/forge_ops_tracker/request_state.rb
121
122
  - lib/forge_ops_tracker/session_flusher.rb