goodsorcery 0.1.1 → 0.2.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/CHANGELOG.md +9 -0
- data/README.md +30 -1
- data/lib/generators/goodsorcery/install/templates/initializer.rb +7 -0
- data/lib/goodsorcery/client.rb +15 -1
- data/lib/goodsorcery/configuration/performance_settings.rb +37 -0
- data/lib/goodsorcery/configuration/validation.rb +10 -0
- data/lib/goodsorcery/configuration.rb +3 -0
- data/lib/goodsorcery/defaults.rb +4 -0
- data/lib/goodsorcery/integrations/active_job.rb +4 -1
- data/lib/goodsorcery/integrations/rails.rb +3 -2
- data/lib/goodsorcery/integrations/sidekiq.rb +18 -2
- data/lib/goodsorcery/performance/agent.rb +104 -0
- data/lib/goodsorcery/performance/aggregator.rb +63 -0
- data/lib/goodsorcery/performance/call_site.rb +61 -0
- data/lib/goodsorcery/performance/flusher.rb +224 -0
- data/lib/goodsorcery/performance/histogram.rb +23 -0
- data/lib/goodsorcery/performance/job_timing.rb +106 -0
- data/lib/goodsorcery/performance/measurement.rb +117 -0
- data/lib/goodsorcery/performance/net_http.rb +37 -0
- data/lib/goodsorcery/performance/payloads.rb +110 -0
- data/lib/goodsorcery/performance/process_label.rb +22 -0
- data/lib/goodsorcery/performance/recorder.rb +202 -0
- data/lib/goodsorcery/performance/span_list.rb +156 -0
- data/lib/goodsorcery/performance/sql_normalizer.rb +149 -0
- data/lib/goodsorcery/performance/subscribers.rb +140 -0
- data/lib/goodsorcery/performance/trace_buffer.rb +78 -0
- data/lib/goodsorcery/performance/trace_builder.rb +121 -0
- data/lib/goodsorcery/performance/transaction_stats.rb +93 -0
- data/lib/goodsorcery/performance/uuid_v7.rb +27 -0
- data/lib/goodsorcery/performance.rb +158 -0
- data/lib/goodsorcery/rack/middleware.rb +10 -3
- data/lib/goodsorcery/transport.rb +12 -1
- data/lib/goodsorcery/version.rb +1 -1
- data/lib/goodsorcery.rb +17 -0
- metadata +21 -1
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GoodSorcery
|
|
4
|
+
module Performance
|
|
5
|
+
# Measures one unit of work (a request or a job) on the current fiber:
|
|
6
|
+
# the total duration, the time per layer and, when traces are on, a raw
|
|
7
|
+
# span list. Spans are small arrays appended as events finish; nothing
|
|
8
|
+
# is copied or normalised until a trace is built (DESIGN.md 8.3).
|
|
9
|
+
#
|
|
10
|
+
# [:sql, start, finish, sql, name, row_count, dialect]
|
|
11
|
+
# [:http, start, finish, method, host, path, status]
|
|
12
|
+
# [:view, start, finish, identifier]
|
|
13
|
+
# [:enqueue, start, finish, job_class, queue]
|
|
14
|
+
#
|
|
15
|
+
# Times are monotonic seconds. A trace reads at most the first
|
|
16
|
+
# SqlNormalizer::INPUT_LIMIT characters of a statement, so only those
|
|
17
|
+
# (and one more, which tells the normaliser the text was cut) are kept,
|
|
18
|
+
# and at most MAX_SQL_BYTES of SQL text per unit of work; past either
|
|
19
|
+
# bound a span is only counted (spans_dropped).
|
|
20
|
+
class Recorder
|
|
21
|
+
MAX_RAW_SPANS = 2_000
|
|
22
|
+
MAX_SQL_CHARS = SqlNormalizer::INPUT_LIMIT + 1
|
|
23
|
+
MAX_SQL_BYTES = 4 * 1_024 * 1_024
|
|
24
|
+
MAX_RENDER_DEPTH = 100
|
|
25
|
+
|
|
26
|
+
attr_reader :kind, :started, :started_wall, :spans, :db_ms, :http_ms, :sql_count, :http_count,
|
|
27
|
+
:spans_dropped, :duration_ms, :finished_wall, :gc
|
|
28
|
+
attr_accessor :name, :http_method, :route, :source, :adapter, :queue, :queue_wait_ms, :status,
|
|
29
|
+
:error_event_id, :env, :job_details
|
|
30
|
+
|
|
31
|
+
def initialize(kind, traces:)
|
|
32
|
+
@kind = kind
|
|
33
|
+
@started = Performance.monotonic
|
|
34
|
+
@started_wall = Process.clock_gettime(Process::CLOCK_REALTIME)
|
|
35
|
+
@db_ms = @http_ms = @render_view_ms = 0.0
|
|
36
|
+
@sql_count = @http_count = @spans_dropped = @sql_bytes = 0
|
|
37
|
+
@render_stack = []
|
|
38
|
+
@failed = @broken = false
|
|
39
|
+
start_trace if traces
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def traces? = !@spans.nil?
|
|
43
|
+
def failed? = @failed
|
|
44
|
+
def broken? = @broken
|
|
45
|
+
def failed! = (@failed = true)
|
|
46
|
+
def broken! = (@broken = true)
|
|
47
|
+
|
|
48
|
+
# An exception escaped the unit of work. A request fails only when the
|
|
49
|
+
# exception becomes a 5xx (+status+ is what Rails answers it with; 500
|
|
50
|
+
# when unknown): a RecordNotFound is a 404, not a failure.
|
|
51
|
+
def raised!(status = nil)
|
|
52
|
+
return @failed = true unless @kind == :web
|
|
53
|
+
|
|
54
|
+
@status = status || 500
|
|
55
|
+
@failed = @status >= 500
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def response_status=(status)
|
|
59
|
+
@status = status
|
|
60
|
+
@failed = true if status && status >= 500
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Yields once, the second time SQL of the same shape runs (8.5), for
|
|
64
|
+
# the call site.
|
|
65
|
+
def add_sql(start, finish, sql, name, rows, dialect: nil, &)
|
|
66
|
+
@db_ms += (finish - start) * 1000.0
|
|
67
|
+
@sql_count += 1
|
|
68
|
+
return unless @spans
|
|
69
|
+
|
|
70
|
+
sql = kept_sql(sql)
|
|
71
|
+
return @spans_dropped += 1 unless sql
|
|
72
|
+
|
|
73
|
+
@repeats.note(sql, &)
|
|
74
|
+
push([:sql, start, finish, sql, name, rows, dialect])
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# +times+ is [start, finish].
|
|
78
|
+
def add_http(times, method, host, path, status)
|
|
79
|
+
start, finish = times
|
|
80
|
+
@http_ms += (finish - start) * 1000.0
|
|
81
|
+
@http_count += 1
|
|
82
|
+
push([:http, start, finish, method, host, path, status]) if @spans
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def add_enqueue(start, finish, job_class, queue)
|
|
86
|
+
push([:enqueue, start, finish, job_class, queue]) if @spans
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def render_started(now)
|
|
90
|
+
@render_stack.push([now, @db_ms + @http_ms]) if @render_stack.size < MAX_RENDER_DEPTH
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Only the outermost render counts toward view time, minus the queries
|
|
94
|
+
# and HTTP calls made while it was open, so nested renders and the
|
|
95
|
+
# other layers are never counted twice. Every render of the unit of
|
|
96
|
+
# work adds up (render_to_string, then render).
|
|
97
|
+
def render_finished(now, identifier)
|
|
98
|
+
start, others_at_start = @render_stack.pop
|
|
99
|
+
return unless start
|
|
100
|
+
|
|
101
|
+
push([:view, start, now, identifier]) if @spans
|
|
102
|
+
return unless @render_stack.empty?
|
|
103
|
+
|
|
104
|
+
others = @db_ms + @http_ms - others_at_start
|
|
105
|
+
@render_view_ms += [((now - start) * 1000.0) - others, 0.0].max
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def call_site_for(sql)
|
|
109
|
+
@repeats&.call_site(sql)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def stop(now = Performance.monotonic)
|
|
113
|
+
@duration_ms = (now - @started) * 1000.0
|
|
114
|
+
@finished_wall = @started_wall + (now - @started)
|
|
115
|
+
@gc = @gc_start&.delta
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Web requests too: the controller's view_runtime includes queries run
|
|
119
|
+
# from templates whenever Active Record's logger is above info, keeps
|
|
120
|
+
# only the last render and includes HTTP calls made while rendering.
|
|
121
|
+
def view_ms = @render_view_ms
|
|
122
|
+
|
|
123
|
+
def ruby_ms
|
|
124
|
+
[@duration_ms - @db_ms - @http_ms - view_ms, 0.0].max
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
private
|
|
128
|
+
|
|
129
|
+
def start_trace
|
|
130
|
+
@spans = []
|
|
131
|
+
@repeats = RepeatedQueries.new
|
|
132
|
+
@gc_start = GcSnapshot.take
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# The statement as the trace will read it, or nil when no more SQL text
|
|
136
|
+
# fits (or the span list is full).
|
|
137
|
+
def kept_sql(sql)
|
|
138
|
+
sql = sql.to_s
|
|
139
|
+
sql = sql[0, MAX_SQL_CHARS] if sql.bytesize > MAX_SQL_CHARS
|
|
140
|
+
return nil if @spans.size >= MAX_RAW_SPANS || @sql_bytes + sql.bytesize > MAX_SQL_BYTES
|
|
141
|
+
|
|
142
|
+
@sql_bytes += sql.bytesize
|
|
143
|
+
sql
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def push(span)
|
|
147
|
+
if @spans.size < MAX_RAW_SPANS
|
|
148
|
+
@spans << span
|
|
149
|
+
else
|
|
150
|
+
@spans_dropped += 1
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# Remembers which shapes of SQL ran in one unit of work, and the call
|
|
156
|
+
# site of each shape that ran at least twice (captured once). The shape
|
|
157
|
+
# is the text without its digits, as an Integer hash: the same for
|
|
158
|
+
# "WHERE id = 7" and "WHERE id = 8", so adapters that write literal ids
|
|
159
|
+
# into the SQL still get a call site, and no copy of the text is kept.
|
|
160
|
+
class RepeatedQueries
|
|
161
|
+
MAX_TEXTS = 2_000
|
|
162
|
+
SEEN_ONCE = true
|
|
163
|
+
|
|
164
|
+
def self.shape(sql)
|
|
165
|
+
(sql.valid_encoding? ? sql : sql.b).delete("0-9").hash
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def initialize
|
|
169
|
+
@shapes = {}
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def note(sql)
|
|
173
|
+
key = self.class.shape(sql)
|
|
174
|
+
seen = @shapes[key]
|
|
175
|
+
if seen.nil?
|
|
176
|
+
@shapes[key] = SEEN_ONCE if @shapes.size < MAX_TEXTS
|
|
177
|
+
elsif seen == SEEN_ONCE
|
|
178
|
+
@shapes[key] = (block_given? && yield) || false
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def call_site(sql)
|
|
183
|
+
site = @shapes[self.class.shape(sql)]
|
|
184
|
+
site.is_a?(CallSite) ? site : nil
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# Process-wide GC figures at the start of a unit of work, and their
|
|
189
|
+
# change since.
|
|
190
|
+
GcSnapshot = Struct.new(:time_ms, :gc_count, :allocations) do
|
|
191
|
+
def self.take
|
|
192
|
+
new(GC.stat(:time), GC.count, GC.stat(:total_allocated_objects))
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def delta
|
|
196
|
+
now = self.class.take
|
|
197
|
+
{ "time_ms" => (now.time_ms - time_ms).to_f, "count" => now.gc_count - gc_count,
|
|
198
|
+
"allocations" => now.allocations - allocations }
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
end
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GoodSorcery
|
|
4
|
+
module Performance
|
|
5
|
+
# Turns a recorder's raw spans into the trace payload's "spans"
|
|
6
|
+
# (DESIGN.md 8.5): SQL normalised, repeated identical SQL (and repeated
|
|
7
|
+
# renders of one template) grouped into one span with its count, total
|
|
8
|
+
# and call site, at most MAX_SPANS spans (the longest), in start order.
|
|
9
|
+
class SpanList
|
|
10
|
+
MAX_SPANS = 300
|
|
11
|
+
MAX_NAME_LENGTH = 255
|
|
12
|
+
MAX_PATH_LENGTH = 1_024
|
|
13
|
+
VIEWS_DIR = "/app/views/"
|
|
14
|
+
# A path given as an absolute URL ("https://user:pass@host/v1") or a
|
|
15
|
+
# network-path reference ("//host/v1"): its scheme and authority.
|
|
16
|
+
URL_PREFIX = %r{\A(?:[a-z][a-z0-9+.-]*:)?//[^/?#]*}i
|
|
17
|
+
|
|
18
|
+
attr_reader :dropped
|
|
19
|
+
|
|
20
|
+
def initialize(recorder, context)
|
|
21
|
+
@recorder = recorder
|
|
22
|
+
@context = context
|
|
23
|
+
@normalized = {}
|
|
24
|
+
@dropped = 0
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def to_a
|
|
28
|
+
groups = group(@recorder.spans).sort_by(&:first_start)
|
|
29
|
+
groups = cap(groups) if groups.size > MAX_SPANS
|
|
30
|
+
groups.map { |span_group| span_group.to_h(@recorder.started, @context) }
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def group(raw_spans)
|
|
36
|
+
groups = {}
|
|
37
|
+
raw_spans.each_with_index do |raw, index|
|
|
38
|
+
slot = group_key(raw) || index
|
|
39
|
+
existing = groups[slot]
|
|
40
|
+
existing ? existing.add(raw) : groups[slot] = SpanGroup.new(raw, fields(raw))
|
|
41
|
+
groups[slot].find_call_site(@recorder, raw)
|
|
42
|
+
end
|
|
43
|
+
groups.values
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def cap(groups)
|
|
47
|
+
kept = groups.max_by(MAX_SPANS, &:total_ms)
|
|
48
|
+
@dropped += (groups - kept).sum(&:count)
|
|
49
|
+
kept.sort_by(&:first_start)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# SQL groups by its normalised text and views by template; HTTP calls
|
|
53
|
+
# and enqueues are never grouped.
|
|
54
|
+
def group_key(raw)
|
|
55
|
+
case raw[0]
|
|
56
|
+
when :sql then [:sql, normalized(raw)]
|
|
57
|
+
when :view then [:view, template_name(raw[3])]
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def fields(raw)
|
|
62
|
+
case raw[0]
|
|
63
|
+
when :sql then { "name" => Text.limit(raw[4] || "SQL", MAX_NAME_LENGTH), "sql" => normalized(raw) }
|
|
64
|
+
when :view then { "template" => template_name(raw[3]) }
|
|
65
|
+
when :http then http_fields(raw)
|
|
66
|
+
else enqueue_fields(raw)
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def normalized(raw)
|
|
71
|
+
(@normalized[raw[6]] ||= {})[raw[3]] ||= @context.normalizer.normalize(raw[3], raw[6])
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Method, host and path only: never a query string, fragment or
|
|
75
|
+
# userinfo, even when the app passed a whole URL as the path.
|
|
76
|
+
def http_fields(raw)
|
|
77
|
+
path = raw[5].to_s.sub(URL_PREFIX, "").split(/[?#]/, 2).first.to_s
|
|
78
|
+
{ "method" => Text.limit(raw[3], 16), "host" => Text.limit(raw[4], MAX_NAME_LENGTH),
|
|
79
|
+
"path" => Text.limit(path.empty? ? "/" : path, MAX_PATH_LENGTH), "status" => raw[6] }.compact
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def enqueue_fields(raw)
|
|
83
|
+
{ "job" => Text.limit(raw[3], MAX_NAME_LENGTH), "queue" => raw[4] && Text.limit(raw[4], 128) }.compact
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Relative to app/views; a template outside the app keeps the part
|
|
87
|
+
# after its last "views/" directory, else its file name.
|
|
88
|
+
def template_name(identifier)
|
|
89
|
+
@templates ||= {}
|
|
90
|
+
@templates[identifier] ||= relative_template(identifier.to_s)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def relative_template(path)
|
|
94
|
+
index = path.rindex(VIEWS_DIR)
|
|
95
|
+
name = if index then path[(index + VIEWS_DIR.size)..]
|
|
96
|
+
elsif (views = path.rindex("/views/")) then path[(views + 7)..]
|
|
97
|
+
else File.basename(path)
|
|
98
|
+
end
|
|
99
|
+
Text.limit(name, MAX_NAME_LENGTH)
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# One span of the payload: a single call, or a group of identical ones.
|
|
104
|
+
class SpanGroup
|
|
105
|
+
DECIMALS = 3
|
|
106
|
+
|
|
107
|
+
attr_reader :first_start, :last_finish, :total_ms, :count, :rows, :call_site
|
|
108
|
+
|
|
109
|
+
def initialize(raw, fields)
|
|
110
|
+
@type = raw[0]
|
|
111
|
+
@fields = fields
|
|
112
|
+
@first_start = raw[1]
|
|
113
|
+
@last_finish = raw[2]
|
|
114
|
+
@total_ms = (raw[2] - raw[1]) * 1000.0
|
|
115
|
+
@count = 1
|
|
116
|
+
@rows = sql_rows(raw)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def add(raw)
|
|
120
|
+
@count += 1
|
|
121
|
+
@total_ms += (raw[2] - raw[1]) * 1000.0
|
|
122
|
+
@last_finish = raw[2] if raw[2] > @last_finish
|
|
123
|
+
rows = sql_rows(raw)
|
|
124
|
+
@rows = (@rows || 0) + rows if rows
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# A SQL group takes the call site captured for any of its raw texts.
|
|
128
|
+
def find_call_site(recorder, raw)
|
|
129
|
+
return @call_site if @call_site || @type != :sql
|
|
130
|
+
|
|
131
|
+
@call_site = recorder.call_site_for(raw[3])
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def to_h(origin, context)
|
|
135
|
+
hash = { "type" => @type.to_s, "offset_ms" => ms(@first_start - origin),
|
|
136
|
+
"duration_ms" => @total_ms.round(DECIMALS) }.merge!(@fields)
|
|
137
|
+
hash["rows"] = @rows if @rows
|
|
138
|
+
return hash if @count < 2
|
|
139
|
+
|
|
140
|
+
hash.merge!("count" => @count, "span_ms" => ms(@last_finish - @first_start))
|
|
141
|
+
hash["call_site"] = context.call_site_hash(@call_site) if @call_site
|
|
142
|
+
hash
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
private
|
|
146
|
+
|
|
147
|
+
def sql_rows(raw)
|
|
148
|
+
raw[0] == :sql && raw[5].is_a?(Integer) ? raw[5] : nil
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def ms(seconds)
|
|
152
|
+
[(seconds * 1000.0).round(DECIMALS), 0.0].max
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
end
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GoodSorcery
|
|
4
|
+
module Performance
|
|
5
|
+
# Turns raw SQL into the text a trace may carry (DESIGN.md 8.6): no
|
|
6
|
+
# comments, no literal values, IN lists and VALUES rows collapsed,
|
|
7
|
+
# whitespace collapsed. Bind placeholders ($1), identifiers and the
|
|
8
|
+
# keywords TRUE, FALSE and NULL stay readable.
|
|
9
|
+
#
|
|
10
|
+
# One left-to-right pass decides what each piece is, so a quote inside a
|
|
11
|
+
# comment, "--" inside a string or a digit inside a quoted identifier is
|
|
12
|
+
# read correctly. How quotes work depends on the database (SqlDialect):
|
|
13
|
+
# a backslash escapes only in MySQL and in Postgres E'…' strings, and
|
|
14
|
+
# "…" is a string in MySQL (and may be one in SQLite). The two ways of
|
|
15
|
+
# reading a backslash first disagree at a string that contains one, so
|
|
16
|
+
# when the dialect is unknown, or a string runs on to the end of a
|
|
17
|
+
# statement that was not cut off (the quotes were misread), everything
|
|
18
|
+
# from that string on becomes one placeholder: a misread never leaves a
|
|
19
|
+
# literal value in the text.
|
|
20
|
+
class SqlNormalizer
|
|
21
|
+
INPUT_LIMIT = 4_000
|
|
22
|
+
OUTPUT_LIMIT = 2_000
|
|
23
|
+
PLACEHOLDER = "?"
|
|
24
|
+
ELLIPSIS = "…"
|
|
25
|
+
|
|
26
|
+
COMMENT = %r{/\*.*?(?:\*/|\z)|--[^\n]*}m
|
|
27
|
+
BACKTICK = /`[^`]*(?:`|\z)/
|
|
28
|
+
DOLLAR_QUOTED = /\$(?<tag>(?:[a-z_]\w*)?)\$.*?(?:(?<close>\$\k<tag>\$)|\z)/mi
|
|
29
|
+
STANDARD_STRING = /'(?:[^']|'')*(?:(?<close>')|\z)/
|
|
30
|
+
BACKSLASH_STRING = /'(?:[^'\\]|\\.|'')*(?:(?<close>')|\z)/m
|
|
31
|
+
ESCAPE_STRING = /(?<![\w$])[eE]'(?:[^'\\]|\\.|'')*(?:(?<close>')|\z)/m
|
|
32
|
+
STANDARD_DOUBLE = /"(?:[^"]|"")*(?:(?<close>")|\z)/
|
|
33
|
+
BACKSLASH_DOUBLE = /"(?:[^"\\]|\\.|"")*(?:(?<close>")|\z)/m
|
|
34
|
+
NUMBER = /(?<![\w$])(?:0x[0-9a-f]+|\d+(?:\.\d+)?(?:e[+-]?\d+)?)/i
|
|
35
|
+
# What an unquoted identifier may contain; other "…" text is a value.
|
|
36
|
+
IDENTIFIER = /\A[[:alnum:]_$]*\z/
|
|
37
|
+
|
|
38
|
+
def self.token(strings, double)
|
|
39
|
+
/(?<comment>#{COMMENT})|(?<backtick>#{BACKTICK})|(?<double>#{double})
|
|
40
|
+
|(?<literal>#{DOLLAR_QUOTED}|#{strings})|(?<number>#{NUMBER})/x
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# One way of reading quotes: the token pattern and what "…" is
|
|
44
|
+
# (:identifier, :literal, or :guess from its contents).
|
|
45
|
+
Rules = Struct.new(:token, :double)
|
|
46
|
+
RULES = {
|
|
47
|
+
postgres: Rules.new(token(/#{ESCAPE_STRING}|#{STANDARD_STRING}/, STANDARD_DOUBLE), :identifier),
|
|
48
|
+
sqlite: Rules.new(token(STANDARD_STRING, STANDARD_DOUBLE), :guess),
|
|
49
|
+
mysql: Rules.new(token(BACKSLASH_STRING, BACKSLASH_DOUBLE), :literal),
|
|
50
|
+
nil => Rules.new(token(BACKSLASH_STRING, BACKSLASH_DOUBLE), :guess)
|
|
51
|
+
}.freeze
|
|
52
|
+
|
|
53
|
+
LIST_ITEM = /(?:-?\?|\$\d+)/
|
|
54
|
+
IN_LIST = /\bIN\s*\(\s*#{LIST_ITEM}(?:\s*,\s*#{LIST_ITEM})*\s*\)/io
|
|
55
|
+
VALUES_ROWS = /(\bVALUES\s*\([^()]*\))(?:\s*,\s*\([^()]*\))+/i
|
|
56
|
+
WHITESPACE = /\s+/
|
|
57
|
+
|
|
58
|
+
# +dialect+ is :postgres, :mysql, :sqlite or nil (unknown).
|
|
59
|
+
def normalize(sql, dialect = nil)
|
|
60
|
+
text = Text.utf8(sql.is_a?(String) ? sql : sql.to_s)
|
|
61
|
+
cut = text.length > INPUT_LIMIT
|
|
62
|
+
text = remove_literals(Text.limit(text, INPUT_LIMIT), dialect, cut)
|
|
63
|
+
text = text.gsub(IN_LIST, "IN (#{ELLIPSIS})").gsub(VALUES_ROWS) { "#{Regexp.last_match(1)}, #{ELLIPSIS}" }
|
|
64
|
+
Text.limit(text.gsub(WHITESPACE, " ").strip, OUTPUT_LIMIT)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
private
|
|
68
|
+
|
|
69
|
+
# With a known dialect, a string left open is trusted only where the
|
|
70
|
+
# length cap cut the statement.
|
|
71
|
+
def remove_literals(text, dialect, cut)
|
|
72
|
+
rules = RULES.fetch(dialect, RULES[nil])
|
|
73
|
+
pass = Pass.new(text, rules)
|
|
74
|
+
at = pass.first_backslash
|
|
75
|
+
return pass.result if at.nil? || (dialect && (pass.closed? || cut))
|
|
76
|
+
|
|
77
|
+
"#{Pass.new(text[0, at], rules).result} #{PLACEHOLDER}"
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# One left-to-right replacement of comments, literals and numbers.
|
|
81
|
+
class Pass
|
|
82
|
+
attr_reader :result, :first_backslash
|
|
83
|
+
|
|
84
|
+
def initialize(text, rules)
|
|
85
|
+
@rules = rules
|
|
86
|
+
@open = false
|
|
87
|
+
@first_backslash = nil
|
|
88
|
+
@result = text.gsub(rules.token) { replacement(Regexp.last_match) }
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# => false when a string ran on to the end of the text.
|
|
92
|
+
def closed? = !@open
|
|
93
|
+
|
|
94
|
+
private
|
|
95
|
+
|
|
96
|
+
def replacement(match)
|
|
97
|
+
return " " if match[:comment]
|
|
98
|
+
return match[0] if match[:backtick] || identifier?(match)
|
|
99
|
+
|
|
100
|
+
note_quote(match) unless match[:number]
|
|
101
|
+
PLACEHOLDER
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def identifier?(match)
|
|
105
|
+
return false unless (double = match[:double])
|
|
106
|
+
|
|
107
|
+
case @rules.double
|
|
108
|
+
when :identifier then true
|
|
109
|
+
when :literal then false
|
|
110
|
+
else IDENTIFIER.match?(double[1..].delete_suffix("\""))
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Where the first quoted value holding a backslash starts, and
|
|
115
|
+
# whether one ran on to the end.
|
|
116
|
+
def note_quote(match)
|
|
117
|
+
@first_backslash ||= match.begin(0) if match[0].include?("\\")
|
|
118
|
+
@open = true if match[:close].nil?
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Which SQL dialect a sql.active_record payload's connection speaks
|
|
124
|
+
# (:postgres, :mysql, :sqlite, or nil), cached per adapter class.
|
|
125
|
+
module SqlDialect
|
|
126
|
+
MAX_ENTRIES = 32
|
|
127
|
+
@cache = {}
|
|
128
|
+
|
|
129
|
+
def self.for(connection)
|
|
130
|
+
return nil unless connection.respond_to?(:adapter_name)
|
|
131
|
+
|
|
132
|
+
klass = connection.class
|
|
133
|
+
return @cache[klass] if @cache.key?(klass)
|
|
134
|
+
|
|
135
|
+
dialect = from_name(connection.adapter_name)
|
|
136
|
+
@cache[klass] = dialect if @cache.size < MAX_ENTRIES
|
|
137
|
+
dialect
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def self.from_name(name)
|
|
141
|
+
case name.to_s
|
|
142
|
+
when /mysql|trilogy|maria/i then :mysql
|
|
143
|
+
when /postg|cockroach/i then :postgres
|
|
144
|
+
when /sqlite/i then :sqlite
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
end
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GoodSorcery
|
|
4
|
+
module Performance
|
|
5
|
+
# ActiveSupport::Notifications subscriptions (DESIGN.md 8.3), made once.
|
|
6
|
+
# Every callback returns at once unless a recorder is active on this
|
|
7
|
+
# fiber, and never raises into the instrumented code: a failure stops
|
|
8
|
+
# recording for that unit of work (Performance.with_recorder).
|
|
9
|
+
module Subscribers
|
|
10
|
+
IGNORED_SQL_NAMES = %w[SCHEMA CACHE].freeze
|
|
11
|
+
RENDER_EVENTS = %w[
|
|
12
|
+
render_template.action_view render_layout.action_view
|
|
13
|
+
render_partial.action_view render_collection.action_view
|
|
14
|
+
].freeze
|
|
15
|
+
# retry_on and discard_on handle the exception inside perform_now, so
|
|
16
|
+
# these mark the attempt as failed.
|
|
17
|
+
JOB_FAILURE_EVENTS = %w[enqueue_retry.active_job retry_stopped.active_job discard.active_job].freeze
|
|
18
|
+
|
|
19
|
+
module_function
|
|
20
|
+
|
|
21
|
+
def subscribe
|
|
22
|
+
return false if @subscribed || !defined?(::ActiveSupport::Notifications)
|
|
23
|
+
|
|
24
|
+
@subscribed = true
|
|
25
|
+
subscribe_timed(::ActiveSupport::Notifications)
|
|
26
|
+
JOB_FAILURE_EVENTS.each { |name| ::ActiveSupport::Notifications.subscribe(name) { |*| on_job_failure } }
|
|
27
|
+
RENDER_EVENTS.each { |name| ::ActiveSupport::Notifications.subscribe(name, RenderListener) }
|
|
28
|
+
true
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Monotonic subscribers get (name, start, finish, id, payload).
|
|
32
|
+
def subscribe_timed(notifications)
|
|
33
|
+
notifications.monotonic_subscribe("sql.active_record") do |_name, start, finish, _id, payload|
|
|
34
|
+
on_sql(start, finish, payload)
|
|
35
|
+
end
|
|
36
|
+
notifications.monotonic_subscribe("process_action.action_controller") do |_name, _start, _finish, _id, payload|
|
|
37
|
+
on_action(payload)
|
|
38
|
+
end
|
|
39
|
+
notifications.monotonic_subscribe("enqueue.active_job") do |_name, start, finish, _id, payload|
|
|
40
|
+
on_enqueue(start, finish, payload)
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def subscribed?
|
|
45
|
+
@subscribed ? true : false
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def on_sql(start, finish, payload)
|
|
49
|
+
Performance.with_recorder do |recorder|
|
|
50
|
+
next if payload[:cached] || IGNORED_SQL_NAMES.include?(payload[:name])
|
|
51
|
+
|
|
52
|
+
recorder.add_sql(start, finish, payload[:sql], payload[:name], payload[:row_count],
|
|
53
|
+
dialect: SqlDialect.for(payload[:connection])) do
|
|
54
|
+
CallSite.capture(Performance.paths)
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def on_action(payload)
|
|
60
|
+
Performance.with_recorder do |recorder|
|
|
61
|
+
next unless recorder.kind == :web && recorder.name.nil?
|
|
62
|
+
|
|
63
|
+
controller = payload[:controller]
|
|
64
|
+
recorder.name = "#{controller}##{payload[:action]}"
|
|
65
|
+
recorder.route = RoutePattern.for(payload[:request])
|
|
66
|
+
recorder.source = SourceLocation.for(controller)
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def on_enqueue(start, finish, payload)
|
|
71
|
+
Performance.with_recorder do |recorder|
|
|
72
|
+
job = payload[:job]
|
|
73
|
+
recorder.add_enqueue(start, finish, job.class.name, job.queue_name) if job
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def on_job_failure
|
|
78
|
+
Performance.with_recorder { |recorder| recorder.failed! if recorder.kind == :job }
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Evented listener for the render events: the start and finish of each
|
|
83
|
+
# render, so nested renders are counted once (Recorder#render_finished).
|
|
84
|
+
module RenderListener
|
|
85
|
+
module_function
|
|
86
|
+
|
|
87
|
+
def start(_name, _id, _payload)
|
|
88
|
+
Performance.with_recorder { |recorder| recorder.render_started(Performance.monotonic) }
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def finish(_name, _id, payload)
|
|
92
|
+
Performance.with_recorder { |recorder| recorder.render_finished(Performance.monotonic, payload[:identifier]) }
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# The matched route's pattern ("/products/:id"), without "(.:format)".
|
|
97
|
+
# Needs Rails 7.1 or newer; nil otherwise.
|
|
98
|
+
module RoutePattern
|
|
99
|
+
FORMAT_SUFFIX = "(.:format)"
|
|
100
|
+
|
|
101
|
+
def self.for(request)
|
|
102
|
+
return nil unless request.respond_to?(:route_uri_pattern)
|
|
103
|
+
|
|
104
|
+
request.route_uri_pattern&.delete_suffix(FORMAT_SUFFIX)
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# The app-relative file that defines a controller or job class, cached
|
|
109
|
+
# per class name. Classes outside the app root have none.
|
|
110
|
+
module SourceLocation
|
|
111
|
+
MAX_ENTRIES = 2_000
|
|
112
|
+
@cache = {}
|
|
113
|
+
@lock = Mutex.new
|
|
114
|
+
|
|
115
|
+
def self.for(class_name)
|
|
116
|
+
return nil if class_name.nil?
|
|
117
|
+
|
|
118
|
+
name = class_name.to_s
|
|
119
|
+
cached = @cache.fetch(name, false)
|
|
120
|
+
return cached unless cached == false
|
|
121
|
+
|
|
122
|
+
locate(name).tap { |path| @lock.synchronize { @cache[name] = path if @cache.size < MAX_ENTRIES } }
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def self.locate(name)
|
|
126
|
+
file, = Object.const_source_location(name)
|
|
127
|
+
return nil unless file
|
|
128
|
+
|
|
129
|
+
relative = Performance.paths.relative(file)
|
|
130
|
+
relative.start_with?("/") ? nil : relative
|
|
131
|
+
rescue NameError, ArgumentError
|
|
132
|
+
nil
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def self.clear
|
|
136
|
+
@lock.synchronize { @cache.clear }
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
end
|