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.
- checksums.yaml +4 -4
- data/README.md +60 -1
- data/app/controllers/flare/jobs_controller.rb +1 -1
- data/app/controllers/flare/requests_controller.rb +1 -1
- data/app/controllers/flare/spans_controller.rb +1 -1
- data/app/helpers/flare/application_helper.rb +23 -0
- data/app/views/flare/jobs/index.html.erb +2 -1
- data/app/views/flare/requests/index.html.erb +2 -1
- data/app/views/flare/requests/show.html.erb +16 -7
- data/app/views/flare/spans/index.html.erb +2 -1
- data/lib/flare/cli/doctor_command.rb +4 -11
- data/lib/flare/cli/local_spans.rb +110 -0
- data/lib/flare/cli/nplus1_command.rb +97 -0
- data/lib/flare/cli/project.rb +28 -0
- data/lib/flare/cli/request_command.rb +120 -0
- data/lib/flare/cli/requests_command.rb +45 -0
- data/lib/flare/cli/setup_command.rb +64 -41
- data/lib/flare/cli/status_command.rb +5 -2
- data/lib/flare/cli.rb +12 -0
- data/lib/flare/client_headers.rb +7 -3
- data/lib/flare/configuration.rb +20 -0
- data/lib/flare/export_filter.rb +273 -0
- data/lib/flare/filtering_span_processor.rb +14 -0
- data/lib/flare/http_transport.rb +4 -0
- data/lib/flare/metric_span_processor.rb +51 -35
- data/lib/flare/metric_submitter.rb +9 -1
- data/lib/flare/recording_batch_span_processor.rb +9 -1
- data/lib/flare/redis_statement_processor.rb +29 -0
- data/lib/flare/revision.rb +82 -0
- data/lib/flare/source_location.rb +21 -16
- data/lib/flare/sql_mask.rb +235 -0
- data/lib/flare/sqlite_exporter.rb +12 -8
- data/lib/flare/storage/sqlite.rb +195 -144
- data/lib/flare/storage.rb +1 -40
- data/lib/flare/store_statement.rb +136 -0
- data/lib/flare/trace_blob.rb +9 -5
- data/lib/flare/trace_exporter.rb +18 -3
- data/lib/flare/upload_url_pool.rb +2 -15
- data/lib/flare/utf8.rb +20 -0
- data/lib/flare/version.rb +1 -1
- data/lib/flare.rb +21 -2
- data/public/flare-assets/flare.css +10 -60
- 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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
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
|
-
|
|
318
|
-
|
|
319
|
-
|
|
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
|
|
324
|
-
statement = span.attributes["db.statement"]
|
|
350
|
+
def extract_sql_table(statement)
|
|
325
351
|
return nil unless statement
|
|
326
352
|
|
|
327
|
-
|
|
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(
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|