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.
Files changed (76) hide show
  1. checksums.yaml +7 -0
  2. data/.rspec +3 -0
  3. data/.rubocop.yml +25 -0
  4. data/CHANGELOG.md +18 -0
  5. data/CONTRIBUTING.md +73 -0
  6. data/LICENSE +201 -0
  7. data/NOT_FINALIZED.md +70 -0
  8. data/README.md +86 -0
  9. data/ROADMAP.md +125 -0
  10. data/Rakefile +12 -0
  11. data/SECURITY.md +63 -0
  12. data/docs/README.md +7 -0
  13. data/docs/architecture.md +125 -0
  14. data/docs/compatibility.md +49 -0
  15. data/docs/launch-article.md +97 -0
  16. data/docs/onboarding.md +122 -0
  17. data/docs/telemetry.md +81 -0
  18. data/examples/rails-rspec-app/.github/workflows/baseline.yml +48 -0
  19. data/examples/rails-rspec-app/README.md +38 -0
  20. data/examples/rails-rspec-app/spec/jobs/invoice_job_spec.rb +15 -0
  21. data/examples/rails-rspec-app/spec/requests/checkout_spec.rb +24 -0
  22. data/exe/perfgate +7 -0
  23. data/lib/perfgate/cli/compare_command.rb +91 -0
  24. data/lib/perfgate/cli/run_command.rb +123 -0
  25. data/lib/perfgate/cli/run_comparison_reporter.rb +77 -0
  26. data/lib/perfgate/cli.rb +60 -0
  27. data/lib/perfgate/comparison/deterministic_metric_decision.rb +34 -0
  28. data/lib/perfgate/comparison/diagnostics.rb +70 -0
  29. data/lib/perfgate/comparison/engine.rb +79 -0
  30. data/lib/perfgate/comparison/metric_change.rb +84 -0
  31. data/lib/perfgate/comparison/metric_decision.rb +39 -0
  32. data/lib/perfgate/comparison/statistical_metric_decision.rb +75 -0
  33. data/lib/perfgate/comparison/workload_comparison.rb +98 -0
  34. data/lib/perfgate/config/defaults.rb +70 -0
  35. data/lib/perfgate/config/env_overrides.rb +54 -0
  36. data/lib/perfgate/config/schema.rb +53 -0
  37. data/lib/perfgate/config/validator.rb +64 -0
  38. data/lib/perfgate/config.rb +136 -0
  39. data/lib/perfgate/errors.rb +20 -0
  40. data/lib/perfgate/execution/process_runner.rb +71 -0
  41. data/lib/perfgate/execution/runner.rb +60 -0
  42. data/lib/perfgate/execution/sample_context.rb +66 -0
  43. data/lib/perfgate/fingerprints/compatibility.rb +46 -0
  44. data/lib/perfgate/fingerprints/components.rb +98 -0
  45. data/lib/perfgate/fingerprints/workload_definition.rb +30 -0
  46. data/lib/perfgate/instrumentation/allocations.rb +20 -0
  47. data/lib/perfgate/instrumentation/duration.rb +20 -0
  48. data/lib/perfgate/instrumentation/gc.rb +30 -0
  49. data/lib/perfgate/instrumentation/sql_activity.rb +50 -0
  50. data/lib/perfgate/instrumentation.rb +36 -0
  51. data/lib/perfgate/metrics/.gitkeep +0 -0
  52. data/lib/perfgate/policy/engine.rb +73 -0
  53. data/lib/perfgate/rails/.gitkeep +0 -0
  54. data/lib/perfgate/report/console.rb +57 -0
  55. data/lib/perfgate/report/markdown.rb +101 -0
  56. data/lib/perfgate/reporting/.gitkeep +0 -0
  57. data/lib/perfgate/rspec/discovery.rb +31 -0
  58. data/lib/perfgate/rspec/id_resolver.rb +30 -0
  59. data/lib/perfgate/rspec/workload_builder.rb +48 -0
  60. data/lib/perfgate/rspec.rb +14 -0
  61. data/lib/perfgate/serialization/run_result.rb +58 -0
  62. data/lib/perfgate/statistics/mann_whitney_u.rb +84 -0
  63. data/lib/perfgate/statistics/summary.rb +60 -0
  64. data/lib/perfgate/storage/adapter.rb +24 -0
  65. data/lib/perfgate/storage/archive.rb +69 -0
  66. data/lib/perfgate/storage/filesystem.rb +121 -0
  67. data/lib/perfgate/telemetry/.gitkeep +0 -0
  68. data/lib/perfgate/version.rb +5 -0
  69. data/lib/perfgate/workloads/registry.rb +50 -0
  70. data/lib/perfgate/workloads/workload.rb +26 -0
  71. data/lib/perfgate.rb +46 -0
  72. data/perfgate.gemspec +41 -0
  73. data/schemas/comparison-result-v1.schema.json +7 -0
  74. data/schemas/run-result-v1.schema.json +7 -0
  75. data/sig/perfgate.rbs +4 -0
  76. 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