Skip to content

Commit dfe459b

Browse files
authored
Add observability (heroku#1569)
* Add Heroku YAML report builder This utility class writes a yaml file for capturing observability metrics. * Add Ruby version metrics to global report * Implement bin/report * Report bundler version * Observe railties and rack versions * Changelog * Document error tolerance behavior * Update captured ruby version information * Fix tests * Document RubyVersion accessors * Cleaner major ruby version * Remove unused legacy ruby logic We no longer provide Ruby 1.9.2 * Silence warnings
1 parent 5f9b8ec commit dfe459b

14 files changed

Lines changed: 276 additions & 31 deletions

CHANGELOG.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,12 @@
22

33
## [Unreleased]
44

5+
- Added observability metrics (https://github.com/heroku/heroku-buildpack-ruby/pull/1569)
56

67
## [v297] - 2025-03-26
78

89
- Ruby 3.1.7 and 3.2.8 is now available
910

10-
1111
## [v296] - 2025-03-21
1212

1313
- Bundler `1.x` usage error is downgraded to a warning. This warning will be moved to an error once `heroku-20` is Sunset (https://github.com/heroku/heroku-buildpack-ruby/pull/1565)

bin/report

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
#!/usr/bin/env bash
2+
3+
set -euo pipefail
4+
5+
CACHE_DIR="${2}"
6+
REPORT_FILE="${CACHE_DIR}/.heroku/ruby/build_report.yml"
7+
8+
# Whilst the release file is always written by the buildpack, some apps use
9+
# third-party slug cleaner buildpacks to remove this and other files, so we
10+
# cannot assume it still exists by the time the release step runs.
11+
if [[ -f "${REPORT_FILE}" ]]; then
12+
cat "${REPORT_FILE}"
13+
fi

bin/support/ruby_compile

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,13 @@ $stdout.sync = true
1010
$:.unshift File.expand_path("../../../lib", __FILE__)
1111
require "language_pack"
1212
require "language_pack/shell_helpers"
13+
HerokuBuildReport.set_global(
14+
# Coupled with `bin/report`
15+
path: Pathname(ARGV[1])
16+
.join(".heroku")
17+
.join("ruby")
18+
.join("build_report.yml")
19+
).tap(&:clear!)
1320

1421
begin
1522
LanguagePack::ShellHelpers.initialize_env(ARGV[2])

lib/heroku_build_report.rb

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
require 'yaml'
2+
require 'pathname'
3+
4+
# Observability reporting for builds
5+
#
6+
# Example usage:
7+
#
8+
# HerokuBuildReport::GLOBAL.capture(
9+
# "ruby_version" => "3.4.2"
10+
# )
11+
module HerokuBuildReport
12+
# Accumulates data in memory and writes it to the specified path in YAML format
13+
#
14+
# Writes data to disk on every capture. Later `bin/report` emits the disk contents
15+
class YamlReport
16+
attr_reader :data
17+
18+
def initialize(path: )
19+
@path = Pathname(path).expand_path
20+
@path.dirname.mkpath
21+
FileUtils.touch(@path)
22+
@data = {}
23+
end
24+
25+
def clear!
26+
@data.clear
27+
@path.write("")
28+
end
29+
30+
def capture(metrics = {})
31+
metrics.each do |(key, value)|
32+
return if key.nil? || key.to_s.strip.empty?
33+
34+
key = key&.strip
35+
raise "Key cannot be empty (#{key.inspect} => #{value})" if key.nil? || key.empty?
36+
37+
@data["#{key}"] = value
38+
end
39+
40+
@path.write(@data.to_yaml)
41+
end
42+
end
43+
44+
# Current load order of the various "language packs"
45+
def self.set_global(path: )
46+
YamlReport.new(path: path).tap { |report|
47+
# Silence warning about setting a constant
48+
begin
49+
old_verbose = $VERBOSE
50+
$VERBOSE = nil
51+
const_set(:GLOBAL, report)
52+
ensure
53+
$VERBOSE = old_verbose
54+
end
55+
}
56+
end
57+
58+
# Stores data in memory only, does not persist to disk
59+
def self.dev_null
60+
YamlReport.new(path: "/dev/null")
61+
end
62+
63+
GLOBAL = self.dev_null # Changed via `set_global`
64+
end

lib/language_pack.rb

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ def self.detect(*args)
2323
$:.unshift File.expand_path("../../vendor", __FILE__)
2424
$:.unshift File.expand_path("..", __FILE__)
2525

26+
require 'heroku_build_report'
27+
2628
require 'language_pack/shell_helpers'
2729
require "language_pack/helpers/plugin_installer"
2830
require "language_pack/helpers/stale_file_cleaner"

lib/language_pack/base.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ def initialize(build_path, cache_path = nil)
3636
@id = Digest::SHA1.hexdigest("#{Time.now.to_f}-#{rand(1000000)}")[0..10]
3737
@fetchers = {:buildpack => LanguagePack::Fetcher.new(VENDOR_URL) }
3838
@arch = get_arch
39+
@report = HerokuBuildReport::GLOBAL
3940

4041
Dir.chdir build_path
4142
end

lib/language_pack/helpers/bundler_wrapper.rb

Lines changed: 22 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -62,19 +62,18 @@ class LanguagePack::Helpers::BundlerWrapper
6262
end
6363
end
6464

65-
def self.detect_bundler_version(contents: )
66-
version_match = contents.match(BUNDLED_WITH_REGEX)
67-
if version_match
68-
major = version_match[:major]
69-
minor = version_match[:minor]
65+
def self.detect_bundler_version(contents: , bundled_with: contents.match(BUNDLED_WITH_REGEX))
66+
if bundled_with
67+
major = bundled_with[:major]
68+
minor = bundled_with[:minor]
7069
version = BLESSED_BUNDLER_VERSIONS["#{major}.#{minor}"]
7170
version
7271
else
7372
DEFAULT_VERSION
7473
end
7574
end
7675

77-
BUNDLED_WITH_REGEX = /^BUNDLED WITH$(\r?\n) (?<major>\d+)\.(?<minor>\d+)\.\d+/m
76+
BUNDLED_WITH_REGEX = /^BUNDLED WITH$(\r?\n) (?<version>(?<major>\d+)\.(?<minor>\d+)\.\d+)/m
7877

7978
class GemfileParseError < BuildpackError
8079
def initialize(error)
@@ -105,12 +104,28 @@ def initialize(version_hash, major_minor)
105104
attr_reader :bundler_path
106105

107106
def initialize(options = {})
107+
@report = options[:report] || HerokuBuildReport::GLOBAL
108108
@bundler_tmp = Pathname.new(Dir.mktmpdir)
109109
@fetcher = options[:fetcher] || LanguagePack::Fetcher.new(LanguagePack::Base::VENDOR_URL) # coupling
110110
@gemfile_path = options[:gemfile_path] || Pathname.new("./Gemfile")
111111
@gemfile_lock_path = Pathname.new("#{@gemfile_path}.lock")
112112

113-
@version = self.class.detect_bundler_version(contents: @gemfile_lock_path.read(mode: "rt"))
113+
contents = @gemfile_lock_path.read(mode: "rt")
114+
bundled_with = contents.match(BUNDLED_WITH_REGEX)
115+
@report.capture(
116+
"bundled_with" => bundled_with&.[]("version") || "empty"
117+
)
118+
@version = self.class.detect_bundler_version(
119+
contents: contents,
120+
bundled_with: bundled_with
121+
)
122+
parts = @version.split(".")
123+
@report.capture(
124+
"bundler_version_installed" => @version,
125+
"bundler_major" => parts&.shift,
126+
"bundler_minor" => parts&.shift,
127+
"bundler_patch" => parts&.shift
128+
)
114129
@dir_name = "bundler-#{@version}"
115130

116131
@bundler_path = options[:bundler_path] || @bundler_tmp.join(@dir_name)

lib/language_pack/installers/heroku_ruby_installer.rb

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,8 @@ class LanguagePack::Installers::HerokuRubyInstaller
1010
include LanguagePack::ShellHelpers
1111
attr_reader :fetcher
1212

13-
def initialize(stack: , multi_arch_stacks: , arch: )
13+
def initialize(stack: , multi_arch_stacks: , arch: , report: HerokuBuildReport::GLOBAL)
14+
@report = report
1415
if multi_arch_stacks.include?(stack)
1516
@fetcher = LanguagePack::Fetcher.new(BASE_URL, stack: stack, arch: arch)
1617
else
@@ -19,6 +20,14 @@ def initialize(stack: , multi_arch_stacks: , arch: )
1920
end
2021

2122
def install(ruby_version, install_dir)
23+
@report.capture(
24+
"ruby.version" => ruby_version.ruby_version,
25+
"ruby.engine" => ruby_version.engine,
26+
"ruby.engine.version" => ruby_version.engine_version,
27+
"ruby.major" => ruby_version.major,
28+
"ruby.minor" => ruby_version.minor,
29+
"ruby.patch" => ruby_version.patch,
30+
)
2231
fetch_unpack(ruby_version, install_dir)
2332
setup_binstubs(install_dir)
2433
end

lib/language_pack/ruby.rb

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,10 @@ def compile
9797
install_binaries
9898
run_assets_precompile_rake_task
9999
end
100+
@report.capture(
101+
"railties_version" => bundler.gem_version('railties'),
102+
"rack_version" => bundler.gem_version('rack')
103+
)
100104
config_detect
101105
best_practice_warnings
102106
warn_outdated_ruby

lib/language_pack/ruby_version.rb

Lines changed: 33 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,6 @@ def initialize(output = "")
1515
BOOTSTRAP_VERSION_NUMBER = "3.1.6".freeze
1616
DEFAULT_VERSION_NUMBER = "3.3.7".freeze
1717
DEFAULT_VERSION = "ruby-#{DEFAULT_VERSION_NUMBER}".freeze
18-
LEGACY_VERSION_NUMBER = "1.9.2".freeze
19-
LEGACY_VERSION = "ruby-#{LEGACY_VERSION_NUMBER}".freeze
2018
RUBY_VERSION_REGEX = %r{
2119
(?<ruby_version>\d+\.\d+\.\d+){0}
2220
(?<patchlevel>p-?\d+){0}
@@ -26,7 +24,25 @@ def initialize(output = "")
2624
ruby-\g<ruby_version>(-\g<patchlevel>)?(-\g<engine>-\g<engine_version>)?
2725
}x
2826

29-
attr_reader :set, :version, :version_without_patchlevel, :patchlevel, :engine, :ruby_version, :engine_version
27+
28+
# `version` is the bundler output like `ruby-3.4.2`
29+
attr_reader :version,
30+
# `set` is either `:gemfile` when the app specified a version or `nil` when using
31+
# the default version
32+
:set,
33+
# `version_without_patchlevel` removes any `-p<number>` as they're not significant
34+
# effectively this is `version_for_download`
35+
:version_without_patchlevel,
36+
# `patchlevel` is the `-p<number>` or is empty
37+
:patchlevel,
38+
# `engine` is `:ruby` or `:jruby`
39+
:engine,
40+
# `ruby_version` is `<major>.<minor>.<patch>` extracted from `version`
41+
:ruby_version,
42+
# `engine_version` is the Jruby version or for MRI it is the same as `ruby_version`
43+
# i.e. `<major>.<minor>.<patch>`
44+
:engine_version
45+
3046
include LanguagePack::ShellHelpers
3147

3248
def initialize(bundler_output, app = {})
@@ -74,7 +90,7 @@ def rake_is_vendored?
7490
end
7591

7692
def default?
77-
@version == none
93+
!set
7894
end
7995

8096
# determine if we're using jruby
@@ -98,6 +114,18 @@ def vendored_bundler?
98114
false
99115
end
100116

117+
def major
118+
@ruby_version.split(".")[0].to_i
119+
end
120+
121+
def minor
122+
@ruby_version.split(".")[1].to_i
123+
end
124+
125+
def patch
126+
@ruby_version.split(".")[2].to_i
127+
end
128+
101129
# Returns the next logical version in the minor series
102130
# for example if the current ruby version is
103131
# `ruby-2.3.1` then then `next_logical_version(1)`
@@ -126,21 +154,10 @@ def next_major_version(increment = 1)
126154
end
127155

128156
private
129-
130-
def none
131-
if @app[:is_new]
132-
DEFAULT_VERSION
133-
elsif @app[:last_version]
134-
@app[:last_version]
135-
else
136-
LEGACY_VERSION
137-
end
138-
end
139-
140157
def set_version
141158
if @bundler_output.empty?
142159
@set = false
143-
@version = none
160+
@version = @app[:last_version] || DEFAULT_VERSION
144161
else
145162
@set = :gemfile
146163
@version = @bundler_output

0 commit comments

Comments
 (0)