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.
- checksums.yaml +7 -0
- data/README.md +326 -0
- data/lib/sixty/aggregator.rb +257 -0
- data/lib/sixty/config.rb +155 -0
- data/lib/sixty/exporter.rb +144 -0
- data/lib/sixty/instrument/action_controller.rb +44 -0
- data/lib/sixty/instrument/active_record.rb +139 -0
- data/lib/sixty/instrument/mongo.rb +482 -0
- data/lib/sixty/instrument/mysql.rb +187 -0
- data/lib/sixty/instrument/pg.rb +197 -0
- data/lib/sixty/instrument/rack.rb +158 -0
- data/lib/sixty/instrumented.rb +204 -0
- data/lib/sixty/plans.rb +258 -0
- data/lib/sixty/railtie.rb +49 -0
- data/lib/sixty/shape.rb +91 -0
- data/lib/sixty/sketch.rb +211 -0
- data/lib/sixty/sql.rb +441 -0
- data/lib/sixty/stack.rb +95 -0
- data/lib/sixty/tracer.rb +222 -0
- data/lib/sixty/version.rb +5 -0
- data/lib/sixty.rb +330 -0
- metadata +97 -0
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
|
data/lib/sixty/stack.rb
ADDED
|
@@ -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
|