sixty 0.1.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.
data/lib/sixty/sql.rb ADDED
@@ -0,0 +1,441 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'set'
4
+
5
+ module Sixty
6
+ # SQL and route normalization.
7
+ #
8
+ # SECURITY BOUNDARY. Everything in this file runs inside the customer's
9
+ # process, before any byte leaves their network. Raw SQL text contains PII in
10
+ # literals ('alice@example.com', SSNs, tokens). We never transmit raw SQL —
11
+ # only the literal-free shape. If you are tempted to add a "send the original
12
+ # for debugging" option: don't.
13
+ #
14
+ # Normalization also bounds cardinality, which is the other way systems like
15
+ # this die. `where id = 1` and `where id = 99` must collapse to one operation
16
+ # or the operations table grows with traffic instead of with the number of
17
+ # queries the application contains.
18
+ #
19
+ # Ported from packages/core/src/sql.js and kept deliberately close to it. A
20
+ # Rails app and a Node service in the same organization must reduce the same
21
+ # statement to the same shape, because the shape is what the collector hashes
22
+ # into an operation's identity — two spellings of the same query would split
23
+ # one operation's history in half at the exact moment somebody rewrites a
24
+ # service in the other language.
25
+ module Sql
26
+ module_function
27
+
28
+ # ActiveRecord issues the same statement text thousands of times per minute.
29
+ # Normalization is a per-character lexer, so the result is memoized against
30
+ # the raw text — bounded, because a caller with unbounded distinct SQL is
31
+ # exactly the case that would otherwise grow this hash forever.
32
+ MAX_CACHED = 1000
33
+ @cache = {}
34
+ @cache_mutex = Mutex.new
35
+
36
+ # @param sql [String]
37
+ # @param dialect [Symbol] :postgres or :mysql
38
+ def normalize_sql(sql, dialect = :postgres)
39
+ return '' unless sql.is_a?(String)
40
+
41
+ analyze(sql, dialect).first
42
+ end
43
+
44
+ # Did this statement arrive with its values already out of it?
45
+ #
46
+ # A statement written with bind parameters (`where id = $1`) carries no data;
47
+ # one written with literals (`where id = 42`) carries all of it. Only the
48
+ # first kind may be handed back to the database in an `EXPLAIN` — see
49
+ # plans.rb — and this is what decides which it is.
50
+ #
51
+ # It is answered by the lexer rather than by a second pattern, because the
52
+ # question is exactly "would normalization have removed anything", and the
53
+ # only code that can answer that without disagreeing with itself is the code
54
+ # that does the removing.
55
+ def value_free?(sql, dialect = :postgres)
56
+ return false unless sql.is_a?(String)
57
+
58
+ analyze(sql, dialect).last.zero?
59
+ end
60
+
61
+ # @return [Array(String, Integer)] the normalized text and how many literals
62
+ # were taken out of it
63
+ def analyze(sql, dialect)
64
+ key = dialect == :mysql ? "mysql\x00#{sql}" : sql
65
+ cached = @cache[key]
66
+ return cached if cached
67
+
68
+ result = lex_with_stats(sql, dialect)
69
+ @cache_mutex.synchronize do
70
+ @cache.clear if @cache.size >= MAX_CACHED
71
+ @cache[key] = result
72
+ end
73
+ result
74
+ end
75
+
76
+ def lex(sql, dialect = :postgres)
77
+ lex_with_stats(sql, dialect).first
78
+ end
79
+
80
+ # Strip literals, collapse whitespace, fold IN-lists.
81
+ #
82
+ # Deliberately a lexer, not a parser: it must never raise on a dialect
83
+ # quirk, must be fast enough to run on every query, and its only job is
84
+ # removing things — an unparseable statement still gets its literals
85
+ # stripped, which is the property that matters.
86
+ #
87
+ # ── Why the dialect is a parameter and not a union of both rule sets ──────
88
+ #
89
+ # The two dialects disagree about a character rather than merely differing.
90
+ # `"alice@example.com"` is a *quoted identifier* in Postgres — schema, safe
91
+ # to keep — and in MySQL's default sql_mode the same bytes are a *string
92
+ # literal*, which is exactly the PII this file exists to remove. Reading
93
+ # MySQL with the Postgres rules would transmit it. Backticks are the mirror
94
+ # image: MySQL's identifier quote, and not a quote at all in Postgres.
95
+ #
96
+ # ActiveRecord knows which one it is talking to, so the adapter name decides
97
+ # (see instrument/active_record.rb) rather than a guess from the text.
98
+ def lex_with_stats(sql, dialect = :postgres)
99
+ mysql = dialect == :mysql
100
+ src = sql.chars
101
+ n = src.length
102
+ out = +''
103
+ i = 0
104
+ # Every literal removed, counted. A bind parameter is not a literal: it
105
+ # was already a placeholder when it arrived.
106
+ literals = 0
107
+
108
+ while i < n
109
+ c = src[i]
110
+
111
+ # --- line comment
112
+ if c == '-' && src[i + 1] == '-'
113
+ i += 1 while i < n && src[i] != "\n"
114
+ next
115
+ end
116
+
117
+ # --- MySQL line comment. `#` is not a comment introducer in Postgres.
118
+ if mysql && c == '#'
119
+ i += 1 while i < n && src[i] != "\n"
120
+ next
121
+ end
122
+
123
+ # --- block comment
124
+ if c == '/' && src[i + 1] == '*'
125
+ i += 2
126
+ i += 1 while i < n && !(src[i] == '*' && src[i + 1] == '/')
127
+ i += 2
128
+ next
129
+ end
130
+
131
+ # --- single-quoted string (SQL escape is '')
132
+ if c == "'"
133
+ i += 1
134
+ while i < n
135
+ # MySQL also honours backslash escapes by default, so `'it\'s'` does
136
+ # not end where a Postgres lexer thinks it does. Mis-finding the
137
+ # closing quote resumes lexing *inside* a literal, and the tail of
138
+ # somebody's data is then emitted as if it were SQL.
139
+ if mysql && src[i] == '\\'
140
+ i += 2
141
+ next
142
+ end
143
+ if src[i] == "'" && src[i + 1] == "'"
144
+ i += 2
145
+ next
146
+ end
147
+ if src[i] == "'"
148
+ i += 1
149
+ break
150
+ end
151
+ i += 1
152
+ end
153
+ out << '?'
154
+ literals += 1
155
+ next
156
+ end
157
+
158
+ # --- MySQL double-quoted string. Postgres reads these as identifiers
159
+ # and keeps them; here they are data and must not survive. Checked
160
+ # before the identifier branch below, which is the Postgres reading.
161
+ if mysql && c == '"'
162
+ i += 1
163
+ while i < n
164
+ if src[i] == '\\'
165
+ i += 2
166
+ next
167
+ end
168
+ if src[i] == '"' && src[i + 1] == '"'
169
+ i += 2
170
+ next
171
+ end
172
+ if src[i] == '"'
173
+ i += 1
174
+ break
175
+ end
176
+ i += 1
177
+ end
178
+ out << '?'
179
+ literals += 1
180
+ next
181
+ end
182
+
183
+ # --- MySQL backtick identifier: preserved, it is schema, not data
184
+ if mysql && c == '`'
185
+ out << c
186
+ i += 1
187
+ while i < n
188
+ out << src[i]
189
+ if src[i] == '`' && src[i + 1] != '`'
190
+ i += 1
191
+ break
192
+ end
193
+ if src[i] == '`' && src[i + 1] == '`'
194
+ out << src[i + 1]
195
+ i += 2
196
+ next
197
+ end
198
+ i += 1
199
+ end
200
+ next
201
+ end
202
+
203
+ # --- dollar-quoted string ($tag$ ... $tag$). Postgres only: `$` is a
204
+ # legal identifier character in MySQL, where `a$b$c` is one name and
205
+ # reading it as a quoted string would swallow the rest of the
206
+ # statement.
207
+ if !mysql && c == '$'
208
+ rest = sql[i..]
209
+ if (m = /\A\$([A-Za-z_]\w*)?\$/.match(rest))
210
+ tag = m[0]
211
+ found = sql.index(tag, i + tag.length)
212
+ i = found.nil? ? n : found + tag.length
213
+ out << '?'
214
+ literals += 1
215
+ next
216
+ end
217
+ # $1, $2 — already placeholders. Normalized to one symbol so a
218
+ # difference in parameter *count* does not fragment the identity.
219
+ if (p = /\A\$\d+/.match(rest))
220
+ out << '?'
221
+ i += p[0].length
222
+ next
223
+ end
224
+ end
225
+
226
+ # --- double-quoted identifier: preserved, it is schema, not data
227
+ if c == '"'
228
+ out << c
229
+ i += 1
230
+ while i < n
231
+ out << src[i]
232
+ if src[i] == '"' && src[i + 1] != '"'
233
+ i += 1
234
+ break
235
+ end
236
+ if src[i] == '"' && src[i + 1] == '"'
237
+ out << src[i + 1]
238
+ i += 2
239
+ next
240
+ end
241
+ i += 1
242
+ end
243
+ next
244
+ end
245
+
246
+ # --- numeric literal (not part of an identifier like col2)
247
+ #
248
+ # `previous` is spelled out rather than written as `src[i - 1]`: at i = 0
249
+ # Ruby's negative index would hand back the *last* character of the
250
+ # statement, so a query starting with a digit would be judged by its own
251
+ # final byte.
252
+ previous = i.zero? ? ' ' : (src[i - 1] || ' ')
253
+ if c =~ /[0-9]/ && previous !~ /[A-Za-z_$."]/
254
+ while i < n && src[i] =~ /[0-9.eE+\-xa-fA-F]/
255
+ # stop at an operator that merely follows the number
256
+ break if src[i] =~ /[+\-]/ && src[i - 1] !~ /[eE]/
257
+
258
+ i += 1
259
+ end
260
+ out << '?'
261
+ literals += 1
262
+ next
263
+ end
264
+
265
+ # --- whitespace run
266
+ if c =~ /\s/
267
+ out << ' '
268
+ i += 1 while i < n && src[i] =~ /\s/
269
+ next
270
+ end
271
+
272
+ out << c
273
+ i += 1
274
+ end
275
+
276
+ normalized = out
277
+ # fold IN (?, ?, ?) -> IN (?) so batch size does not
278
+ # fragment identity
279
+ .gsub(/\b(?:in|IN|In)\s*\(\s*\?(?:\s*,\s*\?)+\s*\)/) { |m| m[0, m.index('(')] + '(?)' }
280
+ # fold multi-row VALUES (?),(?) -> VALUES (?)
281
+ .gsub(/\bvalues\s*(\(\s*\?(?:\s*,\s*\?)*\s*\))(?:\s*,\s*\(\s*\?(?:\s*,\s*\?)*\s*\))+/i, 'values \1')
282
+ .gsub(/\s+/, ' ')
283
+ .gsub(/\s*([(),;])\s*/, '\1')
284
+ .strip
285
+
286
+ [normalized, literals]
287
+ end
288
+
289
+ SQL_VERB = /\A\s*(select|insert|update|delete|with|begin|commit|rollback|create|alter|drop|truncate|copy|explain|set|show|savepoint|release|listen|notify)\b/i.freeze
290
+
291
+ @names = {}
292
+ @names_mutex = Mutex.new
293
+
294
+ # Short display name for a SQL operation: "select:orders", "insert:users".
295
+ # The collector derives its own from the normalized text — this is what the
296
+ # span carries so a trace is readable before it ever leaves the process.
297
+ #
298
+ # Memoized against the normalized statement, and that is not a
299
+ # micro-optimization: naming walks the statement looking for the relation it
300
+ # touches, which measured at fifteen microseconds *per query* — four times
301
+ # the cost of everything else the agent does per query put together. A name
302
+ # is a pure function of the text, and the same few hundred statements repeat
303
+ # forever, so it is computed once each.
304
+ def sql_operation_name(normalized)
305
+ cached = @names[normalized]
306
+ return cached if cached
307
+
308
+ name = compute_operation_name(normalized)
309
+ @names_mutex.synchronize do
310
+ @names.clear if @names.size >= MAX_CACHED
311
+ @names[normalized] = name
312
+ end
313
+ name
314
+ end
315
+
316
+ def compute_operation_name(normalized)
317
+ body, names = split_ctes(normalized)
318
+ verb_match = SQL_VERB.match(body) || SQL_VERB.match(normalized)
319
+ verb = verb_match ? verb_match[1].downcase : 'query'
320
+
321
+ # Resolved against the *main* statement, not the first common table
322
+ # expression: otherwise every `with d as (select ... from unnest(...))`
323
+ # collapses to the same label, several unrelated statements all reading
324
+ # `with:unnest` and indistinguishable in a list.
325
+ table = relation_for(verb, body, names)
326
+ table ||= relation_for(verb, normalized, names) || first_relation(normalized, names)
327
+
328
+ table ? "#{verb}:#{table}" : verb
329
+ end
330
+
331
+ # Which keyword introduces the relation that names the statement. INSERT is
332
+ # named by its target, not by the SELECT that feeds it.
333
+ BY_VERB = {
334
+ 'insert' => [/\binto\s+/i],
335
+ 'update' => [/\bupdate\s+/i],
336
+ 'delete' => [/\bdelete\s+from\s+/i, /\bfrom\s+/i],
337
+ 'select' => [/\bfrom\s+/i, /\bjoin\s+/i],
338
+ 'with' => [/\bfrom\s+/i, /\bjoin\s+/i]
339
+ }.freeze
340
+ # The quote is optional and may be either dialect's. A MySQL statement
341
+ # reaches here as ``insert into `orders` (a) values(?)``, and a pattern that
342
+ # only knows the Postgres quote finds no relation in it — so every
343
+ # backticked statement collapses to the bare verb `insert`, and the feed
344
+ # becomes a list of verbs. The captured name excludes the quotes, so
345
+ # `orders` and `` `orders` `` produce one label rather than two spellings.
346
+ IDENT = /(["`]?[A-Za-z_][\w$]*["`]?\.)?["`]?([A-Za-z_][\w$]*)["`]?/.freeze
347
+
348
+ # Compiled once. `Regexp.new(lead.source + IDENT.source)` per call meant
349
+ # building and discarding a regular expression on every query in the
350
+ # application.
351
+ RELATION_PATTERNS = BY_VERB.transform_values do |leads|
352
+ leads.map { |lead| Regexp.new(lead.source + IDENT.source, Regexp::IGNORECASE) }
353
+ end.freeze
354
+
355
+ def relation_for(verb, sql, cte_names)
356
+ (RELATION_PATTERNS[verb] || RELATION_PATTERNS['select']).each do |re|
357
+ sql.scan(re) do
358
+ name = Regexp.last_match(2)
359
+ # A CTE alias names nothing the reader can go and look at.
360
+ return name if name && !cte_names.include?(name.downcase)
361
+ end
362
+ end
363
+ nil
364
+ end
365
+
366
+ # Any real relation the statement touches, CTE aliases excluded.
367
+ def first_relation(sql, cte_names)
368
+ sql.scan(/\b(?:from|into|update|join)\s+(["`]?[A-Za-z_][\w$]*["`]?\.)?["`]?([A-Za-z_][\w$]*)["`]?/i) do
369
+ name = Regexp.last_match(2)
370
+ return name if name && !cte_names.include?(name.downcase)
371
+ end
372
+ nil
373
+ end
374
+
375
+ # Split a statement into its CTE names and the statement that follows them.
376
+ # Paren-aware: a CTE body contains commas and parentheses, and a regex that
377
+ # ignores nesting stops in the wrong place.
378
+ def split_ctes(sql)
379
+ names = Set.new
380
+ return [sql, names] unless sql =~ /\A\s*with\b/i
381
+
382
+ i = (sql =~ /\bwith\b/i) + 4
383
+ loop do
384
+ name_match = /\A\s*(?:recursive\s+)?["`]?([A-Za-z_][\w$]*)["`]?/i.match(sql[i..] || '')
385
+ open = sql.index('(', i)
386
+ return [sql, names] if open.nil?
387
+
388
+ names << name_match[1].downcase if name_match
389
+
390
+ depth = 0
391
+ j = open
392
+ while j < sql.length
393
+ if sql[j] == '('
394
+ depth += 1
395
+ elsif sql[j] == ')'
396
+ depth -= 1
397
+ if depth.zero?
398
+ j += 1
399
+ break
400
+ end
401
+ end
402
+ j += 1
403
+ end
404
+ return [sql, names] unless depth.zero?
405
+
406
+ rest = sql[j..] || ''
407
+ comma = /\A\s*,/.match(rest)
408
+ return [rest.sub(/\A\s+/, ''), names] unless comma
409
+
410
+ i = j + comma[0].length
411
+ end
412
+ end
413
+
414
+ UUID = /\A[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\z/i.freeze
415
+
416
+ # Template an HTTP path so /users/42 and /users/43 are one operation.
417
+ # Used only when the framework does not hand us a route pattern; a real
418
+ # Rails route always wins over this heuristic.
419
+ def template_path(pathname)
420
+ return '/' unless pathname.is_a?(String)
421
+
422
+ pathname = pathname.split('?', 2).first.to_s
423
+
424
+ templated = pathname.split('/', -1).map do |seg|
425
+ next seg if seg.empty?
426
+ next ':id' if seg =~ /\A\d+\z/
427
+ next ':uuid' if seg =~ UUID
428
+ next ':hash' if seg =~ /\A[0-9a-f]{24,}\z/i
429
+ # A colon inside a path segment is almost never part of a route: routes
430
+ # are named with words. It is, reliably, a delimiter inside an id.
431
+ next ':id' if seg.include?(':') && !seg.start_with?(':')
432
+ # long, high-entropy, mixed-case segments are almost always ids/slugs
433
+ next ':id' if seg.length > 24 && seg =~ /\d/ && seg =~ /[A-Za-z]/
434
+
435
+ seg
436
+ end.join('/')
437
+
438
+ templated.empty? ? '/' : templated
439
+ end
440
+ end
441
+ end
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sixty
4
+ # Where in your code did this operation come from.
5
+ #
6
+ # A finding you cannot locate in your own repository is much less actionable —
7
+ # "select:orders returns 30,000 rows" is only useful once you know which file
8
+ # writes that query. In a Rails app that file is almost never the one that
9
+ # executes the statement: ActiveRecord does, through six frames of adapter,
10
+ # relation and log subscriber. So the frames kept here are the application's
11
+ # own, and the ORM's are skipped.
12
+ #
13
+ # ── Why this is affordable ────────────────────────────────────────────────
14
+ #
15
+ # Capturing a backtrace costs microseconds, which is unacceptable per query.
16
+ # But a call site is a property of the *operation*, not of the call: the same
17
+ # query is issued from the same place every time. So the stack is captured on
18
+ # the first sighting of a normalized statement and never again. The cost
19
+ # amortises to nothing over a process lifetime and is bounded by operation
20
+ # cardinality, which is already capped.
21
+ #
22
+ # ── Why several frames, not one ───────────────────────────────────────────
23
+ #
24
+ # Applications funnel queries through helpers — a scope, a query object, a
25
+ # concern. The top application frame is therefore that helper for every query,
26
+ # which is useless. Rather than guess which frame is "the real caller" with a
27
+ # heuristic that will often be wrong, the top few application frames are kept
28
+ # and shown. The developer recognises their own code immediately, and no guess
29
+ # has to be correct.
30
+ module Stack
31
+ MAX_FRAMES = 4
32
+ MAX_TRACKED = 2000 # matches the aggregator's operation cap
33
+
34
+ # Frames from Ruby itself, from installed gems, and from this agent are
35
+ # never the answer — the caller wants their own code. `<internal:` covers
36
+ # the frames Ruby's own prelude contributes.
37
+ NOT_USER_CODE = %r{
38
+ /gems/ | /ruby/\d | /rubygems/ | ^<internal: | /bundler/ | /packages/ruby/lib/sixty/
39
+ }x.freeze
40
+
41
+ class << self
42
+ attr_accessor :root
43
+
44
+ def seen
45
+ @seen ||= {}
46
+ end
47
+
48
+ def mutex
49
+ @mutex ||= Mutex.new
50
+ end
51
+
52
+ # @param key [String] the operation's normalized identity
53
+ # @return [Array<Hash>, nil] frames, once per key, nil every time after
54
+ def capture(key)
55
+ return nil if key.nil? || key.empty?
56
+
57
+ mutex.synchronize do
58
+ return nil if seen.key?(key) || seen.size >= MAX_TRACKED
59
+
60
+ seen[key] = true
61
+ end
62
+
63
+ frames = []
64
+ # Deep enough to get past ActiveRecord's adapter stack, not so deep that
65
+ # building the array becomes the expensive part.
66
+ caller_locations(2, 40).each do |location|
67
+ path = location.absolute_path || location.path
68
+ next if path.nil? || NOT_USER_CODE.match?(path)
69
+
70
+ frames << { file: relativise(path), line: location.lineno, fn: location.label.to_s[0, 80] }
71
+ break if frames.length >= MAX_FRAMES
72
+ end
73
+
74
+ frames.empty? ? nil : frames
75
+ end
76
+
77
+ # Repository-relative paths, so a frame is comparable across machines and
78
+ # can be turned into a link into the commit that produced it. An absolute
79
+ # deploy path is meaningless to a reader and discloses the layout of the
80
+ # host it was built on.
81
+ def relativise(path)
82
+ prefix = root
83
+ return path if prefix.nil? || prefix.empty?
84
+
85
+ prefix = "#{prefix}/" unless prefix.end_with?('/')
86
+ path.start_with?(prefix) ? path[prefix.length..] : path
87
+ end
88
+
89
+ # Exported for tests: forget what has been seen.
90
+ def reset!
91
+ mutex.synchronize { @seen = {} }
92
+ end
93
+ end
94
+ end
95
+ end