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.
@@ -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