forge_ops_tracker 0.14.0 → 0.15.1

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: 5b7f1809933ffeccac536f117de7c234002826fe29414fc3fa92f12c22bb1a18
4
- data.tar.gz: 84a9edadbaeb72dbe62164a84f26c7f23b6a600f176126ca82370709dc72b5c3
3
+ metadata.gz: 81c08199bfeae97d20295b7c04a46dc3b9dd9be519d4790404b88e2ca514fbe1
4
+ data.tar.gz: dad1efa10164cebcbce9452c1f903812157143e871162076f09298ebf41a6ea7
5
5
  SHA512:
6
- metadata.gz: ca419d26d17f08e4544bb213c2949dedf7001402f163a15b0def3c0942ffcc534637911d1bc7e8890f6a3dc556f174a3ecea703351fe291d492c1c151838e3f9
7
- data.tar.gz: 6890f1adbbf73ae399699ee3482a005bbfee521909c6af6acedb8a805a2aa69c3d46f75bf33a3a4722be9f0e6e84a0263850729e42380d1d1b26e23e50f285cb
6
+ metadata.gz: 2a32b34300ee005cd09be7f188275091ec9ba8cf4ef2e65044176a7e6d1427feef170cbc44dbd9f7a25dcf0073b2afb6186db3db2069a5737bfdcb274e62cc7f
7
+ data.tar.gz: 4431641c5e95f757dd82e7af0015de824804610ee4ebf252cbe3a4db13ca5f5aa1cac5fda274a94833be652a9d6c5e25ab4406570d4790b165cf242f8490ff66
data/CHANGELOG.md CHANGED
@@ -1,5 +1,21 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.15.1 (2026-10-01)
4
+
5
+ - Errors reported just before a process exits are now sent before it ends. Delivery runs on a
6
+ background thread, and a short-lived process (a rake task, a cron script, `rails runner`,
7
+ including the test command on a project's setup page) could exit before that thread sent them,
8
+ so they never arrived. On a normal exit the queue now sends what's left, waiting at most 5
9
+ seconds, and only in a process that reported something.
10
+
11
+ ## 0.15.0 (2026-09-29)
12
+
13
+ - SQL masking now also catches strings with a backslash-escaped quote (`'o\'brien'`,
14
+ `E'o\'brien'`), prefixed strings (`E''`, `X''`, `N''`, `B''`, `U&''`, prefix included), hex,
15
+ binary and exponent numbers (`0x1F`, `0b101`, `3e10`, `1.5E-3`, `.5`), and, on MySQL and
16
+ MariaDB, `"double-quoted"` strings. `SqlStatement.mask` takes an optional `system:`, and
17
+ database spans (including `ForgeOpsTracker.span`'s `db_system:`) pass their `db.system` to it.
18
+
3
19
  ## 0.14.0 (2026-09-25)
4
20
 
5
21
  - Every Active Record database span now carries the query's SQL (`db.statement`), masked so every
data/README.md CHANGED
@@ -416,9 +416,12 @@ on error events is sent either way.
416
416
  Every Active Record query span carries the query's SQL and the database it ran on, so a slow trace
417
417
  shows exactly which query was slow. The SQL is masked before it leaves your process: every string
418
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).
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).
422
425
 
423
426
  Spans you create yourself can carry SQL too. Pass `statement:` (and optionally `db_system:`) on a
424
427
  `kind: "database"` span; it's masked the same way:
@@ -8,6 +8,11 @@ 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
+ # The most a normal process exit waits for queued events to go out (see #drain): long enough for
12
+ # one delivery at the default open_timeout + read_timeout, short enough that an unreachable
13
+ # ForgeOps never holds up a deploy or a cron run for long.
14
+ DRAIN_TIMEOUT = 5
15
+
11
16
  # deliver_with names the Client method each payload goes through: :deliver (error events) by
12
17
  # default, :deliver_change for ForgeOpsTracker.record_change's own queue.
13
18
  def initialize(configuration, client: Client.new(configuration), deliver_with: :deliver)
@@ -17,6 +22,28 @@ module ForgeOpsTracker
17
22
  @queue = SizedQueue.new(configuration.queue_size)
18
23
  @mutex = Mutex.new
19
24
  @thread = nil
25
+ @in_flight = 0
26
+ @drain_registered = false
27
+ end
28
+
29
+ # Sends whatever is still queued, and lets a delivery already under way finish, before the
30
+ # process exits, within DRAIN_TIMEOUT. Without it a short-lived process (a rake task, a cron
31
+ # script, `rails runner`) lost every error it reported in its last moments: the worker thread
32
+ # dies with the process before it gets to them. Delivers here on the exiting thread rather than
33
+ # waiting on the worker, which in a forked child is a parent's thread that no longer runs.
34
+ def drain(timeout: DRAIN_TIMEOUT)
35
+ deadline = monotonic_now + timeout
36
+ while monotonic_now < deadline
37
+ payload = begin
38
+ @queue.pop(true)
39
+ rescue ThreadError
40
+ break
41
+ end
42
+ client.public_send(@deliver_with, payload)
43
+ end
44
+ sleep 0.01 while @in_flight.positive? && monotonic_now < deadline
45
+ rescue StandardError => e
46
+ log { "drain failed: #{e.class}: #{e.message}" }
20
47
  end
21
48
 
22
49
  # Enqueues a payload, dropping it silently (never blocking the caller)
@@ -35,6 +62,7 @@ module ForgeOpsTracker
35
62
  attr_reader :configuration, :client
36
63
 
37
64
  def ensure_worker_started
65
+ register_drain
38
66
  return if @thread&.alive?
39
67
 
40
68
  @mutex.synchronize do
@@ -47,12 +75,35 @@ module ForgeOpsTracker
47
75
 
48
76
  def run
49
77
  loop do
50
- client.public_send(@deliver_with, @queue.pop)
78
+ payload = @queue.pop
79
+ @in_flight += 1
80
+ begin
81
+ client.public_send(@deliver_with, payload)
82
+ ensure
83
+ @in_flight -= 1
84
+ end
51
85
  rescue StandardError => e
52
86
  log { "delivery thread error: #{e.class}: #{e.message}" }
53
87
  end
54
88
  end
55
89
 
90
+ # Registered on the first push, not at load, so a process that never reports anything never
91
+ # waits on exit at all.
92
+ def register_drain
93
+ return if @drain_registered
94
+
95
+ @mutex.synchronize do
96
+ return if @drain_registered
97
+
98
+ @drain_registered = true
99
+ at_exit { drain }
100
+ end
101
+ end
102
+
103
+ def monotonic_now
104
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
105
+ end
106
+
56
107
  def log
57
108
  configuration.logger&.debug { "[ForgeOpsTracker] #{yield}" }
58
109
  end
@@ -84,9 +84,10 @@ module ForgeOpsTracker
84
84
  # and number literal becomes "?", so no value the app queried with is ever sent), and which
85
85
  # database it ran on, from the connection's adapter. Either is left out when it isn't known.
86
86
  def database_span_data(payload)
87
+ system = db_system(payload[:connection])
87
88
  {
88
- "db.statement" => SqlStatement.mask(payload[:sql].to_s),
89
- "db.system" => db_system(payload[:connection])
89
+ "db.statement" => SqlStatement.mask(payload[:sql].to_s, system: system),
90
+ "db.system" => system
90
91
  }.compact
91
92
  end
92
93
 
@@ -102,7 +102,8 @@ module ForgeOpsTracker
102
102
  return false unless duration_ms && duration_ms >= configuration.explain_threshold_ms.to_f
103
103
 
104
104
  connection = payload[:connection]
105
- return false unless PerformanceInstrumentation.db_system(connection) == "postgresql"
105
+ system = PerformanceInstrumentation.db_system(connection)
106
+ return false unless system == "postgresql"
106
107
 
107
108
  sql = payload[:sql]
108
109
  return false unless self.class.explainable?(sql)
@@ -110,7 +111,7 @@ module ForgeOpsTracker
110
111
  pool = connection.respond_to?(:pool) ? connection.pool : nil
111
112
  return false unless pool
112
113
 
113
- statement = SqlStatement.mask(sql)
114
+ statement = SqlStatement.mask(sql, system: system)
114
115
  return false unless reserve(statement)
115
116
 
116
117
  enqueue(Job.new(
@@ -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.14.0"
2
+ VERSION = "0.15.1"
3
3
  end
@@ -155,9 +155,10 @@ module ForgeOpsTracker
155
155
 
156
156
  private
157
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?)
158
159
  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?)
160
+ "db.statement" => (SqlStatement.mask(statement.to_s, system: system) unless statement.nil?),
161
+ "db.system" => system
161
162
  }.compact
162
163
  extra.empty? ? data : data.merge(extra)
163
164
  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.14.0
4
+ version: 0.15.1
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