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.
Files changed (36) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +9 -0
  3. data/README.md +30 -1
  4. data/lib/generators/goodsorcery/install/templates/initializer.rb +7 -0
  5. data/lib/goodsorcery/client.rb +15 -1
  6. data/lib/goodsorcery/configuration/performance_settings.rb +37 -0
  7. data/lib/goodsorcery/configuration/validation.rb +10 -0
  8. data/lib/goodsorcery/configuration.rb +3 -0
  9. data/lib/goodsorcery/defaults.rb +4 -0
  10. data/lib/goodsorcery/integrations/active_job.rb +4 -1
  11. data/lib/goodsorcery/integrations/rails.rb +3 -2
  12. data/lib/goodsorcery/integrations/sidekiq.rb +18 -2
  13. data/lib/goodsorcery/performance/agent.rb +104 -0
  14. data/lib/goodsorcery/performance/aggregator.rb +63 -0
  15. data/lib/goodsorcery/performance/call_site.rb +61 -0
  16. data/lib/goodsorcery/performance/flusher.rb +224 -0
  17. data/lib/goodsorcery/performance/histogram.rb +23 -0
  18. data/lib/goodsorcery/performance/job_timing.rb +106 -0
  19. data/lib/goodsorcery/performance/measurement.rb +117 -0
  20. data/lib/goodsorcery/performance/net_http.rb +37 -0
  21. data/lib/goodsorcery/performance/payloads.rb +110 -0
  22. data/lib/goodsorcery/performance/process_label.rb +22 -0
  23. data/lib/goodsorcery/performance/recorder.rb +202 -0
  24. data/lib/goodsorcery/performance/span_list.rb +156 -0
  25. data/lib/goodsorcery/performance/sql_normalizer.rb +149 -0
  26. data/lib/goodsorcery/performance/subscribers.rb +140 -0
  27. data/lib/goodsorcery/performance/trace_buffer.rb +78 -0
  28. data/lib/goodsorcery/performance/trace_builder.rb +121 -0
  29. data/lib/goodsorcery/performance/transaction_stats.rb +93 -0
  30. data/lib/goodsorcery/performance/uuid_v7.rb +27 -0
  31. data/lib/goodsorcery/performance.rb +158 -0
  32. data/lib/goodsorcery/rack/middleware.rb +10 -3
  33. data/lib/goodsorcery/transport.rb +12 -1
  34. data/lib/goodsorcery/version.rb +1 -1
  35. data/lib/goodsorcery.rb +17 -0
  36. 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