perfgate 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/.rspec +3 -0
- data/.rubocop.yml +25 -0
- data/CHANGELOG.md +18 -0
- data/CONTRIBUTING.md +73 -0
- data/LICENSE +201 -0
- data/NOT_FINALIZED.md +70 -0
- data/README.md +86 -0
- data/ROADMAP.md +125 -0
- data/Rakefile +12 -0
- data/SECURITY.md +63 -0
- data/docs/README.md +7 -0
- data/docs/architecture.md +125 -0
- data/docs/compatibility.md +49 -0
- data/docs/launch-article.md +97 -0
- data/docs/onboarding.md +122 -0
- data/docs/telemetry.md +81 -0
- data/examples/rails-rspec-app/.github/workflows/baseline.yml +48 -0
- data/examples/rails-rspec-app/README.md +38 -0
- data/examples/rails-rspec-app/spec/jobs/invoice_job_spec.rb +15 -0
- data/examples/rails-rspec-app/spec/requests/checkout_spec.rb +24 -0
- data/exe/perfgate +7 -0
- data/lib/perfgate/cli/compare_command.rb +91 -0
- data/lib/perfgate/cli/run_command.rb +123 -0
- data/lib/perfgate/cli/run_comparison_reporter.rb +77 -0
- data/lib/perfgate/cli.rb +60 -0
- data/lib/perfgate/comparison/deterministic_metric_decision.rb +34 -0
- data/lib/perfgate/comparison/diagnostics.rb +70 -0
- data/lib/perfgate/comparison/engine.rb +79 -0
- data/lib/perfgate/comparison/metric_change.rb +84 -0
- data/lib/perfgate/comparison/metric_decision.rb +39 -0
- data/lib/perfgate/comparison/statistical_metric_decision.rb +75 -0
- data/lib/perfgate/comparison/workload_comparison.rb +98 -0
- data/lib/perfgate/config/defaults.rb +70 -0
- data/lib/perfgate/config/env_overrides.rb +54 -0
- data/lib/perfgate/config/schema.rb +53 -0
- data/lib/perfgate/config/validator.rb +64 -0
- data/lib/perfgate/config.rb +136 -0
- data/lib/perfgate/errors.rb +20 -0
- data/lib/perfgate/execution/process_runner.rb +71 -0
- data/lib/perfgate/execution/runner.rb +60 -0
- data/lib/perfgate/execution/sample_context.rb +66 -0
- data/lib/perfgate/fingerprints/compatibility.rb +46 -0
- data/lib/perfgate/fingerprints/components.rb +98 -0
- data/lib/perfgate/fingerprints/workload_definition.rb +30 -0
- data/lib/perfgate/instrumentation/allocations.rb +20 -0
- data/lib/perfgate/instrumentation/duration.rb +20 -0
- data/lib/perfgate/instrumentation/gc.rb +30 -0
- data/lib/perfgate/instrumentation/sql_activity.rb +50 -0
- data/lib/perfgate/instrumentation.rb +36 -0
- data/lib/perfgate/metrics/.gitkeep +0 -0
- data/lib/perfgate/policy/engine.rb +73 -0
- data/lib/perfgate/rails/.gitkeep +0 -0
- data/lib/perfgate/report/console.rb +57 -0
- data/lib/perfgate/report/markdown.rb +101 -0
- data/lib/perfgate/reporting/.gitkeep +0 -0
- data/lib/perfgate/rspec/discovery.rb +31 -0
- data/lib/perfgate/rspec/id_resolver.rb +30 -0
- data/lib/perfgate/rspec/workload_builder.rb +48 -0
- data/lib/perfgate/rspec.rb +14 -0
- data/lib/perfgate/serialization/run_result.rb +58 -0
- data/lib/perfgate/statistics/mann_whitney_u.rb +84 -0
- data/lib/perfgate/statistics/summary.rb +60 -0
- data/lib/perfgate/storage/adapter.rb +24 -0
- data/lib/perfgate/storage/archive.rb +69 -0
- data/lib/perfgate/storage/filesystem.rb +121 -0
- data/lib/perfgate/telemetry/.gitkeep +0 -0
- data/lib/perfgate/version.rb +5 -0
- data/lib/perfgate/workloads/registry.rb +50 -0
- data/lib/perfgate/workloads/workload.rb +26 -0
- data/lib/perfgate.rb +46 -0
- data/perfgate.gemspec +41 -0
- data/schemas/comparison-result-v1.schema.json +7 -0
- data/schemas/run-result-v1.schema.json +7 -0
- data/sig/perfgate.rbs +4 -0
- metadata +139 -0
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "sample_context"
|
|
4
|
+
require_relative "../fingerprints/workload_definition"
|
|
5
|
+
|
|
6
|
+
module Perfgate
|
|
7
|
+
module Execution
|
|
8
|
+
# Executes a single workload's warmup and measured samples in the
|
|
9
|
+
# current process (spec section 12.2, steps 4-9). Process isolation
|
|
10
|
+
# across workloads is layered on top by ProcessRunner. SQL count/
|
|
11
|
+
# duration, allocations, and GC deltas are collected only inside the
|
|
12
|
+
# workload's explicit `Perfgate.measure` block; duration falls back
|
|
13
|
+
# to wall-clock timing of the whole workload when `Perfgate.measure`
|
|
14
|
+
# is never called (spec section 13.1).
|
|
15
|
+
#
|
|
16
|
+
# A failed assertion or raised exception inside the workload is an
|
|
17
|
+
# execution error, not a performance regression (spec section 12.2):
|
|
18
|
+
# it aborts this workload's run and is reported as `status: "error"`
|
|
19
|
+
# rather than raising out of `call`.
|
|
20
|
+
class Runner
|
|
21
|
+
def initialize(workload)
|
|
22
|
+
@workload = workload
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def call
|
|
26
|
+
@workload.warmup.times { run_once }
|
|
27
|
+
|
|
28
|
+
samples = Array.new(@workload.samples) { run_once }
|
|
29
|
+
|
|
30
|
+
result("completed", samples, nil)
|
|
31
|
+
rescue StandardError => e
|
|
32
|
+
result("error", [], "#{e.class}: #{e.message}")
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def result(status, samples, error)
|
|
38
|
+
{
|
|
39
|
+
"id" => @workload.id, "status" => status, "samples" => samples, "error" => error,
|
|
40
|
+
"definition_hash" => Fingerprints::WorkloadDefinition.hash_for(@workload)
|
|
41
|
+
}
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def run_once
|
|
45
|
+
data = nil
|
|
46
|
+
|
|
47
|
+
SampleContext.with_new(metrics: @workload.metrics) do |context|
|
|
48
|
+
wall_start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
49
|
+
@workload.call
|
|
50
|
+
wall_elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - wall_start
|
|
51
|
+
|
|
52
|
+
data = context.data.dup
|
|
53
|
+
data["duration_ns"] ||= (wall_elapsed * 1_000_000_000).round
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
data
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../instrumentation"
|
|
4
|
+
|
|
5
|
+
module Perfgate
|
|
6
|
+
module Execution
|
|
7
|
+
# Thread-local scratch space that `Perfgate.measure` writes into when
|
|
8
|
+
# called from inside a running workload sample, per the explicit
|
|
9
|
+
# measurement contract in spec section 9.1 / 13.1: metrics other than
|
|
10
|
+
# the wall-clock duration fallback are only ever collected during the
|
|
11
|
+
# explicit `Perfgate.measure` block, never for the workload as a
|
|
12
|
+
# whole (Milestone 2 exit criterion: "metrics are correctly isolated
|
|
13
|
+
# to the measurement block").
|
|
14
|
+
class SampleContext
|
|
15
|
+
def self.current
|
|
16
|
+
Thread.current[:baseline_sample_context]
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def self.current=(context)
|
|
20
|
+
Thread.current[:baseline_sample_context] = context
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Runs the block with a fresh context active for the given metric
|
|
24
|
+
# names, restoring whatever was active beforehand (nil, normally)
|
|
25
|
+
# once the block finishes.
|
|
26
|
+
def self.with_new(metrics: [:duration])
|
|
27
|
+
previous = current
|
|
28
|
+
context = new(metrics: metrics)
|
|
29
|
+
self.current = context
|
|
30
|
+
yield context
|
|
31
|
+
ensure
|
|
32
|
+
self.current = previous
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def initialize(metrics: [:duration])
|
|
36
|
+
@collectors = Instrumentation.collectors_for(metrics)
|
|
37
|
+
@data = {}
|
|
38
|
+
@measured = false
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Runs the block with every configured collector started
|
|
42
|
+
# beforehand and stopped immediately after, merging their results
|
|
43
|
+
# into this sample's data. Safe to call more than once per sample;
|
|
44
|
+
# repeated calls accumulate (numeric values are summed).
|
|
45
|
+
def measure
|
|
46
|
+
@measured = true
|
|
47
|
+
started = @collectors.map { |collector| [collector, collector.start] }
|
|
48
|
+
result = yield
|
|
49
|
+
started.each { |collector, state| accumulate(collector.finish(state)) }
|
|
50
|
+
result
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def measured?
|
|
54
|
+
@measured
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
attr_reader :data
|
|
58
|
+
|
|
59
|
+
private
|
|
60
|
+
|
|
61
|
+
def accumulate(collected)
|
|
62
|
+
collected.each { |key, value| @data[key] = @data.fetch(key, 0) + value }
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Perfgate
|
|
4
|
+
module Fingerprints
|
|
5
|
+
# Decides whether a baseline run and a candidate run are comparable
|
|
6
|
+
# at all, before any statistics are computed (spec section 15.3).
|
|
7
|
+
# A difference in any "strict" field makes the runs incompatible;
|
|
8
|
+
# a difference in an "informational" field only downgrades the
|
|
9
|
+
# result to "compatible_with_warnings" (still comparable, but the
|
|
10
|
+
# decision should be presented to the reader with that context).
|
|
11
|
+
module Compatibility
|
|
12
|
+
module_function
|
|
13
|
+
|
|
14
|
+
def evaluate(baseline_components:, candidate_components:, config: Perfgate.configuration)
|
|
15
|
+
strict = diff_fields(baseline_components, candidate_components, config.fingerprint_strict_fields, "strict")
|
|
16
|
+
informational = diff_fields(baseline_components, candidate_components,
|
|
17
|
+
config.fingerprint_informational_fields, "informational")
|
|
18
|
+
differences = strict + informational
|
|
19
|
+
|
|
20
|
+
{ "status" => status_for(differences), "differences" => differences }
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def diff_fields(baseline_components, candidate_components, fields, severity)
|
|
24
|
+
Array(fields).filter_map do |field|
|
|
25
|
+
next if field == "workload_definition_hash"
|
|
26
|
+
|
|
27
|
+
baseline_value = baseline_components[field]
|
|
28
|
+
candidate_value = candidate_components[field]
|
|
29
|
+
next if baseline_value == candidate_value
|
|
30
|
+
|
|
31
|
+
{ "field" => field, "severity" => severity, "baseline" => baseline_value, "candidate" => candidate_value }
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def status_for(differences)
|
|
36
|
+
if differences.any? { |d| d["severity"] == "strict" }
|
|
37
|
+
"incompatible"
|
|
38
|
+
elsif differences.any?
|
|
39
|
+
"compatible_with_warnings"
|
|
40
|
+
else
|
|
41
|
+
"compatible"
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require "etc"
|
|
5
|
+
|
|
6
|
+
module Perfgate
|
|
7
|
+
module Fingerprints
|
|
8
|
+
# Collects the run-level fingerprint component values referenced by
|
|
9
|
+
# the strict/informational field lists in config.fingerprint (spec
|
|
10
|
+
# section 15.1/15.2). `workload_definition_hash` is deliberately
|
|
11
|
+
# excluded here: it is computed per-workload
|
|
12
|
+
# (see WorkloadDefinition) and compared per-workload in the
|
|
13
|
+
# comparison engine, not as a run-wide component.
|
|
14
|
+
module Components
|
|
15
|
+
module_function
|
|
16
|
+
|
|
17
|
+
def collect(config: Perfgate.configuration)
|
|
18
|
+
strict_components(config).merge(informational_components)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def strict_components(config)
|
|
22
|
+
{
|
|
23
|
+
"ruby_engine" => RUBY_ENGINE,
|
|
24
|
+
"ruby_version" => RUBY_VERSION,
|
|
25
|
+
"rails_version" => rails_version,
|
|
26
|
+
"baseline_version_major" => Perfgate::VERSION.split(".").first,
|
|
27
|
+
"database_adapter" => database_adapter,
|
|
28
|
+
"database_version_major" => database_version_major,
|
|
29
|
+
"dataset_hash" => dataset_hash(config)
|
|
30
|
+
}
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def informational_components
|
|
34
|
+
{
|
|
35
|
+
"operating_system" => operating_system,
|
|
36
|
+
"cpu_model" => cpu_model,
|
|
37
|
+
"cpu_count" => Etc.nprocessors.to_s,
|
|
38
|
+
"memory_bytes" => nil,
|
|
39
|
+
"ci_provider" => ci_provider,
|
|
40
|
+
"runner_image" => ENV.fetch("PERFGATE_RUNNER_IMAGE", nil),
|
|
41
|
+
"dependency_lock_hash" => dependency_lock_hash
|
|
42
|
+
}
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def rails_version
|
|
46
|
+
defined?(::Rails) ? ::Rails.version : nil
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def database_adapter
|
|
50
|
+
return nil unless defined?(::ActiveRecord::Base)
|
|
51
|
+
|
|
52
|
+
::ActiveRecord::Base.connection.adapter_name
|
|
53
|
+
rescue StandardError
|
|
54
|
+
nil
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def database_version_major
|
|
58
|
+
return nil unless defined?(::ActiveRecord::Base)
|
|
59
|
+
|
|
60
|
+
version = ::ActiveRecord::Base.connection.database_version
|
|
61
|
+
version.to_s.split(".").first
|
|
62
|
+
rescue StandardError, NotImplementedError
|
|
63
|
+
nil
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Applications provide their own dataset fingerprint hook (spec
|
|
67
|
+
# section 12.3, e.g. a fixture set version or seed migration
|
|
68
|
+
# number); Baseline only ever stores its hash, never the raw value,
|
|
69
|
+
# to avoid leaking application data into shared run artifacts.
|
|
70
|
+
def dataset_hash(config)
|
|
71
|
+
raw = config.dataset_fingerprint.call
|
|
72
|
+
"sha256:#{Digest::SHA256.hexdigest(raw.to_s)}"
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def operating_system
|
|
76
|
+
RbConfig::CONFIG["host_os"]
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def cpu_model
|
|
80
|
+
ENV.fetch("PERFGATE_CPU_MODEL", nil)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def ci_provider
|
|
84
|
+
return "github_actions" if ENV["GITHUB_ACTIONS"]
|
|
85
|
+
return "gitlab_ci" if ENV["GITLAB_CI"]
|
|
86
|
+
return "circleci" if ENV["CIRCLECI"]
|
|
87
|
+
|
|
88
|
+
nil
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Deferred: hashing Gemfile.lock (or equivalent) is a small addition
|
|
92
|
+
# but out of Milestone 3's explicit scope; left nil until wired up.
|
|
93
|
+
def dependency_lock_hash
|
|
94
|
+
nil
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
module Perfgate
|
|
7
|
+
module Fingerprints
|
|
8
|
+
# Computes a per-workload definition hash from the properties that,
|
|
9
|
+
# if changed, would make a historical run incomparable to a new one
|
|
10
|
+
# (spec section 15.4): the workload's id, its sample/warmup counts,
|
|
11
|
+
# and the set of metrics it records. Hashing the workload's source
|
|
12
|
+
# code (to detect behavioral changes even when these properties are
|
|
13
|
+
# unchanged) is explicitly deferred -- the spec notes this requires
|
|
14
|
+
# normalizing formatting/whitespace-only diffs, which is a larger
|
|
15
|
+
# follow-up.
|
|
16
|
+
module WorkloadDefinition
|
|
17
|
+
module_function
|
|
18
|
+
|
|
19
|
+
def hash_for(workload)
|
|
20
|
+
payload = {
|
|
21
|
+
"id" => workload.id,
|
|
22
|
+
"samples" => workload.samples,
|
|
23
|
+
"warmup" => workload.warmup,
|
|
24
|
+
"metrics" => Array(workload.metrics).map(&:to_s).sort
|
|
25
|
+
}
|
|
26
|
+
"sha256:#{Digest::SHA256.hexdigest(JSON.generate(payload))}"
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Perfgate
|
|
4
|
+
module Instrumentation
|
|
5
|
+
# Ruby object allocation count during a `Perfgate.measure` block
|
|
6
|
+
# (spec section 13.4), using the allocation counter built into every
|
|
7
|
+
# supported Ruby version.
|
|
8
|
+
module Allocations
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
def start
|
|
12
|
+
GC.stat(:total_allocated_objects)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def finish(started_at)
|
|
16
|
+
{ "allocations" => GC.stat(:total_allocated_objects) - started_at }
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Perfgate
|
|
4
|
+
module Instrumentation
|
|
5
|
+
# Wall-clock duration of a `Perfgate.measure` block (spec section
|
|
6
|
+
# 13.1). Uses a monotonic clock so NTP adjustments and system clock
|
|
7
|
+
# changes never produce a negative or inflated reading.
|
|
8
|
+
module Duration
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
def start
|
|
12
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def finish(started_at)
|
|
16
|
+
{ "duration_ns" => ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at) * 1_000_000_000).round }
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Perfgate
|
|
4
|
+
module Instrumentation
|
|
5
|
+
# Garbage-collection activity during a `Perfgate.measure` block (spec
|
|
6
|
+
# section 13.5). `gc_count` matches the run-result schema in section
|
|
7
|
+
# 14.1; `gc_minor_count`/`gc_major_count` are additional diagnostic
|
|
8
|
+
# deltas the schema doesn't name explicitly but section 13.5 asks us
|
|
9
|
+
# to capture. GC metrics are diagnostic only and never fail a run.
|
|
10
|
+
module Gc
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
def start
|
|
14
|
+
{
|
|
15
|
+
count: GC.stat(:count),
|
|
16
|
+
minor_count: GC.stat(:minor_gc_count),
|
|
17
|
+
major_count: GC.stat(:major_gc_count)
|
|
18
|
+
}
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def finish(started_at)
|
|
22
|
+
{
|
|
23
|
+
"gc_count" => GC.stat(:count) - started_at[:count],
|
|
24
|
+
"gc_minor_count" => GC.stat(:minor_gc_count) - started_at[:minor_count],
|
|
25
|
+
"gc_major_count" => GC.stat(:major_gc_count) - started_at[:major_count]
|
|
26
|
+
}
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Perfgate
|
|
4
|
+
module Instrumentation
|
|
5
|
+
# SQL query count and cumulative duration during a `Perfgate.measure`
|
|
6
|
+
# block (spec sections 13.2-13.3), collected via an
|
|
7
|
+
# ActiveSupport::Notifications subscription scoped to exactly that
|
|
8
|
+
# block. Schema queries, transaction control statements, and cached
|
|
9
|
+
# query hits are excluded as configurable noise (13.2); raw SQL text
|
|
10
|
+
# is never stored, only counts and a cumulative duration.
|
|
11
|
+
module SqlActivity
|
|
12
|
+
NOISE_EVENT_NAMES = %w[SCHEMA TRANSACTION].freeze
|
|
13
|
+
|
|
14
|
+
module_function
|
|
15
|
+
|
|
16
|
+
def start
|
|
17
|
+
ensure_active_support!
|
|
18
|
+
|
|
19
|
+
state = { count: 0, duration_ns: 0 }
|
|
20
|
+
subscriber = ::ActiveSupport::Notifications.subscribe("sql.active_record") do |*args|
|
|
21
|
+
event = ::ActiveSupport::Notifications::Event.new(*args)
|
|
22
|
+
record(state, event) unless noise?(event.payload)
|
|
23
|
+
end
|
|
24
|
+
{ subscriber: subscriber, state: state }
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def finish(started)
|
|
28
|
+
::ActiveSupport::Notifications.unsubscribe(started.fetch(:subscriber))
|
|
29
|
+
state = started.fetch(:state)
|
|
30
|
+
{ "sql_count" => state[:count], "sql_duration_ns" => state[:duration_ns] }
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def record(state, event)
|
|
34
|
+
state[:count] += 1
|
|
35
|
+
state[:duration_ns] += (event.duration * 1_000_000).round
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def noise?(payload)
|
|
39
|
+
NOISE_EVENT_NAMES.include?(payload[:name].to_s) || payload[:cached]
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def ensure_active_support!
|
|
43
|
+
return if defined?(::ActiveSupport::Notifications)
|
|
44
|
+
|
|
45
|
+
raise Perfgate::Error,
|
|
46
|
+
"sql_count/sql_duration instrumentation requires ActiveSupport::Notifications to be loaded"
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "instrumentation/duration"
|
|
4
|
+
require_relative "instrumentation/sql_activity"
|
|
5
|
+
require_relative "instrumentation/allocations"
|
|
6
|
+
require_relative "instrumentation/gc"
|
|
7
|
+
|
|
8
|
+
module Perfgate
|
|
9
|
+
# Instrumentation collectors for the metrics a workload can measure
|
|
10
|
+
# (spec section 13): each collector module exposes `.start`/`.finish`
|
|
11
|
+
# and is invoked only within a workload's explicit `Perfgate.measure`
|
|
12
|
+
# block.
|
|
13
|
+
module Instrumentation
|
|
14
|
+
# Maps a workload's configured metric names (spec section 11's
|
|
15
|
+
# `metrics` config, e.g. `:duration`, `:sql_count`) to the collector
|
|
16
|
+
# module responsible for it. `sql_count` and `sql_duration` share a
|
|
17
|
+
# single collector since both come from the same notification
|
|
18
|
+
# subscription. `memory` (section 13.6) is intentionally absent: it
|
|
19
|
+
# is disabled by default and not yet implemented.
|
|
20
|
+
REGISTRY = {
|
|
21
|
+
duration: Duration,
|
|
22
|
+
sql_count: SqlActivity,
|
|
23
|
+
sql_duration: SqlActivity,
|
|
24
|
+
allocations: Allocations,
|
|
25
|
+
gc: Gc
|
|
26
|
+
}.freeze
|
|
27
|
+
|
|
28
|
+
module_function
|
|
29
|
+
|
|
30
|
+
# The distinct set of collectors needed to satisfy the given metric
|
|
31
|
+
# names, in a stable order.
|
|
32
|
+
def collectors_for(metric_names)
|
|
33
|
+
metric_names.filter_map { |name| REGISTRY[name] }.uniq
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
File without changes
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Perfgate
|
|
4
|
+
module Policy
|
|
5
|
+
# Turns a comparison-result document (or the absence of one) into
|
|
6
|
+
# the PASS/WARN/FAIL/INCOMPARABLE decision and CI exit code baseline
|
|
7
|
+
# exits with (spec section 17):
|
|
8
|
+
#
|
|
9
|
+
# 0 PASS, or WARN under a non-blocking policy
|
|
10
|
+
# 1 Performance FAIL
|
|
11
|
+
# 2 Configuration error (raised directly by the CLI, not here)
|
|
12
|
+
# 3 Execution error (raised directly by the CLI, not here)
|
|
13
|
+
# 4 Missing baseline under a strict policy
|
|
14
|
+
# 5 Incompatible baseline under a strict policy
|
|
15
|
+
#
|
|
16
|
+
# `config.policy` controls how "soft" signals (an incompatible
|
|
17
|
+
# fingerprint, a new/removed workload) escalate into a hard FAIL --
|
|
18
|
+
# every one of those keys defaults to "warn" (never blocking) and
|
|
19
|
+
# only escalates when explicitly set to "fail".
|
|
20
|
+
module Engine
|
|
21
|
+
EXIT_CODES = { "pass" => 0, "warn" => 0, "fail" => 1, "missing_baseline" => 4, "incomparable" => 5 }.freeze
|
|
22
|
+
ESCALATING_WORKLOAD_DECISIONS = { "new_workload" => :new_workload, "removed_workload" => :removed_workload,
|
|
23
|
+
"incomparable" => :incompatible }.freeze
|
|
24
|
+
|
|
25
|
+
module_function
|
|
26
|
+
|
|
27
|
+
def evaluate(comparison_result:, config: Perfgate.configuration)
|
|
28
|
+
status = status_for(comparison_result, config)
|
|
29
|
+
{ "status" => status, "exit_code" => EXIT_CODES.fetch(status) }
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# A separate entry point for the CLI: there is no comparison result
|
|
33
|
+
# at all when no prior baseline run could be found to compare
|
|
34
|
+
# against (spec section 17's exit code 4).
|
|
35
|
+
def evaluate_missing_baseline(config: Perfgate.configuration)
|
|
36
|
+
status = config.policy[:missing_baseline] == "fail" ? "missing_baseline" : "warn"
|
|
37
|
+
{ "status" => status, "exit_code" => EXIT_CODES.fetch(status) }
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def status_for(comparison_result, config)
|
|
41
|
+
return incompatible_status(config) if comparison_result["decision"] == "incompatible"
|
|
42
|
+
|
|
43
|
+
escalation = workload_escalation(comparison_result, config)
|
|
44
|
+
return escalation if escalation
|
|
45
|
+
|
|
46
|
+
metric_status(comparison_result, config)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def incompatible_status(config)
|
|
50
|
+
config.policy[:incompatible] == "fail" ? "incomparable" : "warn"
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def workload_escalation(comparison_result, config)
|
|
54
|
+
workloads = comparison_result.fetch("workloads", [])
|
|
55
|
+
ESCALATING_WORKLOAD_DECISIONS.each do |decision, policy_key|
|
|
56
|
+
next unless config.policy[policy_key] == "fail"
|
|
57
|
+
next unless workloads.any? { |w| w["decision"] == decision }
|
|
58
|
+
|
|
59
|
+
return "fail"
|
|
60
|
+
end
|
|
61
|
+
nil
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def metric_status(comparison_result, config)
|
|
65
|
+
case comparison_result["decision"]
|
|
66
|
+
when "fail" then "fail"
|
|
67
|
+
when "warn" then config.policy[:fail_on] == "warn" ? "fail" : "warn"
|
|
68
|
+
else "pass"
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
File without changes
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Perfgate
|
|
4
|
+
module Report
|
|
5
|
+
# Renders the schema_version 1 comparison-result document (plus its
|
|
6
|
+
# Policy::Engine verdict) as the plain-text console summary from
|
|
7
|
+
# spec 20.1: overall decision, run identities, a per-workload/
|
|
8
|
+
# per-metric table with a "likely signal" diagnostic line,
|
|
9
|
+
# compatibility status, and sample counts.
|
|
10
|
+
module Console
|
|
11
|
+
DURATION_METRICS = %w[duration sql_duration].freeze
|
|
12
|
+
METRIC_LABELS = { "duration" => "Duration", "sql_count" => "SQL queries", "sql_duration" => "SQL duration",
|
|
13
|
+
"allocations" => "Allocations" }.freeze
|
|
14
|
+
|
|
15
|
+
module_function
|
|
16
|
+
|
|
17
|
+
def render(comparison_result:, policy_result:)
|
|
18
|
+
lines = ["Baseline Performance Assurance", "", "Overall: #{policy_result["status"].upcase}",
|
|
19
|
+
"Baseline: #{comparison_result["baseline_run_id"]}",
|
|
20
|
+
"Candidate: #{comparison_result["candidate_run_id"]}", ""]
|
|
21
|
+
comparison_result.fetch("workloads", []).each { |workload| lines.concat(workload_lines(workload)) }
|
|
22
|
+
lines << "Compatibility: #{comparison_result.dig("compatibility", "status")}"
|
|
23
|
+
lines.join("\n")
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def workload_lines(workload)
|
|
27
|
+
marker = workload["decision"] == "pass" ? "\u2713" : "\u2717"
|
|
28
|
+
lines = ["#{marker} #{workload["id"]}"]
|
|
29
|
+
workload["metrics"].each { |name, metric| lines << metric_line(name, metric) }
|
|
30
|
+
lines.concat(diagnostics_lines(workload))
|
|
31
|
+
lines << ""
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def metric_line(name, metric)
|
|
35
|
+
label = METRIC_LABELS.fetch(name, name)
|
|
36
|
+
before = format_value(name, metric["baseline_median"])
|
|
37
|
+
after = format_value(name, metric["candidate_median"])
|
|
38
|
+
change = metric["change_percent"] ? format("%+.1f%%", metric["change_percent"]) : "n/a"
|
|
39
|
+
format(" %<label>-15s %<before>s \u2192 %<after>s %<change>s %<decision>s",
|
|
40
|
+
label: label, before: before, after: after, change: change, decision: metric["decision"].upcase)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def format_value(name, value)
|
|
44
|
+
return "n/a" if value.nil?
|
|
45
|
+
|
|
46
|
+
DURATION_METRICS.include?(name) ? format("%.0f ms", value / 1_000_000.0) : value.to_s
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def diagnostics_lines(workload)
|
|
50
|
+
messages = workload["diagnostics"] || []
|
|
51
|
+
return [] if messages.empty?
|
|
52
|
+
|
|
53
|
+
[" Likely signal:"] + messages.map { |message| " #{message}" }
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|