forge_ops_tracker 0.14.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: 5b7f1809933ffeccac536f117de7c234002826fe29414fc3fa92f12c22bb1a18
4
- data.tar.gz: 84a9edadbaeb72dbe62164a84f26c7f23b6a600f176126ca82370709dc72b5c3
3
+ metadata.gz: 2c05c6f94c7c080f7c148cc315097b8e177d34dec772d580e21cf89a7120f1b3
4
+ data.tar.gz: d486796b0f26b752872118cfaad2c9c184671bc596bd6c32a04cfb6a754dc171
5
5
  SHA512:
6
- metadata.gz: ca419d26d17f08e4544bb213c2949dedf7001402f163a15b0def3c0942ffcc534637911d1bc7e8890f6a3dc556f174a3ecea703351fe291d492c1c151838e3f9
7
- data.tar.gz: 6890f1adbbf73ae399699ee3482a005bbfee521909c6af6acedb8a805a2aa69c3d46f75bf33a3a4722be9f0e6e84a0263850729e42380d1d1b26e23e50f285cb
6
+ metadata.gz: e99dddf991705420ceb4d69f21ad8d5e39885f5f2f782e0747976aa2c8cb2463a4d8f56652f23f6944c40d38bc89e95475e61ecaf2079b0f106ca401d4e25d06
7
+ data.tar.gz: 1e5e0e69a95ddecb9411a024dcbf1817b688312131bbffc7359cb917f86d416e7d2811b1215feeb355b4ff4df3b1a2757bafe747ddc425e057e3248bb3572287
data/CHANGELOG.md CHANGED
@@ -1,5 +1,13 @@
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
+
3
11
  ## 0.14.0 (2026-09-25)
4
12
 
5
13
  - 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:
@@ -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.0"
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.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