greenroom 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/CHANGELOG.md +16 -0
- data/LICENSE.txt +21 -0
- data/README.md +165 -0
- data/Rakefile +10 -0
- data/exe/greenroom +6 -0
- data/lib/greenroom/census/cursor.rb +48 -0
- data/lib/greenroom/census/job.rb +118 -0
- data/lib/greenroom/census/middleware.rb +30 -0
- data/lib/greenroom/census/run.rb +59 -0
- data/lib/greenroom/cli/check.rb +454 -0
- data/lib/greenroom/cli/judge.rb +273 -0
- data/lib/greenroom/cli.rb +30 -0
- data/lib/greenroom/digest.rb +28 -0
- data/lib/greenroom/experiment_behavior.rb +82 -0
- data/lib/greenroom/recorder.rb +219 -0
- data/lib/greenroom/reporter/new_relic.rb +15 -0
- data/lib/greenroom/reporter.rb +61 -0
- data/lib/greenroom/version.rb +5 -0
- data/lib/greenroom.rb +57 -0
- data/sig/greenroom.rbs +4 -0
- metadata +105 -0
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "date"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
require "json"
|
|
6
|
+
require "open3"
|
|
7
|
+
require "optparse"
|
|
8
|
+
require "tmpdir"
|
|
9
|
+
require "time"
|
|
10
|
+
require "yaml"
|
|
11
|
+
require_relative "check"
|
|
12
|
+
|
|
13
|
+
module Greenroom
|
|
14
|
+
module CLI
|
|
15
|
+
module Judge
|
|
16
|
+
DEFAULTS = {
|
|
17
|
+
"minimum_comparisons" => 1000,
|
|
18
|
+
"consecutive_clean_nights" => 2,
|
|
19
|
+
"expiry_days" => 30,
|
|
20
|
+
"minimum_comparison_ratio" => 0.8
|
|
21
|
+
}.freeze
|
|
22
|
+
Verdict = Data.define(:experiment, :verdict, :reason, :patch, :patch_error)
|
|
23
|
+
|
|
24
|
+
class << self
|
|
25
|
+
def run(arguments, stdout: $stdout, stderr: $stderr)
|
|
26
|
+
options = {repo: ".", json: false}
|
|
27
|
+
parser = option_parser(options)
|
|
28
|
+
parser.parse!(arguments)
|
|
29
|
+
return usage(stderr, parser, "The --events option is required.") unless options[:events]
|
|
30
|
+
return usage(stderr, parser, "The --config option is required.") unless options[:config]
|
|
31
|
+
return usage(stderr, parser, "Unexpected arguments are present.") unless arguments.empty?
|
|
32
|
+
|
|
33
|
+
# The host application CI owns the service credentials and pull
|
|
34
|
+
# request delivery. JSON input keeps these service boundaries outside
|
|
35
|
+
# the gem.
|
|
36
|
+
events = JSON.parse(File.read(options[:events]))
|
|
37
|
+
config = DEFAULTS.merge(YAML.safe_load_file(options[:config]) || {})
|
|
38
|
+
require "rubocop-ast"
|
|
39
|
+
verdicts, errors = Evaluation.new(events, config, options[:repo]).call
|
|
40
|
+
print_results(stdout, verdicts, errors, options[:json])
|
|
41
|
+
# The exit status reports when a person must act. A successful promotion
|
|
42
|
+
# stays green because the host CI uses the JSON result to open its
|
|
43
|
+
# pull request.
|
|
44
|
+
(verdicts.any? { |item| %w[nogo attention expired].include?(item.verdict) }) ? 1 : 0
|
|
45
|
+
rescue OptionParser::ParseError, JSON::ParserError, Errno::ENOENT, Psych::Exception => error
|
|
46
|
+
stderr.puts(error.message)
|
|
47
|
+
2
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
def option_parser(options)
|
|
53
|
+
OptionParser.new do |parser|
|
|
54
|
+
parser.banner = "Usage: greenroom judge --events PATH --config PATH [--repo PATH] [--json]"
|
|
55
|
+
parser.on("--events PATH") { |value| options[:events] = value }
|
|
56
|
+
parser.on("--config PATH") { |value| options[:config] = value }
|
|
57
|
+
parser.on("--repo PATH") { |value| options[:repo] = value }
|
|
58
|
+
parser.on("--json") { options[:json] = true }
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def usage(stderr, parser, message)
|
|
63
|
+
stderr.puts(message)
|
|
64
|
+
stderr.puts(parser)
|
|
65
|
+
2
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def print_results(stdout, verdicts, errors, json)
|
|
69
|
+
if json
|
|
70
|
+
stdout.puts(JSON.generate({verdicts: verdicts.map(&:to_h), errors:}))
|
|
71
|
+
return
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
verdicts.each do |item|
|
|
75
|
+
line = "#{item.experiment}: #{item.verdict} (#{item.reason})"
|
|
76
|
+
if item.patch_error
|
|
77
|
+
detail = item.patch_error.sub(/\A[A-Z]/) { |character| character.downcase }.delete_suffix(".")
|
|
78
|
+
line = "#{line} -- no patch: #{detail}"
|
|
79
|
+
end
|
|
80
|
+
stdout.puts(line)
|
|
81
|
+
stdout.write(item.patch) if item.patch
|
|
82
|
+
end
|
|
83
|
+
errors.each { |error| stdout.puts("error: #{error}") }
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
class Evaluation
|
|
88
|
+
def initialize(events, config, repository)
|
|
89
|
+
@events = events
|
|
90
|
+
@config = config
|
|
91
|
+
@repository = File.expand_path(repository)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def call
|
|
95
|
+
valid, errors = readable_events
|
|
96
|
+
verdicts = valid.group_by { |event| event.fetch("experiment") }.map do |name, entries|
|
|
97
|
+
evaluate(name, deduplicate(entries))
|
|
98
|
+
end
|
|
99
|
+
[verdicts, errors]
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
private
|
|
103
|
+
|
|
104
|
+
def readable_events
|
|
105
|
+
valid = []
|
|
106
|
+
errors = []
|
|
107
|
+
@events.each_with_index do |event, index|
|
|
108
|
+
if event["timestamp"]
|
|
109
|
+
valid << event
|
|
110
|
+
else
|
|
111
|
+
errors << "Event #{index + 1} has no timestamp."
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
[valid, errors]
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def deduplicate(events)
|
|
118
|
+
# A retry can report the same run again with corrected data. Assignment
|
|
119
|
+
# keeps the later report as the run's final value.
|
|
120
|
+
events.each_with_object({}) { |event, runs| runs[event.fetch("run_id")] = event }.values
|
|
121
|
+
.sort_by { |event| Time.iso8601(event.fetch("timestamp")) }
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def evaluate(name, runs)
|
|
125
|
+
latest = runs.last
|
|
126
|
+
previous = runs[-2]
|
|
127
|
+
verdict, reason = decision(runs, latest, previous)
|
|
128
|
+
patch = %w[go nogo expired].include?(verdict) ? Patch.new(@repository, name, verdict).call : nil
|
|
129
|
+
patch_error = nil
|
|
130
|
+
if patch.is_a?(Array)
|
|
131
|
+
# Event data determines the verdict reason, but repository state
|
|
132
|
+
# determines the patch result. One result must not replace the other.
|
|
133
|
+
patch_error = patch.last
|
|
134
|
+
patch = nil
|
|
135
|
+
end
|
|
136
|
+
Verdict.new(name, verdict, reason, patch, patch_error)
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def decision(runs, latest, previous)
|
|
140
|
+
# Candidate failures, row failures, and a reduced sample question the
|
|
141
|
+
# census itself. A person must validate the census before any
|
|
142
|
+
# patch.
|
|
143
|
+
attention = attention_reason(latest, previous)
|
|
144
|
+
return ["attention", attention] if attention
|
|
145
|
+
return ["nogo", "#{latest.fetch("mismatches")} mismatches"] if latest.fetch("mismatches").positive?
|
|
146
|
+
if expired?(runs, latest)
|
|
147
|
+
return ["expired", "#{latest.fetch("comparisons")} comparisons after #{@config.fetch("expiry_days")} days"]
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# Scheduled runs do not replace a missed night. Counting reported runs
|
|
151
|
+
# prevents a deployment pause from becoming a failed observation.
|
|
152
|
+
clean = runs.reverse.take_while { |run| clean?(run) }.length
|
|
153
|
+
needed = @config.fetch("consecutive_clean_nights")
|
|
154
|
+
return ["go", "#{needed} clean nights, #{latest.fetch("comparisons")} comparisons"] if clean >= needed
|
|
155
|
+
|
|
156
|
+
# Most nightly runs cannot support a final decision. Waiting avoids an
|
|
157
|
+
# early promotion and avoids repeated alerts for normal evidence
|
|
158
|
+
# collection.
|
|
159
|
+
["waiting", "#{clean} of #{needed} clean nights, #{latest.fetch("comparisons")} comparisons"]
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def attention_reason(latest, previous)
|
|
163
|
+
# Control errors describe the established production path. Ignored
|
|
164
|
+
# comparisons remain operational data, but version 1 does not use them
|
|
165
|
+
# for decisions. If either metric caused attention, normal nightly runs
|
|
166
|
+
# would alert a person. Frequent alerts would make each alert less useful.
|
|
167
|
+
return "#{latest.fetch("candidate_errors")} candidate errors" if latest.fetch("candidate_errors").positive?
|
|
168
|
+
return "#{latest.fetch("row_errors")} row errors" if latest.fetch("row_errors").positive?
|
|
169
|
+
# A smaller denominator can make zero mismatches look reliable after
|
|
170
|
+
# the census examines too little data.
|
|
171
|
+
# The comparison needs a prior positive value because a ratio against
|
|
172
|
+
# a missing run or zero is undefined.
|
|
173
|
+
return unless previous&.fetch("comparisons")&.positive?
|
|
174
|
+
return unless latest.fetch("comparisons") < previous.fetch("comparisons") * @config.fetch("minimum_comparison_ratio")
|
|
175
|
+
|
|
176
|
+
"comparisons fell from #{previous.fetch("comparisons")} to #{latest.fetch("comparisons")}"
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def expired?(runs, latest)
|
|
180
|
+
# Expiry requires a reported run below the threshold. A missing event
|
|
181
|
+
# means that the census did not run, not that it examined zero rows.
|
|
182
|
+
return false unless latest.fetch("comparisons") < @config.fetch("minimum_comparisons")
|
|
183
|
+
|
|
184
|
+
first = Time.iso8601(runs.first.fetch("timestamp"))
|
|
185
|
+
last = Time.iso8601(latest.fetch("timestamp"))
|
|
186
|
+
last - first >= @config.fetch("expiry_days") * 86_400
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def clean?(run)
|
|
190
|
+
run.fetch("mismatches").zero? && run.fetch("comparisons") >= @config.fetch("minimum_comparisons")
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
class Patch
|
|
195
|
+
def initialize(repository, experiment, verdict)
|
|
196
|
+
@repository = repository
|
|
197
|
+
@experiment = experiment
|
|
198
|
+
@verdict = verdict
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def call
|
|
202
|
+
matches = find_matches
|
|
203
|
+
# Multiple locations make the patch target ambiguous. The judge stops
|
|
204
|
+
# instead of changing a location that it cannot identify safely.
|
|
205
|
+
return [nil, "The experiment has #{matches.length} locations."] unless matches.one?
|
|
206
|
+
|
|
207
|
+
path, source, match = matches.first
|
|
208
|
+
changed = (@verdict == "go") ? promote(source, match) : revert(source, match)
|
|
209
|
+
build(path, source, changed)
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
private
|
|
213
|
+
|
|
214
|
+
def find_matches
|
|
215
|
+
Dir.glob(File.join(@repository, "**", "*.rb")).filter_map do |path|
|
|
216
|
+
source = File.read(path)
|
|
217
|
+
next unless source.include?(@experiment)
|
|
218
|
+
|
|
219
|
+
# The merge gate defines the accepted experiment structure. Reusing
|
|
220
|
+
# its analyzer prevents the judge from patching a shape that the
|
|
221
|
+
# gate rejects.
|
|
222
|
+
analyzer = Check::Analyzer.new(source, source)
|
|
223
|
+
analyzer.experiments.filter { |item| item.name == @experiment }.map { |item| [path, source, item] }
|
|
224
|
+
rescue Check::Analyzer::SourceError
|
|
225
|
+
[]
|
|
226
|
+
end.flatten(1)
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
def promote(source, match)
|
|
230
|
+
string = match.method.each_descendant(:str).find { |node| node.value == @experiment }
|
|
231
|
+
offset = string.source_range.end_pos
|
|
232
|
+
source.dup.insert(offset, ', run: "candidate"')
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
def revert(source, match)
|
|
236
|
+
body = match.method.children[2]
|
|
237
|
+
use_body = match.use_block.children[2]
|
|
238
|
+
changed = source.dup
|
|
239
|
+
candidate_range = full_line_range(changed, match.candidate.source_range)
|
|
240
|
+
# Revert reviews compare the result byte for byte with the source from
|
|
241
|
+
# before the wrap. The separator before the candidate belongs to it.
|
|
242
|
+
preceding_line = changed.rindex("\n", candidate_range.begin - 2)&.+(1) || 0
|
|
243
|
+
candidate_range = preceding_line..candidate_range.end if changed[preceding_line...candidate_range.begin].strip.empty?
|
|
244
|
+
changed.slice!(candidate_range)
|
|
245
|
+
replace(changed, body.source_range, use_body.source)
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
def replace(source, range, replacement)
|
|
249
|
+
source.dup.tap { |value| value[range.begin_pos...range.end_pos] = replacement }
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
def full_line_range(source, range)
|
|
253
|
+
start = source.rindex("\n", range.begin_pos - 1)&.+(1) || 0
|
|
254
|
+
finish = source.index("\n", range.end_pos) || source.length - 1
|
|
255
|
+
start..finish
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
def build(path, before, after)
|
|
259
|
+
# The host application owns its working tree and pull requests. A
|
|
260
|
+
# unified diff lets that host inspect and apply the change itself.
|
|
261
|
+
relative = path.delete_prefix("#{@repository}/")
|
|
262
|
+
Dir.mktmpdir("greenroom-judge-patch") do |directory|
|
|
263
|
+
%w[a b].each { |side| FileUtils.mkdir_p(File.join(directory, side, File.dirname(relative))) }
|
|
264
|
+
File.write(File.join(directory, "a", relative), before)
|
|
265
|
+
File.write(File.join(directory, "b", relative), after)
|
|
266
|
+
stdout, _stderr, _status = Open3.capture3("git", "diff", "--no-index", "--", "a/#{relative}", "b/#{relative}", chdir: directory)
|
|
267
|
+
stdout.gsub("a/a/#{relative}", "a/#{relative}").gsub("b/b/#{relative}", "b/#{relative}")
|
|
268
|
+
end
|
|
269
|
+
end
|
|
270
|
+
end
|
|
271
|
+
end
|
|
272
|
+
end
|
|
273
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "optparse"
|
|
4
|
+
|
|
5
|
+
module Greenroom
|
|
6
|
+
module CLI
|
|
7
|
+
class << self
|
|
8
|
+
def run(arguments, stdout: $stdout, stderr: $stderr)
|
|
9
|
+
command = arguments.first
|
|
10
|
+
return usage(stderr, "A command is required.") unless command
|
|
11
|
+
if command == "judge"
|
|
12
|
+
require_relative "cli/judge"
|
|
13
|
+
return Judge.run(arguments.drop(1), stdout:, stderr:)
|
|
14
|
+
end
|
|
15
|
+
return usage(stderr, "Unknown command: #{command}.") unless command == "check"
|
|
16
|
+
|
|
17
|
+
require_relative "cli/check"
|
|
18
|
+
Check.run(arguments.drop(1), stdout:, stderr:)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
private
|
|
22
|
+
|
|
23
|
+
def usage(stderr, message)
|
|
24
|
+
stderr.puts(message)
|
|
25
|
+
stderr.puts("Usage: greenroom <check|judge> [options]")
|
|
26
|
+
2
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
|
|
5
|
+
module Greenroom
|
|
6
|
+
# Turns an observed value into a short, fixed-length digest for a sample
|
|
7
|
+
# event. Never carries the value itself, only evidence that two values did
|
|
8
|
+
# or did not match.
|
|
9
|
+
module Digest
|
|
10
|
+
module_function
|
|
11
|
+
|
|
12
|
+
# Value stability is the host's job, via Scientist's own `clean` block --
|
|
13
|
+
# this module only shortens whatever that block already produced. The
|
|
14
|
+
# default #inspect can embed an object's memory address, so a host that
|
|
15
|
+
# needs a digest to stay stable across runs must write a `clean` block
|
|
16
|
+
# rather than rely on this method to normalize the value.
|
|
17
|
+
#
|
|
18
|
+
# `::Digest::SHA256`, not `SHA256`: this module is itself named `Digest`,
|
|
19
|
+
# so an unqualified `Digest::SHA256` would resolve to
|
|
20
|
+
# `Greenroom::Digest::SHA256` (itself) under Ruby's lexical scoping,
|
|
21
|
+
# rather than the standard library's `::Digest` module.
|
|
22
|
+
def of(value)
|
|
23
|
+
return nil if value.nil?
|
|
24
|
+
|
|
25
|
+
::Digest::SHA256.hexdigest(value.inspect)
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Greenroom
|
|
4
|
+
# The two behaviours `Scientist::Experiment` requires from its includer,
|
|
5
|
+
# plus a non-raising override of `raised`.
|
|
6
|
+
#
|
|
7
|
+
# A module, not a concrete class: `Scientist::Experiment` has exactly one
|
|
8
|
+
# registration slot per process (`Scientist::Experiment.set_default`), and
|
|
9
|
+
# a later `include` silently steals it from an earlier one. If this gem
|
|
10
|
+
# shipped a class for a host to include, requiring this gem at all would
|
|
11
|
+
# risk taking over that slot from the host's own experiment class. A host
|
|
12
|
+
# includes both `Scientist::Experiment` and this module into its own
|
|
13
|
+
# class instead, keeping the registration decision with the host.
|
|
14
|
+
#
|
|
15
|
+
# A host that wants an experiment to run outside a census (for example,
|
|
16
|
+
# sampling live traffic) overrides `enabled?` in its own class; that is
|
|
17
|
+
# why `publish` still works when no recorder is installed.
|
|
18
|
+
module ExperimentBehavior
|
|
19
|
+
# True exactly while a census job has a recorder installed on this
|
|
20
|
+
# thread. The recorder's presence is what turns an experiment on, so
|
|
21
|
+
# there is only one mechanism to reason about, not two that could drift
|
|
22
|
+
# out of sync.
|
|
23
|
+
def enabled?
|
|
24
|
+
!Recorder.current.nil?
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# A recorder installed on this thread means a census is in progress:
|
|
28
|
+
# hand it the result to tally instead of sending an event per
|
|
29
|
+
# comparison. With no recorder installed, an experiment that a host
|
|
30
|
+
# enabled outside a census (see the module documentation) still gets a
|
|
31
|
+
# single event per comparison, sent directly.
|
|
32
|
+
def publish(result)
|
|
33
|
+
recorder = Recorder.current
|
|
34
|
+
if recorder
|
|
35
|
+
recorder.record(result)
|
|
36
|
+
else
|
|
37
|
+
publish_comparison(result)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Scientist's default `raised` re-raises, which would let a failure in
|
|
42
|
+
# this gem's own instrumentation (a bad `clean` block, a reporter
|
|
43
|
+
# outage) break the host's actual call. Recording the failure and
|
|
44
|
+
# returning nil keeps that failure from ever reaching the caller.
|
|
45
|
+
def raised(operation, error)
|
|
46
|
+
begin
|
|
47
|
+
Greenroom.reporter.record("GreenroomInternalError", {
|
|
48
|
+
experiment: name,
|
|
49
|
+
operation: operation,
|
|
50
|
+
error: error.class.name
|
|
51
|
+
})
|
|
52
|
+
rescue
|
|
53
|
+
# Recording the failure must never itself fail the call that
|
|
54
|
+
# triggered it -- a reporter outage would otherwise take down the
|
|
55
|
+
# very requests this gem exists to observe, in a way that a broken
|
|
56
|
+
# bit of measurement code has no business doing.
|
|
57
|
+
end
|
|
58
|
+
nil
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
private
|
|
62
|
+
|
|
63
|
+
def publish_comparison(result)
|
|
64
|
+
candidate = result.candidates.first
|
|
65
|
+
Greenroom.reporter.record("GreenroomComparison", {
|
|
66
|
+
experiment: result.experiment_name,
|
|
67
|
+
matched: result.matched?,
|
|
68
|
+
control_duration_ms: duration_ms(result.control),
|
|
69
|
+
candidate_duration_ms: duration_ms(candidate),
|
|
70
|
+
candidate_error: error_class_name(candidate)
|
|
71
|
+
})
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def duration_ms(observation)
|
|
75
|
+
observation && observation.duration * 1000
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def error_class_name(observation)
|
|
79
|
+
observation.exception.class.name if observation&.raised?
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "digest"
|
|
4
|
+
|
|
5
|
+
module Greenroom
|
|
6
|
+
# The tallying half of a census run. A census job installs one of these on
|
|
7
|
+
# the thread it is running on; while it is installed, `enabled?` is true
|
|
8
|
+
# and comparisons flow into it instead of going nowhere. Uninstalling it
|
|
9
|
+
# turns comparisons back off, so the presence of a recorder is the one
|
|
10
|
+
# mechanism behind both "is the experiment running" and "where do the
|
|
11
|
+
# results go".
|
|
12
|
+
class Recorder
|
|
13
|
+
# A mutable set of counters for one experiment name. Kept separate from
|
|
14
|
+
# `Recorder` itself because a single run can, in principle, touch more
|
|
15
|
+
# than one experiment name (a misconfigured target, or a host that reuses
|
|
16
|
+
# one job for several experiments), and each needs its own counts.
|
|
17
|
+
class Tally
|
|
18
|
+
attr_accessor :comparisons, :mismatches, :ignored, :candidate_errors, :control_errors
|
|
19
|
+
|
|
20
|
+
def initialize
|
|
21
|
+
@comparisons = 0
|
|
22
|
+
@mismatches = 0
|
|
23
|
+
@ignored = 0
|
|
24
|
+
@candidate_errors = 0
|
|
25
|
+
@control_errors = 0
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# String keys, not symbols: a Tally's hash only ever leaves this class
|
|
29
|
+
# inside `Recorder#snapshot`, and that Hash must already be shaped like
|
|
30
|
+
# JSON, because it travels through `Sidekiq.dump_json` / `load_json`
|
|
31
|
+
# (which always returns string keys) on the way to and from a cursor.
|
|
32
|
+
def to_h
|
|
33
|
+
{
|
|
34
|
+
"comparisons" => comparisons,
|
|
35
|
+
"mismatches" => mismatches,
|
|
36
|
+
"ignored" => ignored,
|
|
37
|
+
"candidate_errors" => candidate_errors,
|
|
38
|
+
"control_errors" => control_errors
|
|
39
|
+
}
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# census jobs process one row per thread, so the thread is the unit a
|
|
44
|
+
# recorder must be scoped to: `Thread.current` (fiber-local by default)
|
|
45
|
+
# gives each thread its own slot without one thread's recorder leaking
|
|
46
|
+
# into a concurrently running, unrelated job on another thread.
|
|
47
|
+
THREAD_KEY = :greenroom_recorder
|
|
48
|
+
private_constant :THREAD_KEY
|
|
49
|
+
|
|
50
|
+
class << self
|
|
51
|
+
def current
|
|
52
|
+
Thread.current[THREAD_KEY]
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def install(recorder)
|
|
56
|
+
Thread.current[THREAD_KEY] = recorder
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Returns the recorder that was installed, so a caller that wants to
|
|
60
|
+
# flush it (`on_stop`) does not need to have held onto a reference
|
|
61
|
+
# itself.
|
|
62
|
+
def uninstall
|
|
63
|
+
recorder = Thread.current[THREAD_KEY]
|
|
64
|
+
Thread.current[THREAD_KEY] = nil
|
|
65
|
+
recorder
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# For tests and any plain call site that wants a recorder installed
|
|
69
|
+
# for the duration of a block. `ensure` uninstalls even if the block
|
|
70
|
+
# raises, so a failing test cannot leave a recorder behind for the
|
|
71
|
+
# next one.
|
|
72
|
+
def with(recorder)
|
|
73
|
+
install(recorder)
|
|
74
|
+
yield
|
|
75
|
+
ensure
|
|
76
|
+
uninstall
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
attr_reader :run_id, :sample_limit, :rows_scanned, :tallies, :samples
|
|
81
|
+
attr_accessor :subject
|
|
82
|
+
|
|
83
|
+
def initialize(run_id:, experiment: nil, sample_limit: 100)
|
|
84
|
+
@run_id = run_id
|
|
85
|
+
@sample_limit = sample_limit
|
|
86
|
+
@rows_scanned = 0
|
|
87
|
+
@row_errors = 0
|
|
88
|
+
@tallies = {}
|
|
89
|
+
@samples = []
|
|
90
|
+
@subject = nil
|
|
91
|
+
|
|
92
|
+
# A run that never triggers a single comparison (a module-only
|
|
93
|
+
# include, or a target whose `call` never reaches `Scientist.run`)
|
|
94
|
+
# would otherwise leave `tallies` empty and `flush_total` silent. A
|
|
95
|
+
# silent run and a run that never started look identical from the
|
|
96
|
+
# outside, so the zero must be sent, not inferred from an absence.
|
|
97
|
+
tally_for(experiment) if experiment
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def count_row
|
|
101
|
+
@rows_scanned += 1
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def record_row_error(_error)
|
|
105
|
+
@row_errors += 1
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Receives a `Scientist::Result` from `publish`. Counts are split into
|
|
109
|
+
# "mismatched" and "ignored" so that a host's `ignore` block, which
|
|
110
|
+
# exists to suppress noisy known-differences, does not also hide how
|
|
111
|
+
# much it is suppressing.
|
|
112
|
+
def record(result)
|
|
113
|
+
tally = tally_for(result.experiment_name)
|
|
114
|
+
tally.comparisons += 1
|
|
115
|
+
tally.mismatches += 1 if result.mismatched?
|
|
116
|
+
tally.ignored += 1 if result.ignored?
|
|
117
|
+
tally.candidate_errors += result.candidates.count(&:raised?)
|
|
118
|
+
tally.control_errors += 1 if result.control&.raised?
|
|
119
|
+
|
|
120
|
+
sample(result) if result.mismatched?
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def snapshot
|
|
124
|
+
{
|
|
125
|
+
"rows_scanned" => @rows_scanned,
|
|
126
|
+
"row_errors" => @row_errors,
|
|
127
|
+
"tallies" => @tallies.transform_values(&:to_h)
|
|
128
|
+
}
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def restore(snapshot)
|
|
132
|
+
@rows_scanned = snapshot.fetch("rows_scanned", 0).to_i
|
|
133
|
+
@row_errors = snapshot.fetch("row_errors", 0).to_i
|
|
134
|
+
# Merges into whatever `@tallies` already holds instead of replacing
|
|
135
|
+
# it outright. A resumed segment's `Recorder.new(experiment:)` seeds a
|
|
136
|
+
# zero tally under that name (see the comment in `initialize`) before
|
|
137
|
+
# `restore` ever runs; a snapshot taken before the first comparison
|
|
138
|
+
# for that name does not carry it, and replacing `@tallies` wholesale
|
|
139
|
+
# would erase the seeded zero and lose the name -- the run's later
|
|
140
|
+
# `flush_total` would then report `experiment: nil` instead of the
|
|
141
|
+
# name it was actually comparing.
|
|
142
|
+
snapshot.fetch("tallies", {}).each do |name, counts|
|
|
143
|
+
tally = tally_for(name)
|
|
144
|
+
tally.comparisons = counts.fetch("comparisons", 0).to_i
|
|
145
|
+
tally.mismatches = counts.fetch("mismatches", 0).to_i
|
|
146
|
+
tally.ignored = counts.fetch("ignored", 0).to_i
|
|
147
|
+
tally.candidate_errors = counts.fetch("candidate_errors", 0).to_i
|
|
148
|
+
tally.control_errors = counts.fetch("control_errors", 0).to_i
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# Sends one progress event (this run is alive, with the counts so far)
|
|
153
|
+
# and one mismatch event per sample gathered since the last flush. The
|
|
154
|
+
# samples that were just sent are discarded afterward: a sample is
|
|
155
|
+
# evidence, not part of the denominator, so re-sending it on resume
|
|
156
|
+
# (see `Census::Cursor`) is harmless, and keeping it around forever
|
|
157
|
+
# would only grow memory for no benefit.
|
|
158
|
+
def flush_segment(reporter)
|
|
159
|
+
emit(reporter, "GreenroomCensusProgress")
|
|
160
|
+
@samples.each { |sample| reporter.record("GreenroomMismatch", sample) }
|
|
161
|
+
@samples.clear
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# Sends the run's aggregate event: the one number a judge reads as the
|
|
165
|
+
# denominator. Splitting counts across `flush_segment` calls and asking
|
|
166
|
+
# a judge to sum them would make that sum itself something to verify for
|
|
167
|
+
# double-counting or gaps; a single event at the end avoids that.
|
|
168
|
+
def flush_total(reporter)
|
|
169
|
+
emit(reporter, "GreenroomCensus")
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
private
|
|
173
|
+
|
|
174
|
+
def tally_for(name)
|
|
175
|
+
@tallies[name] ||= Tally.new
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def sample(result)
|
|
179
|
+
return if @samples.size >= @sample_limit
|
|
180
|
+
|
|
181
|
+
candidate = result.mismatched.first
|
|
182
|
+
@samples << {
|
|
183
|
+
run_id: run_id,
|
|
184
|
+
experiment: result.experiment_name,
|
|
185
|
+
subject: subject,
|
|
186
|
+
control_digest: Digest.of(result.control&.cleaned_value),
|
|
187
|
+
candidate_digest: Digest.of(candidate&.cleaned_value),
|
|
188
|
+
control_error: error_class_name(result.control),
|
|
189
|
+
candidate_error: error_class_name(candidate)
|
|
190
|
+
}
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def error_class_name(observation)
|
|
194
|
+
observation.exception.class.name if observation&.raised?
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# Shared by `flush_segment` and `flush_total`: both send one event per
|
|
198
|
+
# experiment tallied, plus the run-wide `rows_scanned` and `row_errors`.
|
|
199
|
+
# When no comparison has run yet, `tallies` is empty; a run that is
|
|
200
|
+
# merely quiet still needs one event (see the comment in `initialize`),
|
|
201
|
+
# so this falls back to a single zero tally keyed by `nil`.
|
|
202
|
+
def emit(reporter, event_type)
|
|
203
|
+
entries = @tallies.empty? ? {nil => Tally.new} : @tallies
|
|
204
|
+
entries.each do |name, tally|
|
|
205
|
+
reporter.record(event_type, {
|
|
206
|
+
run_id: run_id,
|
|
207
|
+
experiment: name,
|
|
208
|
+
rows_scanned: rows_scanned,
|
|
209
|
+
comparisons: tally.comparisons,
|
|
210
|
+
mismatches: tally.mismatches,
|
|
211
|
+
ignored: tally.ignored,
|
|
212
|
+
candidate_errors: tally.candidate_errors,
|
|
213
|
+
control_errors: tally.control_errors,
|
|
214
|
+
row_errors: @row_errors
|
|
215
|
+
})
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "greenroom/reporter"
|
|
4
|
+
|
|
5
|
+
module Greenroom
|
|
6
|
+
# Sends recorded events to New Relic as custom events. Not required by
|
|
7
|
+
# `greenroom.rb`: a host that wants this delivery requires this file on
|
|
8
|
+
# purpose, after loading its own New Relic agent, so the gem's own runtime
|
|
9
|
+
# dependencies never include the agent gem.
|
|
10
|
+
class Reporter::NewRelic < Reporter
|
|
11
|
+
def record(event_type, attributes)
|
|
12
|
+
::NewRelic::Agent.record_custom_event(event_type, truncate(attributes))
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|