flare 0.4.2 → 0.5.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.
Files changed (43) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +60 -1
  3. data/app/controllers/flare/jobs_controller.rb +1 -1
  4. data/app/controllers/flare/requests_controller.rb +1 -1
  5. data/app/controllers/flare/spans_controller.rb +1 -1
  6. data/app/helpers/flare/application_helper.rb +23 -0
  7. data/app/views/flare/jobs/index.html.erb +2 -1
  8. data/app/views/flare/requests/index.html.erb +2 -1
  9. data/app/views/flare/requests/show.html.erb +16 -7
  10. data/app/views/flare/spans/index.html.erb +2 -1
  11. data/lib/flare/cli/doctor_command.rb +4 -11
  12. data/lib/flare/cli/local_spans.rb +110 -0
  13. data/lib/flare/cli/nplus1_command.rb +97 -0
  14. data/lib/flare/cli/project.rb +28 -0
  15. data/lib/flare/cli/request_command.rb +120 -0
  16. data/lib/flare/cli/requests_command.rb +45 -0
  17. data/lib/flare/cli/setup_command.rb +64 -41
  18. data/lib/flare/cli/status_command.rb +5 -2
  19. data/lib/flare/cli.rb +12 -0
  20. data/lib/flare/client_headers.rb +7 -3
  21. data/lib/flare/configuration.rb +20 -0
  22. data/lib/flare/export_filter.rb +273 -0
  23. data/lib/flare/filtering_span_processor.rb +14 -0
  24. data/lib/flare/http_transport.rb +4 -0
  25. data/lib/flare/metric_span_processor.rb +51 -35
  26. data/lib/flare/metric_submitter.rb +9 -1
  27. data/lib/flare/recording_batch_span_processor.rb +9 -1
  28. data/lib/flare/redis_statement_processor.rb +29 -0
  29. data/lib/flare/revision.rb +82 -0
  30. data/lib/flare/source_location.rb +21 -16
  31. data/lib/flare/sql_mask.rb +235 -0
  32. data/lib/flare/sqlite_exporter.rb +12 -8
  33. data/lib/flare/storage/sqlite.rb +195 -144
  34. data/lib/flare/storage.rb +1 -40
  35. data/lib/flare/store_statement.rb +136 -0
  36. data/lib/flare/trace_blob.rb +9 -5
  37. data/lib/flare/trace_exporter.rb +18 -3
  38. data/lib/flare/upload_url_pool.rb +2 -15
  39. data/lib/flare/utf8.rb +20 -0
  40. data/lib/flare/version.rb +1 -1
  41. data/lib/flare.rb +21 -2
  42. data/public/flare-assets/flare.css +10 -60
  43. metadata +45 -32
@@ -2,6 +2,8 @@
2
2
 
3
3
  require_relative "metric_key"
4
4
  require_relative "http_metrics_config"
5
+ require_relative "sql_mask"
6
+ require_relative "store_statement"
5
7
 
6
8
  module Flare
7
9
  TRANSACTION_NAME_ATTRIBUTE = "flare.transaction_name" unless const_defined?(:TRANSACTION_NAME_ATTRIBUTE)
@@ -9,6 +11,8 @@ module Flare
9
11
  # OpenTelemetry SpanProcessor that extracts metrics from spans.
10
12
  # Aggregates counts, durations, and error rates by namespace/service/target/operation.
11
13
  class MetricSpanProcessor
14
+ MAX_METRIC_STATEMENT_BYTES = 4_096
15
+
12
16
  # Standard OTel span kind symbols
13
17
  SERVER = :server
14
18
  CLIENT = :client
@@ -55,6 +59,14 @@ module Flare
55
59
  elsif view_span?(span)
56
60
  record_view_metric(span)
57
61
  end
62
+ rescue StandardError => e
63
+ # The SDK calls processors without a rescue, so a bug here would raise
64
+ # into the app's request or job. Warn once per process so it can't
65
+ # flood the logs (keyed by pid so forked workers still warn).
66
+ return if @on_finish_error_warned_pid == $$
67
+
68
+ @on_finish_error_warned_pid = $$
69
+ warn "[Flare::MetricSpanProcessor] on_finish failed: #{e.class}: #{e.message}"
58
70
  end
59
71
 
60
72
  def force_flush(timeout: nil)
@@ -169,13 +181,21 @@ module Flare
169
181
 
170
182
  def record_db_metric(span)
171
183
  db_system = span.attributes["db.system"].to_s
184
+ # Read the table and operation from the statement without its values, so
185
+ # the words in a string literal can't become the metric's name. This runs on
186
+ # the request thread and needs only the start of a statement, and a literal
187
+ # the cut lands in is masked like any unterminated one.
188
+ raw_statement = span.attributes["db.statement"]
189
+ if raw_statement && SqlMask.sql_system?(db_system)
190
+ statement = SqlMask.mask(raw_statement.to_s.byteslice(0, MAX_METRIC_STATEMENT_BYTES), db_system)
191
+ end
172
192
 
173
193
  key = MetricKey.new(
174
194
  bucket: bucket_time(span),
175
195
  namespace: "db",
176
196
  service: db_system,
177
- target: extract_db_target(span, db_system),
178
- operation: extract_db_operation(span, db_system)
197
+ target: extract_db_target(span, db_system, statement),
198
+ operation: extract_db_operation(span, db_system, statement)
179
199
  )
180
200
 
181
201
  @storage.increment(
@@ -191,17 +211,18 @@ module Flare
191
211
  span.attributes["net.peer.name"] ||
192
212
  extract_host_from_url(span) ||
193
213
  "unknown"
214
+ host = host.to_s.sub(/\A.*@/m, "").downcase
194
215
 
195
216
  method = span.attributes["http.method"] ||
196
217
  span.attributes["http.request.method"] ||
197
218
  "UNKNOWN"
198
219
 
199
- path = resolve_http_path(span, host.to_s.downcase)
220
+ path = resolve_http_path(span, host)
200
221
 
201
222
  key = MetricKey.new(
202
223
  bucket: bucket_time(span),
203
224
  namespace: "http",
204
- service: host.to_s.downcase,
225
+ service: host,
205
226
  target: "#{method.to_s.upcase} #{path}",
206
227
  operation: http_status_class(span)
207
228
  )
@@ -257,9 +278,14 @@ module Flare
257
278
  ((span.end_timestamp - span.start_timestamp) / 1_000_000.0).round
258
279
  end
259
280
 
281
+ # A 5xx is an error. So is a request that raised before any response
282
+ # (a timeout, a dropped connection): it has no status code, only an error
283
+ # span status. 4xx responses also carry an error span status, so the
284
+ # span status only counts when there was no response at all.
260
285
  def http_error?(span)
261
286
  status = span.attributes["http.status_code"] || span.attributes["http.response.status_code"]
262
- status.to_i >= 500
287
+ code = status.to_i
288
+ code >= 500 || (code.zero? && span_error?(span))
263
289
  end
264
290
 
265
291
  def http_status_class(span)
@@ -291,50 +317,40 @@ module Flare
291
317
  end
292
318
  end
293
319
 
294
- def extract_db_target(span, db_system)
295
- case db_system
296
- when "redis"
320
+ def extract_db_target(span, db_system, statement)
321
+ if StoreStatement.redis_system?(db_system)
297
322
  # For Redis, use db.redis.namespace if available, or the database index
298
323
  span.attributes["db.redis.database_index"]&.to_s ||
299
324
  span.attributes["db.name"] ||
300
325
  "default"
301
326
  else
302
- # For SQL databases, prefer table name from attributes or parsed from SQL
327
+ # Prefer the table name from attributes. Only SQL statements are parsed
328
+ # for one; statement is nil for anything else.
303
329
  span.attributes["db.sql.table"] ||
304
- extract_sql_table(span) ||
330
+ span.attributes["db.mongodb.collection"] ||
331
+ span.attributes["db.collection.name"] ||
332
+ extract_sql_table(statement) ||
305
333
  span.attributes["db.name"] ||
306
334
  "unknown"
307
335
  end
308
336
  end
309
337
 
310
- def extract_db_operation(span, db_system)
311
- case db_system
312
- when "redis"
313
- span.attributes["db.operation"]&.to_s&.downcase ||
314
- span.name.to_s.split.last&.downcase ||
315
- "command"
338
+ def extract_db_operation(span, db_system, statement)
339
+ operation = SqlMask.operation_word(span.attributes["db.operation"])
340
+
341
+ if StoreStatement.redis_system?(db_system)
342
+ (operation || SqlMask.operation_word(span.name.to_s.split.last))&.downcase || "query"
316
343
  else
317
- span.attributes["db.operation"]&.to_s&.upcase ||
318
- extract_sql_operation(span) ||
319
- "query"
344
+ # The first word of a masked statement (SELECT, INSERT, UPDATE, etc.)
345
+ word = statement ? statement.split(" ", 2).first : SqlMask.command_word(span.attributes["db.statement"])
346
+ (operation || word)&.upcase || "query"
320
347
  end
321
348
  end
322
349
 
323
- def extract_sql_operation(span)
324
- statement = span.attributes["db.statement"]
350
+ def extract_sql_table(statement)
325
351
  return nil unless statement
326
352
 
327
- # Extract first word (SELECT, INSERT, UPDATE, DELETE, etc.)
328
- statement.to_s.strip.split(/\s+/).first&.upcase
329
- end
330
-
331
- def extract_sql_table(span)
332
- statement = span.attributes["db.statement"]
333
- return nil unless statement
334
-
335
- sql = statement.to_s.strip
336
-
337
- case sql
353
+ case statement
338
354
  when /\bFROM\s+[`"]?(\w+)[`"]?/i
339
355
  $1
340
356
  when /\bINTO\s+[`"]?(\w+)[`"]?/i
@@ -373,7 +389,7 @@ module Flare
373
389
  path = span.attributes["http.target"] ||
374
390
  span.attributes["url.path"] ||
375
391
  extract_path_from_url(span)
376
- path&.to_s&.split("?")&.first || "/"
392
+ path&.to_s&.split(/[?#]/)&.first || "/"
377
393
  end
378
394
 
379
395
  # Normalize URL paths to prevent cardinality explosion.
@@ -395,8 +411,8 @@ module Flare
395
411
  when /\A[0-9a-f]{24}\z/i
396
412
  # MongoDB ObjectId: /items/507f1f77bcf86cd799439011 -> /items/:id
397
413
  ":id"
398
- when /\A[0-9a-f]{32,}\z/i
399
- # Long hex strings (tokens, hashes): -> :token
414
+ when /\A[0-9a-f]{32,}\z/i, /[^@]@/
415
+ # Long hex strings (tokens, hashes), or anything shaped like user@host: -> :token
400
416
  ":token"
401
417
  else
402
418
  segment
@@ -24,6 +24,14 @@ module Flare
24
24
  # Max retries before giving up
25
25
  MAX_RETRIES = 3
26
26
 
27
+ # Network errors worth another try. A DNS failure or an unreachable host
28
+ # fails before anything is sent, like a refused connection.
29
+ RETRIABLE_ERRORS = [
30
+ Net::OpenTimeout, Net::ReadTimeout, Net::WriteTimeout,
31
+ Errno::ECONNREFUSED, Errno::ECONNRESET,
32
+ SocketError, Errno::EHOSTUNREACH, Errno::ENETUNREACH
33
+ ].freeze
34
+
27
35
  class SubmissionError < StandardError
28
36
  attr_reader :request_id, :response_code, :response_body
29
37
 
@@ -179,7 +187,7 @@ module Flare
179
187
  result, should_retry = yield
180
188
  return [nil, DeadlineExceeded.new("metric submission deadline exceeded")] if deadline.expired?
181
189
  return [result, nil] unless should_retry
182
- rescue SubmissionError, Net::OpenTimeout, Net::ReadTimeout, Net::WriteTimeout, Errno::ECONNREFUSED, Errno::ECONNRESET => e
190
+ rescue SubmissionError, *RETRIABLE_ERRORS => e
183
191
  last_error = e
184
192
  attempts_remaining -= 1
185
193
 
@@ -35,6 +35,8 @@ module Flare
35
35
  detect_forking
36
36
 
37
37
  @mutex.synchronize do
38
+ return if @stopped
39
+
38
40
  overflow = @queue.length + 1 - @max_queue_size
39
41
  @queue.shift(overflow) if overflow.positive?
40
42
  @queue << span
@@ -160,7 +162,13 @@ module Flare
160
162
  end
161
163
  SUCCESS
162
164
  ensure
163
- @mutex.synchronize { @queue.unshift(*snapshot) } if snapshot&.any?
165
+ if snapshot&.any?
166
+ @mutex.synchronize do
167
+ @queue.unshift(*snapshot)
168
+ overflow = @queue.length - @max_queue_size
169
+ @queue.shift(overflow) if overflow.positive?
170
+ end
171
+ end
164
172
  end
165
173
 
166
174
  def start_flush_export(snapshot, deadline)
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "opentelemetry/sdk"
4
+
5
+ require_relative "store_statement"
6
+
7
+ module Flare
8
+ # Replaces a Redis statement with its shape as the span starts, so raw arguments
9
+ # never reach a finished span: not the development database, not an export, and
10
+ # not another exporter the app has added. The Redis instrumentation sends the
11
+ # key only when it's told to include arguments, which Flare does for this.
12
+ class RedisStatementProcessor < OpenTelemetry::SDK::Trace::SpanProcessor
13
+ STATEMENT = "db.statement"
14
+
15
+ def on_start(span, _parent_context)
16
+ return unless span.kind == :client
17
+
18
+ attributes = span.attributes
19
+ return unless attributes && StoreStatement.redis_system?(attributes["db.system"])
20
+
21
+ statement = attributes[STATEMENT]
22
+ return unless statement
23
+
24
+ span.set_attribute(STATEMENT, StoreStatement.redis(statement, pipelined: span.name == "PIPELINED"))
25
+ rescue StandardError
26
+ span.set_attribute(STATEMENT, StoreStatement::PLACEHOLDER) if span.respond_to?(:set_attribute)
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Flare
4
+ # Works out the deployed revision of the app this client runs in, usually a
5
+ # git SHA. Flare.configuration.revision calls it once, the first time the
6
+ # revision is asked for; it's sent as X-Client-Revision so the server can
7
+ # mark deploys on the charts.
8
+ #
9
+ # In order:
10
+ # 1. The configured value (config.revision, which defaults to FLARE_REVISION)
11
+ # 2. A platform's variable (Heroku, Render, Railway, Kamal, Fly, ...)
12
+ # 3. A REVISION file in the app root (Capistrano, many Docker builds)
13
+ # 4. git rev-parse HEAD, only when the app root has a .git directory
14
+ # Nil when none of them answer; the server treats a missing header as
15
+ # "unknown".
16
+ module Revision
17
+ MAX_LENGTH = 64
18
+
19
+ # Variable => how to read a revision from it (nil: the value as is).
20
+ PLATFORM_VARIABLES = {
21
+ "HEROKU_SLUG_COMMIT" => nil,
22
+ "RENDER_GIT_COMMIT" => nil,
23
+ "RAILWAY_GIT_COMMIT_SHA" => nil,
24
+ "KAMAL_VERSION" => nil,
25
+ # The whole image reference ("registry.fly.io/app:deployment-01J...@sha256:...").
26
+ # Its tag names the deploy, and is the git SHA when deployed with --image-label.
27
+ "FLY_IMAGE_REF" => ->(ref) { ref.split("@", 2).first.to_s[%r{:([^:/]+)\z}, 1] },
28
+ "GIT_SHA" => nil,
29
+ "SOURCE_VERSION" => nil
30
+ }.freeze
31
+
32
+ def self.resolve(configured, env: ENV, root: app_root)
33
+ revision = clean(configured) || platform_revision(env) ||
34
+ clean(revision_file(root)) || clean(git_head(root))
35
+ revision&.freeze
36
+ end
37
+
38
+ # The first platform variable that holds a revision. Stops there, so a
39
+ # later variable is never parsed.
40
+ def self.platform_revision(env)
41
+ PLATFORM_VARIABLES.each do |name, extract|
42
+ value = env[name]
43
+ value = clean(extract && value ? extract.call(value) : value)
44
+ return value if value
45
+ end
46
+ nil
47
+ end
48
+
49
+ # Up to the first line break (CR or LF), stripped and capped, since it
50
+ # goes in a header.
51
+ def self.clean(value)
52
+ value = value.to_s.strip.split(/[\r\n]/, 2).first.to_s.strip
53
+ return if value.empty?
54
+
55
+ value[0, MAX_LENGTH]
56
+ end
57
+
58
+ def self.app_root
59
+ if defined?(::Rails) && ::Rails.respond_to?(:root) && ::Rails.root
60
+ ::Rails.root.to_s
61
+ else
62
+ Dir.pwd
63
+ end
64
+ end
65
+
66
+ def self.revision_file(root)
67
+ path = File.join(root, "REVISION")
68
+ File.read(path, 256) if File.file?(path)
69
+ rescue StandardError
70
+ nil
71
+ end
72
+
73
+ def self.git_head(root)
74
+ return unless File.exist?(File.join(root, ".git"))
75
+
76
+ output = IO.popen(["git", "-C", root, "rev-parse", "HEAD"], err: File::NULL, &:read)
77
+ output if $?.success?
78
+ rescue StandardError
79
+ nil
80
+ end
81
+ end
82
+ end
@@ -16,11 +16,13 @@ module Flare
16
16
  /lib\/active_support/,
17
17
  /lib\/action_/,
18
18
  /opentelemetry/,
19
- /flare/,
20
19
  /<internal:/,
21
20
  /\/bin\//,
22
21
  ].freeze
23
22
 
23
+ # Frames from this gem itself (for path: and git: installs outside /gems/).
24
+ GEM_ROOT = "#{File.expand_path("../..", __dir__)}/"
25
+
24
26
  module_function
25
27
 
26
28
  # Find the first app source location from the current backtrace
@@ -39,36 +41,39 @@ module Flare
39
41
  # Find multiple app source locations from the current backtrace
40
42
  # Returns an array of cleaned backtrace lines (up to MAX_TRACE_LINES)
41
43
  def find_trace
42
- backtrace = caller(2, 100)
43
- return [] unless backtrace
44
-
45
- # Filter to only app code lines
46
- app_lines = backtrace.select { |line| app_code?(line) }
47
- return [] if app_lines.empty?
48
-
49
- # Clean and format each line, limit to MAX_TRACE_LINES
50
- app_lines.first(MAX_TRACE_LINES).map { |line| clean_backtrace_line(line) }
44
+ app_lines(caller(2, 100)).map { |line| clean_backtrace_line(line) }
51
45
  end
52
46
 
53
- # Add source location attributes to a hash (for span attributes)
47
+ # Add source location attributes to a hash (for span attributes).
48
+ # Captures the backtrace once for both the location and the trace.
54
49
  def add_to_attributes(attrs)
55
- location = find
50
+ lines = app_lines(caller(1, 100))
51
+ location = lines.first && parse_backtrace_line(lines.first)
56
52
  return attrs unless location
57
53
 
58
54
  attrs["code.filepath"] = location[:filepath]
59
55
  attrs["code.lineno"] = location[:lineno]
60
56
  attrs["code.function"] = location[:function] if location[:function]
61
-
62
- # Add full trace as a single string attribute
63
- trace = find_trace
64
- attrs["code.stacktrace"] = trace.join("\n") if trace.any?
57
+ attrs["code.stacktrace"] = lines.map { |line| clean_backtrace_line(line) }.join("\n")
65
58
 
66
59
  attrs
67
60
  end
68
61
 
62
+ # The first MAX_TRACE_LINES app code lines of a backtrace.
63
+ def app_lines(backtrace)
64
+ return [] unless backtrace
65
+
66
+ backtrace.select { |line| app_code?(line) }.first(MAX_TRACE_LINES)
67
+ end
68
+
69
69
  def app_code?(line)
70
70
  # Must contain /app/ (Rails convention) and not match ignore patterns
71
71
  return false unless line.include?("/app/")
72
+ return false if line.start_with?(GEM_ROOT)
73
+
74
+ if defined?(Rails) && Rails.respond_to?(:root) && Rails.root
75
+ return true if line.start_with?("#{Rails.root}/app/")
76
+ end
72
77
 
73
78
  IGNORE_PATTERNS.none? { |pattern| line.match?(pattern) }
74
79
  end
@@ -0,0 +1,235 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "strscan"
4
+
5
+ module Flare
6
+ # Takes the values out of a SQL statement in one left-to-right pass, keeping
7
+ # keywords and names so the same query with different values reads the same.
8
+ #
9
+ # The first delimiter decides the token, so a quote inside a comment or a
10
+ # comment marker inside a string can't change how the rest is read. When the
11
+ # scan can't tell where a value ends (an unterminated literal or comment, or a
12
+ # backslash before a quote, which means different things across databases and
13
+ # server settings), everything after that point is masked.
14
+ module SqlMask
15
+ PLACEHOLDER = "?"
16
+
17
+ # Longer statements are masked up to here and cut, which bounds the work.
18
+ MAX_BYTES = 16_384
19
+ TRUNCATED = " ..."
20
+
21
+ # Systems known to speak SQL. Anything else may not follow its quoting rules,
22
+ # so callers keep only a command word. No system at all is read as SQL, with
23
+ # every quote treated as a value.
24
+ SQL_SYSTEMS = /postgres|postgis|mysql|trilogy|maria|sqlite|cockroach|sqlserver|mssql|oracle|db2/i
25
+
26
+ # Close enough to SQL to mask, with rules that vary: every quoted string counts
27
+ # as a value, and # and // start comments that run to the end of the line.
28
+ # Backticks hold names only where that's known.
29
+ SQL_LIKE_SYSTEMS = /cassandra|clickhouse|bigquery|snowflake/i
30
+ BACKTICK_NAMES = /clickhouse|bigquery/i
31
+
32
+ # Double quotes hold names on these. On MySQL they hold values, and elsewhere
33
+ # they're treated as values too. SQLite reads them as values when no such
34
+ # column exists, so it keeps them only in places that can't hold a value.
35
+ # MySQL also starts comments with #, which is an operator on Postgres.
36
+ DOUBLE_QUOTED_NAMES = /postgres|postgis|cockroach|oracle|db2/i
37
+ SQLITE = /sqlite/i
38
+ HASH_COMMENTS = /mysql|trilogy|maria/i
39
+
40
+ COMMAND_WORD = /\A\s*([A-Z][A-Z_.]{0,31}|[a-z][a-z_.]{0,31})(?=\s|\z)/
41
+ OPERATION_WORD = /\A[A-Za-z][A-Za-z_.]{0,31}\z/
42
+ # Decided from a short tail of the output, so anything spaced too widely to see
43
+ # in it isn't a name. FROM counts only with the word before it in view, because
44
+ # IS DISTINCT FROM takes a value.
45
+ NAME_POSITION = /(?:\b(?:JOIN|INTO|UPDATE|TABLE)\s{1,8}|\.)\z/ni
46
+ FROM_POSITION = /(\S+)\s{1,8}FROM\s{1,8}\z/ni
47
+ DISTINCT_WORD = /DISTINCT\z/ni
48
+ DOUBLE_QUOTED = /"[^"]*+(?:""[^"]*+)*+"/n
49
+ QUALIFIER = /#{DOUBLE_QUOTED.source}(?=\.(?:\*|\w|#{DOUBLE_QUOTED.source}))/n
50
+
51
+ WORD = /[A-Za-z_\x80-\xff][\w$\x80-\xff]*+/n
52
+ UUID = /\h{8}-\h{4}-\h{4}-\h{4}-\h{12}\b/n
53
+ NUMBER = /\.?\d[\w.\x80-\xff]*+(?:(?<=[eE])[+-]\d++)?/n
54
+ DOLLAR_OTHER = /\$\d*+/n
55
+ DOLLAR_TAG = /\$(?:[A-Za-z_\x80-\xff][\w\x80-\xff]*+)?\$/n
56
+ LINE_COMMENT = /(?:--|#|\/\/)[^\n]*+/n
57
+ COMMENT_START = %r{/\*}n
58
+ COMMENT_EDGE = %r{/\*|\*/}n
59
+ LITERAL_PREFIX = /\A(?:[xXbBnNeE]|_\w+)\z/n
60
+ LITERAL_RUNS = { "'" => /[^'\\]*+/n, '"' => /[^"\\]*+/n, "`" => /[^`\\]*+/n }.freeze
61
+ IN_LIST = /\bIN ?\( ?\?(?: ?, ?\?)* ?\)/ni
62
+ OTHER_WHITESPACE = "\t\n\r\f\v"
63
+
64
+ # A run of tokens that are copied as they are, matched in one go because most
65
+ # of a statement is this. It stops at anything that needs a decision: a quote,
66
+ # a number, a $, a comment, or a word that may be a literal's prefix.
67
+ # A word that starts a UUID stops the run, so the whole UUID can be masked.
68
+ word = %q{(?!\h{8}-\h{4}-)[A-Za-z_\x80-\xff][\w$\x80-\xff]*+(?!')}
69
+ other = %q{[^\w\x80-\xff'"`$#./-]++|\.(?!\d)|-(?!-)}
70
+ backtick = "`[^`]*+(?:``[^`]*+)*+`"
71
+ plain = "#{word}|#{other}|#{backtick}|/(?!\\*)"
72
+ PLAIN = Regexp.new("(?:#{plain})++", Regexp::NOENCODING)
73
+ PLAIN_NAMES = Regexp.new(%((?:#{plain}|#{DOUBLE_QUOTED.source}|#)++), Regexp::NOENCODING)
74
+ PLAIN_HASH = Regexp.new("(?:#{plain}|#)++", Regexp::NOENCODING)
75
+ PLAIN_LIKE = Regexp.new("(?:#{word}|#{other}|/(?![*/]))++", Regexp::NOENCODING)
76
+ PLAIN_LIKE_BACKTICKS = Regexp.new("(?:#{word}|#{other}|#{backtick}|/(?![*/]))++", Regexp::NOENCODING)
77
+
78
+ module_function
79
+
80
+ def sql_system?(system)
81
+ system.to_s.empty? || SQL_SYSTEMS.match?(system.to_s) || SQL_LIKE_SYSTEMS.match?(system.to_s)
82
+ end
83
+
84
+ # The first word of a statement, if it could be a command name. Nil otherwise.
85
+ def command_word(statement)
86
+ statement.to_s[COMMAND_WORD, 1]
87
+ end
88
+
89
+ # The value if all of it could be an operation name. Nil otherwise.
90
+ def operation_word(value)
91
+ value.to_s[OPERATION_WORD]
92
+ end
93
+
94
+ def mask(statement, system = nil)
95
+ sql = statement.to_s.b
96
+ truncated = sql.bytesize > MAX_BYTES
97
+ sql = sql.byteslice(0, MAX_BYTES) if truncated
98
+ out = scan(StringScanner.new(sql), system.to_s)
99
+ out.tr!(OTHER_WHITESPACE, " ")
100
+ out.squeeze!(" ")
101
+ out.gsub!(IN_LIST, "IN (?)")
102
+ out.strip!
103
+ out << TRUNCATED if truncated
104
+ out.force_encoding(Encoding::UTF_8).scrub!
105
+ end
106
+
107
+ def scan(scanner, system)
108
+ sqlite = SQLITE.match?(system)
109
+ plain = if SQL_LIKE_SYSTEMS.match?(system) then BACKTICK_NAMES.match?(system) ? PLAIN_LIKE_BACKTICKS : PLAIN_LIKE
110
+ elsif DOUBLE_QUOTED_NAMES.match?(system) then PLAIN_NAMES
111
+ elsif system.empty? || HASH_COMMENTS.match?(system) then PLAIN
112
+ else PLAIN_HASH
113
+ end
114
+ out = String.new(capacity: scanner.string.bytesize, encoding: Encoding::BINARY)
115
+
116
+ until scanner.eos?
117
+ if (run = scanner.scan(plain))
118
+ out << run
119
+ elsif scanner.skip(UUID) || scanner.skip(NUMBER)
120
+ out << PLACEHOLDER
121
+ elsif (word = scanner.scan(WORD))
122
+ if LITERAL_PREFIX.match?(word)
123
+ mask_literal(scanner, out, backslash_escapes: word.casecmp?("e"))
124
+ else
125
+ out << word
126
+ end
127
+ else
128
+ scan_delimiter(scanner, out, plain, sqlite)
129
+ end
130
+ end
131
+
132
+ out
133
+ end
134
+
135
+ # Handles the delimiter the plain run stopped on. Giving up on the rest of the
136
+ # statement means terminating the scanner, which ends the loop above.
137
+ def scan_delimiter(scanner, out, plain, sqlite)
138
+ case scanner.peek(1)
139
+ when "'"
140
+ mask_literal(scanner, out)
141
+ when '"'
142
+ # A closed name would already be part of the plain run where quotes are names.
143
+ if plain == PLAIN_NAMES then mask_rest(scanner, out)
144
+ elsif sqlite && (name = sqlite_name(scanner, out)) then out << name
145
+ else mask_literal(scanner, out)
146
+ end
147
+ when "`"
148
+ # Likewise, where backticks are names.
149
+ plain == PLAIN_LIKE ? mask_literal(scanner, out) : mask_rest(scanner, out)
150
+ when "$"
151
+ mask_dollar(scanner, out)
152
+ else # /* or, where they start a comment, //, -- and #
153
+ if scanner.check(COMMENT_START)
154
+ skip_comment(scanner, out)
155
+ else
156
+ scanner.skip(LINE_COMMENT)
157
+ out << " "
158
+ end
159
+ end
160
+ end
161
+
162
+ # A quoted word right after FROM and the like, or either side of a dot, can
163
+ # only be a name.
164
+ def sqlite_name(scanner, out)
165
+ tail = out.byteslice(-32, 32) || out
166
+ from = FROM_POSITION.match(tail)
167
+ if NAME_POSITION.match?(tail) || (from && !DISTINCT_WORD.match?(from[1]))
168
+ scanner.scan(DOUBLE_QUOTED)
169
+ else
170
+ scanner.scan(QUALIFIER)
171
+ end
172
+ end
173
+
174
+ def mask_rest(scanner, out)
175
+ out << PLACEHOLDER
176
+ scanner.terminate
177
+ end
178
+
179
+ # The scanner is on the opening quote. Reads to the closing one, or masks the
180
+ # rest if there isn't one.
181
+ #
182
+ # An even run of backslashes before a quote closes the literal whether or not
183
+ # the server treats backslash as an escape. An odd run only closes it if the
184
+ # server doesn't, and that depends on the database and its settings, so the
185
+ # literal counts as unterminated. Only E'' strings say for themselves.
186
+ def mask_literal(scanner, out, backslash_escapes: false)
187
+ quote = scanner.getch
188
+ plain = LITERAL_RUNS.fetch(quote)
189
+ out << PLACEHOLDER
190
+
191
+ loop do
192
+ scanner.skip(plain)
193
+ return scanner.terminate if scanner.eos?
194
+
195
+ if scanner.peek(1) == "\\"
196
+ if backslash_escapes
197
+ scanner.skip(/\\.?/mn)
198
+ else
199
+ run = scanner.skip(/\\+/n)
200
+ return scanner.terminate if run.odd? && scanner.peek(1) == quote
201
+ end
202
+ else
203
+ scanner.getch
204
+ return unless scanner.peek(1) == quote
205
+
206
+ scanner.getch
207
+ end
208
+ end
209
+ end
210
+
211
+ def mask_dollar(scanner, out)
212
+ out << PLACEHOLDER
213
+ if (tag = scanner.scan(DOLLAR_TAG))
214
+ scanner.terminate unless scanner.skip_until(Regexp.new(Regexp.escape(tag), Regexp::NOENCODING))
215
+ else
216
+ scanner.skip(DOLLAR_OTHER)
217
+ end
218
+ end
219
+
220
+ # Comments nest on Postgres. Reading them that way elsewhere can only mask
221
+ # more, never less.
222
+ def skip_comment(scanner, out)
223
+ scanner.skip(COMMENT_START)
224
+ depth = 1
225
+ while depth.positive?
226
+ return scanner.terminate unless scanner.skip_until(COMMENT_EDGE)
227
+
228
+ depth += scanner.matched == "/*" ? 1 : -1
229
+ end
230
+ out << " "
231
+ end
232
+
233
+ private_class_method :scan, :scan_delimiter, :sqlite_name, :mask_rest, :mask_literal, :mask_dollar, :skip_comment
234
+ end
235
+ end