forge_ops_tracker 0.12.0 → 0.14.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: 70707edca2368561ce9b785b91dc9e8a26280071ec3db9ed34fa71db6c402fcf
4
- data.tar.gz: a6fc1013df0404e46e0683749f2cedd688ac30273bda4ac113a7dd6334a19430
3
+ metadata.gz: 5b7f1809933ffeccac536f117de7c234002826fe29414fc3fa92f12c22bb1a18
4
+ data.tar.gz: 84a9edadbaeb72dbe62164a84f26c7f23b6a600f176126ca82370709dc72b5c3
5
5
  SHA512:
6
- metadata.gz: 420ff144de5e84636348c245fef836cf240c94fe6bccb49217f8d51455a3c08ce81c18dadcf7260ccc46f35dd7b2470d809b1eecd92d9bea70e17ecf149f1a78
7
- data.tar.gz: 74fc60b3d7451827618e0d63deef8cd6563d29eab144c8556bc912e91bbb4a7c8467110ae25940a7e4a7457db8718cad8b763ff752311014ed325b22ce5c13b0
6
+ metadata.gz: ca419d26d17f08e4544bb213c2949dedf7001402f163a15b0def3c0942ffcc534637911d1bc7e8890f6a3dc556f174a3ecea703351fe291d492c1c151838e3f9
7
+ data.tar.gz: 6890f1adbbf73ae399699ee3482a005bbfee521909c6af6acedb8a805a2aa69c3d46f75bf33a3a4722be9f0e6e84a0263850729e42380d1d1b26e23e50f285cb
data/CHANGELOG.md CHANGED
@@ -1,5 +1,37 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.14.0 (2026-09-25)
4
+
5
+ - Every Active Record database span now carries the query's SQL (`db.statement`), masked so every
6
+ string and number is `?` and bind values are never sent, and the database it ran on
7
+ (`db.system`: `postgresql`, `mysql`, `sqlite`, `mssql`, `oracle`, or the adapter's own name).
8
+ - `ForgeOpsTracker.span` takes optional `statement:` and `db_system:` on a `kind: "database"`
9
+ span, masked the same way.
10
+ - New opt-in `config.explain_slow_queries` (default false) and `config.explain_threshold_ms`
11
+ (default 500). On PostgreSQL, a single plain `SELECT` at least that slow gets a plain
12
+ `EXPLAIN (FORMAT JSON)` (never `EXPLAIN ANALYZE`), run on a background thread with its own
13
+ pooled connection, in a read-only transaction with a 2 second statement timeout that is always
14
+ rolled back. The plan is sent with every string in it masked. Limited to once per 10 minutes per
15
+ distinct query and 10 per minute per process; plans over 64 KB are dropped; any failure is
16
+ logged at debug and never raised.
17
+
18
+ ## 0.13.0 (2026-09-25)
19
+
20
+ - New `ForgeOpsTracker.record_change(kind:, title:, details: {}, environment: nil, service: nil,
21
+ actor: nil, url: nil, id: nil, occurred_at: nil)` records something that changed in your system
22
+ (a feature flag, a config value, a hand-run migration) so ForgeOps can show it next to the errors
23
+ that followed. `kind` is one of `feature_flag`, `config`, `migration`, `dependency`,
24
+ `infrastructure`, or `other`; anything else is sent as `other`. Sent from a background thread,
25
+ never raises, and a no-op wherever reporting is off.
26
+ - Changes between deploys are now detected automatically. Once per process, after Rails finishes
27
+ booting (or on `ForgeOpsTracker.configure` outside Rails), a background thread sends a snapshot of
28
+ the Ruby version, the gem versions in `Gemfile.lock`, and the current Active Record migration
29
+ version, and ForgeOps records whatever changed since the previous boot. New
30
+ `config.detect_changes` (default true) turns this off.
31
+ - New `config.track_env_var_names` (default false) adds environment variable names, never values,
32
+ to that snapshot. Host-specific names (`HOSTNAME`, `PATH`, `PORT`, `LC_*`, Kubernetes service
33
+ variables, and others) and the gem's own `FORGE_OPS_*` settings are always left out.
34
+
3
35
  ## 0.12.0
4
36
 
5
37
  - Every error event reported during a request now says where it happened: `transaction_name` (the
data/README.md CHANGED
@@ -411,6 +411,116 @@ 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. ForgeOps masks it again on
420
+ arrival. The database comes from the connection's adapter (`postgresql`, `mysql`, `sqlite`,
421
+ `mssql`, `oracle`, or the adapter's own name otherwise).
422
+
423
+ Spans you create yourself can carry SQL too. Pass `statement:` (and optionally `db_system:`) on a
424
+ `kind: "database"` span; it's masked the same way:
425
+
426
+ ```ruby
427
+ sql = ActiveRecord::Base.sanitize_sql_array(
428
+ [ "SELECT * FROM orders WHERE customer_id = ? AND status = ?", customer.id, "open" ]
429
+ )
430
+
431
+ ForgeOpsTracker.span("Load open orders", kind: "database", statement: sql, db_system: "postgresql") do
432
+ ReportingDatabase.connection.select_all(sql)
433
+ end
434
+ # Sent as "SELECT * FROM orders WHERE customer_id = ? AND status = ?"
435
+ ```
436
+
437
+ ### Query plans for slow queries (PostgreSQL)
438
+
439
+ Opt in, and a slow `SELECT` also sends Postgres's plan for it, so ForgeOps can show why it was
440
+ slow (a sequential scan over a big table, a missing index, a bad join order):
441
+
442
+ ```ruby
443
+ ForgeOpsTracker.configure do |config|
444
+ config.explain_slow_queries = true # default false
445
+ config.explain_threshold_ms = 500 # default 500; only queries at least this slow
446
+ end
447
+ ```
448
+
449
+ What it does:
450
+
451
+ - For a query that took at least `explain_threshold_ms`, runs `EXPLAIN (FORMAT JSON)` with the
452
+ same SQL and bind values, and sends the plan with the query's masked SQL. Every string inside the
453
+ plan (a filter like `(email = 'a@b.co'::text)`) is masked the same way before it's sent. A plan
454
+ over 64 KB is dropped rather than sent.
455
+ - Runs on the gem's own background thread, on a connection it checks out of your app's pool itself,
456
+ inside its own `READ ONLY` transaction with a 2 second `statement_timeout`, always rolled back.
457
+ Never on the connection your request is using, and never inside your request's transaction.
458
+ - At most once per 10 minutes for each distinct (masked) query, and at most 10 per minute per
459
+ process overall.
460
+
461
+ What it doesn't do:
462
+
463
+ - It never runs `EXPLAIN ANALYZE`, so your query is planned again but never executed again.
464
+ - It only explains a single plain `SELECT`: nothing that starts with anything else (including
465
+ `WITH`), nothing with a second statement, no `FOR UPDATE`/`FOR SHARE` (or `FOR NO KEY UPDATE`/
466
+ `FOR KEY SHARE`), and nothing with `INSERT`, `UPDATE`, `DELETE` or `MERGE` anywhere in it.
467
+ - It only runs on PostgreSQL. Other databases are left alone.
468
+ - It never raises into your app. A timeout, a permission error, an exhausted connection pool or
469
+ anything else is logged at debug level and skipped.
470
+ - The original SQL and its bind values are only held in memory until the `EXPLAIN` runs. Only the
471
+ masked SQL and the masked plan are sent.
472
+
473
+ It does use one pooled connection, briefly, for each plan. With the limits above that's at most 10
474
+ short checkouts a minute, but on a pool that's already at capacity, leave it off or raise
475
+ `explain_threshold_ms`. Plans are part of performance monitoring; on a plan without it, they're
476
+ rejected server-side and dropped.
477
+
478
+ ## What changed
479
+
480
+ ForgeOps can show what changed in your system next to the errors and slowdowns that followed it.
481
+ Two ways in:
482
+
483
+ **Record a change yourself** when something changes that no deploy captures, like a feature flag
484
+ flipped, a config value edited, or a migration run by hand:
485
+
486
+ ```ruby
487
+ ForgeOpsTracker.record_change(
488
+ kind: "feature_flag", # feature_flag, config, migration, dependency, infrastructure, or other
489
+ title: "Enabled new_checkout for 10% of users",
490
+ details: { flag: "new_checkout", rollout_percent: 10 },
491
+ actor: current_user.email,
492
+ url: "https://flags.example.com/new_checkout"
493
+ )
494
+ ```
495
+
496
+ `kind` and `title` are required; `details`, `environment` (defaults to `config.environment`),
497
+ `service`, `actor`, `url`, `id` (an idempotency key, so sending the same change twice records it
498
+ once), and `occurred_at` (defaults to now) are optional. An unknown `kind` is sent as `other`. It's
499
+ queued and sent from a background thread, so it never slows down the caller, never raises, and is
500
+ a no-op wherever reporting is off.
501
+
502
+ **Changes between deploys are detected for you.** Once per process, right after Rails finishes
503
+ booting, a background thread sends ForgeOps a snapshot of what the app is running: the Ruby
504
+ version, every gem version in your `Gemfile.lock`, and the current Active Record migration version
505
+ (left out if the database can't be reached). ForgeOps compares it with the previous boot's and
506
+ records whatever changed, such as a gem upgrade or a new migration. Outside Rails the snapshot goes
507
+ out when you call `ForgeOpsTracker.configure`, without the migration version.
508
+
509
+ ```ruby
510
+ ForgeOpsTracker.configure do |config|
511
+ config.detect_changes = true # default; false sends no startup snapshot
512
+ config.track_env_var_names = false # default; true also sends environment variable names
513
+ end
514
+ ```
515
+
516
+ With `track_env_var_names` on, the snapshot lists the names of your environment variables (never
517
+ their values), so an added or removed variable shows up as a change. Names that differ from host to
518
+ host, like `HOSTNAME`, `PATH`, `PORT`, `LC_*`, and Kubernetes service variables, are left out, as
519
+ are the gem's own `FORGE_OPS_*` settings.
520
+
521
+ Requires a ForgeOps plan that includes change tracking; on a plan that doesn't, both are rejected
522
+ server-side and dropped, exactly like any other delivery failure.
523
+
414
524
  ## Custom metrics
415
525
 
416
526
  Unlike session/performance tracking above, there's no automatic instrumentation here at all: a
@@ -0,0 +1,47 @@
1
+ require "time"
2
+
3
+ module ForgeOpsTracker
4
+ # Builds the body for one ForgeOpsTracker.record_change call (POST /api/v1/changes): something
5
+ # that changed in a running system (a feature flag flipped, a config value edited, a migration
6
+ # run by hand), so ForgeOps can line it up against the errors and slowdowns around it.
7
+ module Change
8
+ # The server rejects any other kind outright (422), so an unknown one is sent as "other"
9
+ # rather than dropped: the change still gets recorded, just less specifically categorized.
10
+ KINDS = %w[feature_flag config migration dependency infrastructure other].freeze
11
+ MAX_TITLE_LENGTH = 200
12
+
13
+ module_function
14
+
15
+ # Returns nil when there's nothing sendable (a blank title): the server requires one, so
16
+ # posting it anyway would only ever come back 422.
17
+ def build(configuration, kind:, title:, details: {}, environment: nil, service: nil, actor: nil, url: nil, id: nil, occurred_at: nil)
18
+ title = title.to_s.strip
19
+ return nil if title.empty?
20
+
21
+ {
22
+ kind: normalize_kind(kind),
23
+ title: title[0, MAX_TITLE_LENGTH],
24
+ details: details.is_a?(Hash) ? details : {},
25
+ environment: (environment || configuration.environment)&.to_s,
26
+ service: service&.to_s,
27
+ actor: actor&.to_s,
28
+ url: url&.to_s,
29
+ id: id&.to_s,
30
+ occurred_at: iso8601(occurred_at || Time.now)
31
+ }.compact
32
+ end
33
+
34
+ def normalize_kind(kind)
35
+ kind = kind.to_s
36
+ KINDS.include?(kind) ? kind : "other"
37
+ end
38
+
39
+ def iso8601(value)
40
+ case value
41
+ when Time then value.utc.iso8601(3)
42
+ when String then value
43
+ else value.respond_to?(:to_time) ? value.to_time.utc.iso8601(3) : Time.now.utc.iso8601(3)
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,133 @@
1
+ require "thread"
2
+
3
+ module ForgeOpsTracker
4
+ # Tells ForgeOps what this process sees at startup (the Ruby version, the locked gem versions, the
5
+ # current Active Record migration version, and optionally the names of its environment variables),
6
+ # once per process, from a background thread. The server diffs it against the last snapshot for
7
+ # the same project and environment and records whatever changed as a change, so this gem stays
8
+ # stateless: it never remembers what it sent last time.
9
+ #
10
+ # Any key that can't be determined reliably is left out rather than guessed at, since the server
11
+ # reads a missing key as "unknown", never as "everything was removed".
12
+ class ChangeSnapshot
13
+ MAX_DEPENDENCIES = 3000
14
+ MAX_NAME_LENGTH = 200
15
+ MAX_VERSION_LENGTH = 100
16
+ MAX_ENV_VAR_NAMES = 3000
17
+
18
+ # Host-specific variables that differ from one box to the next (or one boot to the next) without
19
+ # anything about the deploy having changed, so a fleet doesn't look like it's changing on every
20
+ # restart. The gem's own FORGE_OPS_* settings are dropped too.
21
+ ENV_VAR_DENYLIST = %w[
22
+ HOSTNAME HOST HOME PATH PWD OLDPWD SHLVL _ TERM USER LOGNAME SHELL LANG TMPDIR TZ PORT DYNO
23
+ INVOCATION_ID JOURNAL_STREAM
24
+ ].freeze
25
+ ENV_VAR_DENYLIST_PATTERNS = [
26
+ /\ALC_/, /\ASYSTEMD_/, /\AMEMORY_PRESSURE_/, /\AKUBERNETES_/, /\AFORGE_OPS_/,
27
+ /_SERVICE_HOST\z/, /_SERVICE_PORT/, /_PORT_.*_TCP/
28
+ ].freeze
29
+
30
+ MUTEX = Mutex.new
31
+
32
+ class << self
33
+ # Starts the one snapshot this process sends, on its own thread so it never delays boot. A
34
+ # second call is a no-op, so the Railtie and a plain ForgeOpsTracker.configure can't both
35
+ # send one. Does nothing (and doesn't use up the once) when the gem isn't enabled or
36
+ # detect_changes is off. Never raises.
37
+ def start(configuration)
38
+ return false unless configuration.enabled? && configuration.detect_changes
39
+
40
+ MUTEX.synchronize do
41
+ return false if @started
42
+
43
+ @started = true
44
+ end
45
+
46
+ thread = Thread.new { new(configuration).deliver }
47
+ thread.report_on_exception = false
48
+ true
49
+ rescue StandardError
50
+ false
51
+ end
52
+
53
+ # Names only, never values, sorted so the same set always serializes the same way.
54
+ def env_var_names(env = ENV)
55
+ env.keys.reject { |name| denied_env_var?(name) }.sort.first(MAX_ENV_VAR_NAMES)
56
+ end
57
+
58
+ def denied_env_var?(name)
59
+ ENV_VAR_DENYLIST.include?(name) || ENV_VAR_DENYLIST_PATTERNS.any? { |pattern| pattern.match?(name) }
60
+ end
61
+ end
62
+
63
+ def initialize(configuration, client: Client.new(configuration))
64
+ @configuration = configuration
65
+ @client = client
66
+ end
67
+
68
+ def deliver
69
+ client.deliver_change_snapshot(payload)
70
+ rescue StandardError => e
71
+ configuration.logger&.debug { "[ForgeOpsTracker] change snapshot failed: #{e.class}: #{e.message}" }
72
+ false
73
+ end
74
+
75
+ def payload
76
+ {
77
+ environment: configuration.environment.to_s,
78
+ state: {
79
+ schema_version: schema_version,
80
+ runtime: runtime,
81
+ dependencies: dependencies,
82
+ env_var_names: configuration.track_env_var_names ? self.class.env_var_names : nil
83
+ }.compact
84
+ }
85
+ end
86
+
87
+ def runtime
88
+ "#{RUBY_ENGINE} #{RUBY_ENGINE_VERSION}"
89
+ end
90
+
91
+ # The app's Gemfile.lock (through Bundler, so it's the exact resolved versions), falling back to
92
+ # whatever gems are actually activated when there's no bundle at all. nil, and so left out,
93
+ # when neither is available.
94
+ def dependencies
95
+ specs = bundler_specs || Gem.loaded_specs.values
96
+ return nil if specs.empty?
97
+
98
+ specs.each_with_object({}) do |spec, deps|
99
+ deps[spec.name.to_s[0, MAX_NAME_LENGTH]] = spec.version.to_s[0, MAX_VERSION_LENGTH]
100
+ end.sort.first(MAX_DEPENDENCIES).to_h
101
+ rescue StandardError
102
+ nil
103
+ end
104
+
105
+ # The newest migration Active Record has run against this app's database, as a string. Left
106
+ # out when Active Record isn't loaded, the database can't be reached, or nothing has been
107
+ # migrated yet: a snapshot must never fail (or fail boot) because the database is down.
108
+ def schema_version
109
+ return nil unless defined?(::ActiveRecord::Base)
110
+
111
+ version = ::ActiveRecord::Base.connection_pool.with_connection do |connection|
112
+ pool = ::ActiveRecord::Base.connection_pool
113
+ context = pool.respond_to?(:migration_context) ? pool.migration_context : connection.migration_context
114
+ context.current_version
115
+ end
116
+ version.to_i.positive? ? version.to_s : nil
117
+ rescue StandardError, ScriptError
118
+ nil
119
+ end
120
+
121
+ private
122
+ attr_reader :configuration, :client
123
+
124
+ def bundler_specs
125
+ return nil unless defined?(::Bundler)
126
+
127
+ specs = ::Bundler.locked_gems&.specs
128
+ specs.nil? || specs.empty? ? nil : specs
129
+ rescue StandardError
130
+ nil
131
+ end
132
+ end
133
+ end
@@ -60,6 +60,23 @@ module ForgeOpsTracker
60
60
  post(configuration.spans_uri, { trace_id: trace_id, spans: spans })
61
61
  end
62
62
 
63
+ # Same delivery contract again; see Configuration#changes_uri and ForgeOpsTracker.record_change.
64
+ # One change per call. A plan without change tracking answers 403, which is just a false here.
65
+ def deliver_change(payload)
66
+ post(configuration.changes_uri, payload)
67
+ end
68
+
69
+ # Same again; see Configuration#change_snapshots_uri and ChangeSnapshot.
70
+ def deliver_change_snapshot(payload)
71
+ post(configuration.change_snapshots_uri, payload)
72
+ end
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
+
63
80
  private
64
81
  attr_reader :configuration
65
82
 
@@ -17,7 +17,9 @@ 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
22
+ attr_accessor :detect_changes, :track_env_var_names
21
23
 
22
24
  def initialize
23
25
  @dsn = ENV["FORGE_OPS_DSN"]
@@ -125,6 +127,13 @@ module ForgeOpsTracker
125
127
  # PerformanceSample-derived threshold elsewhere in this gem: those describe a trend across
126
128
  # many requests, this describes a single one being outright slow.
127
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
128
137
  # Adds a W3C `traceparent` header (https://www.w3.org/TR/trace-context/) to every outbound
129
138
  # Net::HTTP request made during a request, so the service on the other end can continue this
130
139
  # request's trace instead of starting its own (see Integrations::NetHTTP). On by default, the
@@ -138,6 +147,15 @@ module ForgeOpsTracker
138
147
  # Regexp is matched against the host. Worth narrowing for an app that calls third-party APIs
139
148
  # which reject unknown headers, or that shouldn't learn this app's trace ids at all.
140
149
  @trace_propagation_targets = nil
150
+ # Sends one ChangeSnapshot per process at startup (Ruby version, locked gem versions, the
151
+ # current migration version), which ForgeOps diffs against the previous boot's to record what
152
+ # changed between deploys. On by default, like every other automatic behavior here.
153
+ @detect_changes = true
154
+ # Whether that snapshot also lists the names of this process's environment variables, so an
155
+ # added or removed variable shows up as a change. Off by default: names only, never values,
156
+ # but even names can describe more about an app than some teams want to share. See
157
+ # ChangeSnapshot::ENV_VAR_DENYLIST for the host-specific names that are always left out.
158
+ @track_env_var_names = false
141
159
  end
142
160
 
143
161
  # Whether an outbound request to host should carry a traceparent header; see
@@ -231,11 +249,34 @@ module ForgeOpsTracker
231
249
  uri
232
250
  end
233
251
 
252
+ def query_plans_uri
253
+ sibling_uri("/query_plans")
254
+ end
255
+
256
+ def changes_uri
257
+ sibling_uri("/changes")
258
+ end
259
+
260
+ def change_snapshots_uri
261
+ sibling_uri("/change_snapshots")
262
+ end
263
+
234
264
  def enabled?
235
265
  !blank?(dsn) && !blank?(api_key) && enabled_environments.map(&:to_s).include?(environment.to_s)
236
266
  end
237
267
 
238
268
  private
269
+ # Same substitution every *_uri method above spells out by hand: a sibling path under the
270
+ # DSN's own /api/v1/events.
271
+ def sibling_uri(path)
272
+ uri = ingestion_uri
273
+ return nil unless uri
274
+
275
+ uri = uri.dup
276
+ uri.path = uri.path.sub(%r{/events\z}, path)
277
+ uri
278
+ end
279
+
239
280
  def parsed_dsn
240
281
  return nil if blank?(dsn)
241
282
 
@@ -8,9 +8,12 @@ module ForgeOpsTracker
8
8
  # in each forked Puma/Passenger worker process rather than being carried
9
9
  # across a fork, which would leave it dead in the child.
10
10
  class DeliveryQueue
11
- def initialize(configuration, client: Client.new(configuration))
11
+ # deliver_with names the Client method each payload goes through: :deliver (error events) by
12
+ # default, :deliver_change for ForgeOpsTracker.record_change's own queue.
13
+ def initialize(configuration, client: Client.new(configuration), deliver_with: :deliver)
12
14
  @configuration = configuration
13
15
  @client = client
16
+ @deliver_with = deliver_with
14
17
  @queue = SizedQueue.new(configuration.queue_size)
15
18
  @mutex = Mutex.new
16
19
  @thread = nil
@@ -44,7 +47,7 @@ module ForgeOpsTracker
44
47
 
45
48
  def run
46
49
  loop do
47
- client.deliver(@queue.pop)
50
+ client.public_send(@deliver_with, @queue.pop)
48
51
  rescue StandardError => e
49
52
  log { "delivery thread error: #{e.class}: #{e.message}" }
50
53
  end
@@ -70,6 +70,38 @@ 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
+ {
88
+ "db.statement" => SqlStatement.mask(payload[:sql].to_s),
89
+ "db.system" => db_system(payload[:connection])
90
+ }.compact
91
+ end
92
+
93
+ # payload[:connection] is the adapter instance the query ran on (Rails 6.0 and later).
94
+ def db_system(connection)
95
+ return nil unless connection.respond_to?(:adapter_name)
96
+
97
+ name = connection.adapter_name.to_s.downcase
98
+ return nil if name.empty?
99
+
100
+ DB_SYSTEMS.fetch(name, name)
101
+ rescue StandardError
102
+ nil
103
+ end
104
+
73
105
  def job_transaction_name(payload)
74
106
  payload[:job].class.name
75
107
  end
@@ -0,0 +1,249 @@
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
+ return false unless PerformanceInstrumentation.db_system(connection) == "postgresql"
106
+
107
+ sql = payload[:sql]
108
+ return false unless self.class.explainable?(sql)
109
+
110
+ pool = connection.respond_to?(:pool) ? connection.pool : nil
111
+ return false unless pool
112
+
113
+ statement = SqlStatement.mask(sql)
114
+ return false unless reserve(statement)
115
+
116
+ enqueue(Job.new(
117
+ sql: sql.dup, binds: bind_values(payload), pool: pool, statement: statement,
118
+ duration_ms: duration_ms.to_f.round(1), captured_at: Time.now.utc.iso8601(3)
119
+ ))
120
+ rescue StandardError => e
121
+ log { "EXPLAIN skipped: #{e.class}: #{e.message}" }
122
+ false
123
+ end
124
+
125
+ # Runs one queued EXPLAIN and delivers its plan. Normally called on the worker thread; public so
126
+ # a test can run a job synchronously. Never raises.
127
+ def perform(job)
128
+ plan = explain(job)
129
+ return false unless plan
130
+
131
+ masked_plan = self.class.mask_plan(plan)
132
+ if JSON.generate(masked_plan).bytesize > MAX_PLAN_BYTES
133
+ log { "EXPLAIN plan over #{MAX_PLAN_BYTES} bytes, dropped" }
134
+ return false
135
+ end
136
+
137
+ client.deliver_query_plan(
138
+ statement: job.statement, db_system: "postgresql", plan: masked_plan,
139
+ duration_ms: job.duration_ms, environment: configuration.environment, captured_at: job.captured_at
140
+ )
141
+ rescue StandardError => e
142
+ log { "EXPLAIN failed: #{e.class}: #{e.message}" }
143
+ false
144
+ end
145
+
146
+ private
147
+ attr_reader :configuration, :client
148
+
149
+ # Both rate limits at once, under one lock, so two threads finishing the same slow query
150
+ # together still only explain it once.
151
+ def reserve(statement)
152
+ return false if statement.nil?
153
+
154
+ @mutex.synchronize do
155
+ now = @clock.call
156
+ @recent.reject! { |at| now - at >= GLOBAL_WINDOW }
157
+ return false if @recent.size >= GLOBAL_LIMIT
158
+
159
+ last = @last_explained[statement]
160
+ return false if last && now - last < STATEMENT_INTERVAL
161
+
162
+ if @last_explained.size >= MAX_REMEMBERED_STATEMENTS
163
+ @last_explained.reject! { |_, at| now - at >= STATEMENT_INTERVAL }
164
+ end
165
+ @last_explained[statement] = now
166
+ @recent << now
167
+ true
168
+ end
169
+ end
170
+
171
+ # The values the app's own query was run with, already type cast for the database. Rails
172
+ # passes these either as an Array or as a lambda that builds one.
173
+ def bind_values(payload)
174
+ binds = payload[:type_casted_binds]
175
+ binds = binds.call if binds.respond_to?(:call)
176
+ Array(binds).dup
177
+ end
178
+
179
+ def enqueue(job)
180
+ ensure_worker_started
181
+ @queue.push(job, true)
182
+ true
183
+ rescue ThreadError
184
+ log { "EXPLAIN queue full, dropping" }
185
+ false
186
+ end
187
+
188
+ def ensure_worker_started
189
+ return if @thread&.alive?
190
+
191
+ @mutex.synchronize do
192
+ return if @thread&.alive?
193
+
194
+ @thread = Thread.new { run }
195
+ @thread.abort_on_exception = false
196
+ end
197
+ end
198
+
199
+ def run
200
+ Thread.current[:forge_ops_tracker_explaining] = true
201
+ loop do
202
+ perform(@queue.pop)
203
+ rescue StandardError => e
204
+ log { "EXPLAIN thread error: #{e.class}: #{e.message}" }
205
+ end
206
+ end
207
+
208
+ # The parsed plan (the JSON array Postgres returns), or nil. The connection is checked out
209
+ # explicitly rather than borrowed with with_connection, so it's never one this thread (or
210
+ # any other) already holds. Checked back in only once its transaction is rolled back; if
211
+ # even the ROLLBACK fails, the connection is removed from the pool and closed instead, so a
212
+ # connection in an unknown state is never handed to the app.
213
+ def explain(job)
214
+ connection = job.pool.checkout(CHECKOUT_TIMEOUT)
215
+ begin
216
+ connection.execute("BEGIN READ ONLY")
217
+ connection.execute("SET LOCAL statement_timeout = '#{STATEMENT_TIMEOUT}'")
218
+ result = connection.exec_query("EXPLAIN (FORMAT JSON) #{job.sql}", "EXPLAIN", job.binds)
219
+ value = result.rows.first&.first
220
+ value.is_a?(String) ? JSON.parse(value) : value
221
+ ensure
222
+ release(job.pool, connection, rollback(connection))
223
+ end
224
+ end
225
+
226
+ def rollback(connection)
227
+ connection.execute("ROLLBACK")
228
+ true
229
+ rescue StandardError => e
230
+ log { "EXPLAIN rollback failed: #{e.class}: #{e.message}" }
231
+ false
232
+ end
233
+
234
+ def release(pool, connection, healthy)
235
+ if healthy
236
+ pool.checkin(connection)
237
+ else
238
+ pool.remove(connection)
239
+ connection.disconnect!
240
+ end
241
+ rescue StandardError => e
242
+ log { "EXPLAIN connection release failed: #{e.class}: #{e.message}" }
243
+ end
244
+
245
+ def log
246
+ configuration.logger&.debug { "[ForgeOpsTracker] #{yield}" }
247
+ end
248
+ end
249
+ 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|
@@ -305,6 +319,14 @@ module ForgeOpsTracker
305
319
  # sidekiq, ActiveJob's redis queue adapter, ...), which this gem has no control over; deferring
306
320
  # the defined? check to after every railtie (including this app's own dependencies) has
307
321
  # finished initializing avoids a load-order-dependent false negative.
322
+ # After initialization, not in a plain initializer, so the app's own
323
+ # config/initializers/forge_ops_tracker.rb has already run (detect_changes/track_env_var_names
324
+ # and the DSN are final) and Active Record is configured enough to report the migration
325
+ # version. ChangeSnapshot does the actual work on its own thread, so boot never waits on it.
326
+ config.after_initialize do
327
+ ForgeOpsTracker::ChangeSnapshot.start(ForgeOpsTracker.configuration)
328
+ end
329
+
308
330
  config.after_initialize do
309
331
  next unless defined?(::RedisClient)
310
332
 
@@ -1,3 +1,3 @@
1
1
  module ForgeOpsTracker
2
- VERSION = "0.12.0"
2
+ VERSION = "0.14.0"
3
3
  end
@@ -6,6 +6,8 @@ require "forge_ops_tracker/sql_statement"
6
6
  require "forge_ops_tracker/event_builder"
7
7
  require "forge_ops_tracker/client"
8
8
  require "forge_ops_tracker/delivery_queue"
9
+ require "forge_ops_tracker/change"
10
+ require "forge_ops_tracker/change_snapshot"
9
11
  require "forge_ops_tracker/error_subscriber"
10
12
  require "forge_ops_tracker/session_flusher"
11
13
  require "forge_ops_tracker/histogram_bucketer"
@@ -18,6 +20,7 @@ require "forge_ops_tracker/periodic_poller"
18
20
  require "forge_ops_tracker/breadcrumb_buffer"
19
21
  require "forge_ops_tracker/span_buffer"
20
22
  require "forge_ops_tracker/span_queue"
23
+ require "forge_ops_tracker/query_explainer"
21
24
  require "forge_ops_tracker/trace_parent"
22
25
  require "forge_ops_tracker/request_state"
23
26
  require "forge_ops_tracker/middleware/session_tracking"
@@ -32,8 +35,33 @@ module ForgeOpsTracker
32
35
  @configuration ||= Configuration.new
33
36
  end
34
37
 
38
+ # Outside Rails this is also where the startup ChangeSnapshot goes out, since there's no
39
+ # Railtie to send it once the app has booted; under Rails the Railtie sends it instead, after
40
+ # initialization, when Active Record can report the migration version.
35
41
  def configure
36
42
  yield configuration
43
+ ChangeSnapshot.start(configuration) unless defined?(::Rails::Railtie)
44
+ end
45
+
46
+ # Records one thing that changed in a running system, e.g.
47
+ # `ForgeOpsTracker.record_change(kind: "feature_flag", title: "Enabled new_checkout for 10%")`,
48
+ # so ForgeOps can show it next to the errors and slowdowns that followed. kind is one of
49
+ # Change::KINDS (anything else is sent as "other"); environment defaults to the configured one.
50
+ # Delivered from a background thread like error events, so it never blocks the caller, and a
51
+ # no-op when the gem isn't enabled. Never raises: returns false when nothing was queued.
52
+ def record_change(kind:, title:, details: {}, environment: nil, service: nil, actor: nil, url: nil, id: nil, occurred_at: nil)
53
+ return false unless configuration.enabled?
54
+
55
+ payload = Change.build(
56
+ configuration, kind: kind, title: title, details: details, environment: environment,
57
+ service: service, actor: actor, url: url, id: id, occurred_at: occurred_at
58
+ )
59
+ return false unless payload
60
+
61
+ change_queue.push(payload)
62
+ rescue StandardError => e
63
+ configuration.logger&.debug { "[ForgeOpsTracker] record_change failed: #{e.class}: #{e.message}" }
64
+ false
37
65
  end
38
66
 
39
67
  # Records a named business metric (a signup, a payment, anything a customer wants to name),
@@ -101,12 +129,18 @@ module ForgeOpsTracker
101
129
  # a standalone buffer for itself: a lone span with no request-level trace around it, and no
102
130
  # middleware left running to ever flush it, would just accumulate in this thread's memory
103
131
  # forever with nothing to ever send it, which is worse than never recording it at all.
104
- 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)
105
138
  return yield unless configuration.enabled? && configuration.track_tracing
106
139
 
107
140
  buffer = Thread.current[:forge_ops_tracker_spans]
108
141
  return yield unless buffer
109
142
 
143
+ data = database_span_data(data, statement, db_system) if kind.to_s == "database"
110
144
  span_id = SecureRandom.hex(8)
111
145
  started_at = Time.now.utc
112
146
  buffer.push(span_id)
@@ -120,6 +154,18 @@ module ForgeOpsTracker
120
154
  end
121
155
 
122
156
  private
157
+ def database_span_data(data, statement, db_system)
158
+ extra = {
159
+ "db.statement" => (SqlStatement.mask(statement.to_s) unless statement.nil?),
160
+ "db.system" => (db_system.to_s.strip.downcase unless db_system.nil? || db_system.to_s.strip.empty?)
161
+ }.compact
162
+ extra.empty? ? data : data.merge(extra)
163
+ end
164
+
165
+ def change_queue
166
+ @change_queue ||= DeliveryQueue.new(configuration, deliver_with: :deliver_change)
167
+ end
168
+
123
169
  def metric_buffer
124
170
  @metric_buffer ||= MetricBuffer.new(configuration)
125
171
  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.12.0
4
+ version: 0.14.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - ForgeOps
@@ -90,6 +90,8 @@ files:
90
90
  - README.md
91
91
  - lib/forge_ops_tracker.rb
92
92
  - lib/forge_ops_tracker/breadcrumb_buffer.rb
93
+ - lib/forge_ops_tracker/change.rb
94
+ - lib/forge_ops_tracker/change_snapshot.rb
93
95
  - lib/forge_ops_tracker/client.rb
94
96
  - lib/forge_ops_tracker/configuration.rb
95
97
  - lib/forge_ops_tracker/delivery_queue.rb
@@ -114,6 +116,7 @@ files:
114
116
  - lib/forge_ops_tracker/performance_instrumentation.rb
115
117
  - lib/forge_ops_tracker/periodic_poller.rb
116
118
  - lib/forge_ops_tracker/pii_scrubber.rb
119
+ - lib/forge_ops_tracker/query_explainer.rb
117
120
  - lib/forge_ops_tracker/railtie.rb
118
121
  - lib/forge_ops_tracker/request_state.rb
119
122
  - lib/forge_ops_tracker/session_flusher.rb