From d8fcbd424a2b1c5ca33c6572e9288d3e3d30c41a Mon Sep 17 00:00:00 2001 From: Mike McQuaid Date: Sat, 24 Oct 2026 14:44:00 +0100 Subject: [PATCH] Share analytics queries and package downloads - Extract InfluxDB queries so API generators can call Ruby directly. - Share cached package totals between `brew info` and API generation. - Keep query formatting and reader tests with their implementations. --- Library/Homebrew/dev-cmd/formula-analytics.rb | 393 +----------------- Library/Homebrew/github_packages.rb | 50 +++ .../test/dev-cmd/formula-analytics_spec.rb | 152 ------- Library/Homebrew/test/github_packages_spec.rb | 46 ++ .../test/utils/analytics/influx_spec.rb | 158 +++++++ Library/Homebrew/test/utils/analytics_spec.rb | 42 +- Library/Homebrew/utils/analytics.rb | 53 +-- Library/Homebrew/utils/analytics/influx.rb | 386 +++++++++++++++++ 8 files changed, 669 insertions(+), 611 deletions(-) create mode 100644 Library/Homebrew/test/utils/analytics/influx_spec.rb create mode 100644 Library/Homebrew/utils/analytics/influx.rb diff --git a/Library/Homebrew/dev-cmd/formula-analytics.rb b/Library/Homebrew/dev-cmd/formula-analytics.rb index 9fd0be41474f3..98bdf26a72ba1 100755 --- a/Library/Homebrew/dev-cmd/formula-analytics.rb +++ b/Library/Homebrew/dev-cmd/formula-analytics.rb @@ -1,7 +1,7 @@ # typed: strict # frozen_string_literal: true -require "system_command" +require "utils/analytics/influx" require "abstract_command" @@ -62,110 +62,15 @@ class FormulaAnalytics < AbstractCommand hide_from_man_page! end - FIRST_INFLUXDB_ANALYTICS_DATE = Date.new(2023, 03, 27).freeze - STANDARD_PREFIXES = %w[/opt/homebrew /usr/local /home/linuxbrew/.linuxbrew].freeze - sig { override.void } def run - setup_python - influx_analytics(args) - end - - sig { void } - def setup_python - uv = which("uv", ORIGINAL_PATHS) - odie <<~EOS if uv.nil? - `uv` is required. Try: - brew install uv - EOS - - vendor_python = venv_root.dirname - vendor_python.children.reject { |path| path == venv_root }.each(&:rmtree) if vendor_python.exist? - - with_env(UV_PROJECT_ENVIRONMENT: venv_root.to_s) do - SystemCommand.safe_system uv, "sync", "--frozen", "--project", formula_analytics_root, out: :err - end - end - - sig { returns(Pathname) } - def formula_analytics_root - HOMEBREW_LIBRARY/"Homebrew/formula-analytics" - end - - sig { returns(Pathname) } - def influxdb_query_script - formula_analytics_root/"influxdb-query.py" - end - - sig { returns(Pathname) } - def venv_root - python_version = (formula_analytics_root/".python-version").read.chomp - Pathname.new("~/.brew-formula-analytics/vendor/python").expand_path/python_version - end - - sig { returns(Pathname) } - def venv_python - venv_root/"bin/python" - end - - sig { params(query: String, _block: T.proc.params(record: T::Hash[String, T.untyped]).void).void } - def each_influx_record(query, &_block) - require "json" - require "tempfile" - require "utils/analytics" - require "utils/popen" - - request = { - host: URI.parse(Utils::Analytics::INFLUX_HOST).host, - org: Utils::Analytics::INFLUX_ORG, - database: Utils::Analytics::INFLUX_BUCKET, - query:, - }.to_json - - Tempfile.create("influxdb-query-stderr") do |stderr_file| - Utils.popen([venv_python.to_s, influxdb_query_script.to_s], "r+b", { err: stderr_file.path }) do |pipe| - pipe.write request - pipe.close_write - pipe.each_line { |line| yield JSON.parse(line) } - end - - next if $CHILD_STATUS.success? - - stderr = stderr_file.read - if stderr.include?("unauthenticated") - odie "Could not authenticate with InfluxDB! Please check your `$HOMEBREW_INFLUXDB_TOKEN`!" - end - odie "InfluxDB query failed:\n#{stderr}" - end - end - - sig { params(args: Homebrew::DevCmd::FormulaAnalytics::Args).void } - def influx_analytics(args) - require "utils/analytics" - require "json" - + influx = Utils::Analytics::Influx.new + influx.setup_python if args.setup? - SystemCommand.safe_system venv_python, influxdb_query_script, "--check" + SystemCommand.safe_system influx.venv_python, influx.influxdb_query_script, "--check" return end - odie "`$HOMEBREW_NO_ANALYTICS` is set!" if ENV["HOMEBREW_NO_ANALYTICS"] - - odie "No InfluxDB credentials found in `$HOMEBREW_INFLUXDB_TOKEN`!" unless ENV["HOMEBREW_INFLUXDB_TOKEN"] - - max_days_ago = (Date.today - FIRST_INFLUXDB_ANALYTICS_DATE).to_s.to_i - days_ago = (args.days_ago || 30).to_i - if days_ago > max_days_ago - opoo "Analytics started #{FIRST_INFLUXDB_ANALYTICS_DATE}. `--days-ago` set to maximum value." - days_ago = max_days_ago - end - if days_ago > 365 - opoo "Analytics are only retained for 1 year, setting `--days-ago=365`." - days_ago = 365 - end - - all_core_formulae_json = args.all_core_formulae_json? - categories = [] categories << :build_error if args.build_error? categories << :cask_install if args.cask_install? @@ -181,294 +86,10 @@ def influx_analytics(args) categories << :command_run_options if args.brew_command_run_options? categories << :test_bot_test if args.brew_test_bot_test? - category_matching_buckets = [:build_error, :cask_install, :command_run, :test_bot_test] - - categories.each do |category| - additional_where = all_core_formulae_json ? " AND tap_name ~ '^homebrew/(core|cask)$'" : "" - bucket = if category_matching_buckets.include?(category) - category - elsif [:command_run_options, :homebrew_env_config].include?(category) - :command_run - else - :formula_install - end - - case category - when :homebrew_devcmdrun_developer - dimension_key = "devcmdrun_developer" - groups = [:devcmdrun, :developer] - when :homebrew_env_config - dimension_key = "env_config" - groups = [:env_config, :env_config_state] - # Events predating the user-set-aware `env_config_state` tag - # counted brew's own exports as configuration, so drop them. - additional_where += " AND env_config_state IS NOT NULL" - when :homebrew_os_arch_ci - dimension_key = "os_arch_ci" - groups = [:os, :arch, :ci] - when :homebrew_prefixes - dimension_key = "prefix" - groups = [:prefix, :os, :arch] - when :homebrew_versions - dimension_key = "version" - groups = [:version] - when :os_versions - dimension_key = :os_version - groups = [:os_name_and_version] - when :command_run - dimension_key = "command_run" - groups = [:command] - when :command_run_options - dimension_key = "command_run_options" - groups = [:command, :options, :devcmdrun, :developer] - when :test_bot_test - dimension_key = "test_bot_test" - groups = [:command, :passed, :arch, :os] - when :cask_install - dimension_key = :cask - groups = [:package, :tap_name] - else - dimension_key = :formula - additional_where += " AND on_request = 'true'" if category == :formula_install_on_request - groups = [:package, :tap_name, :options] - end - - sql_groups = groups.map { |e| "\"#{e}\"" }.join(",") - query = <<~EOS - SELECT #{sql_groups}, COUNT(*) AS "count" FROM "#{bucket}" WHERE time >= now() - INTERVAL '#{days_ago} day'#{additional_where} GROUP BY #{sql_groups} - EOS - - json = T.let({ - category:, - total_items: 0, - start_date: Date.today - days_ago.to_i, - end_date: Date.today, - total_count: 0, - items: [], - }, T::Hash[Symbol, T.untyped]) - - each_influx_record(query) do |record| - if category == :homebrew_env_config - state = record["env_config_state"] - env_config_name = record["env_config"].to_s - # Drop malformed events from non-standard clients and events - # for variables Homebrew no longer supports. - next if %w[unset default non_default].exclude?(state) - next unless Homebrew::EnvConfig::ENVS.key?(env_config_name.to_sym) - - count = record["count"] - json[:total_count] += count - json[:items] << { - number: nil, - dimension_key => env_config_name, - count:, - non_default_count: (state == "non_default") ? count : 0, - set_default_count: (state == "default") ? count : 0, - unset_count: (state == "unset") ? count : 0, - } - next - end - - dimension = case category - when :homebrew_devcmdrun_developer - "devcmdrun=#{record["devcmdrun"]} HOMEBREW_DEVELOPER=#{record["developer"]}" - when :homebrew_os_arch_ci - if record["ci"] == "true" - "#{record["os"]} #{record["arch"]} (CI)" - else - "#{record["os"]} #{record["arch"]}" - end - when :homebrew_prefixes - prefix = record["prefix"].to_s - if STANDARD_PREFIXES.none? { |std| std.casecmp?(prefix) } - "custom-prefix (#{record["os"]} #{record["arch"]})" - else - prefix - end - when :os_versions - format_os_version_dimension(record["os_name_and_version"]) - when :command_run_options - "#{record["command"]} #{record["options"].to_s.split.sort.join(" ")}" - when :test_bot_test - command_and_package, options = record["command"].split.partition { |arg| !arg.start_with?("-") } - - # Cleanup bad data before https://github.com/Homebrew/homebrew-test-bot/pull/1043 - # Can delete this code after 27th April 2025. - next if %w[audit install linkage style test].exclude?(command_and_package.first) - next if command_and_package.last.include?("/") - next if options.include?("--tap=") - next if options.include?("--only-dependencies") - next if options.include?("--cached") - - command_and_options = (command_and_package + options.sort).join(" ") - passed = (record["passed"] == "true") ? "PASSED" : "FAILED" - - "#{command_and_options} (#{record["os"]} #{record["arch"]}) (#{passed})" - else - record[groups.first.to_s] - end - next if dimension.blank? - - if (tap_name = record["tap_name"].presence) && - ((tap_name != "homebrew/cask" && dimension_key == :cask) || - (tap_name != "homebrew/core" && dimension_key == :formula)) - dimension = "#{tap_name}/#{dimension}" - end - - if (all_core_formulae_json || category == :build_error) && - (options = record["options"].presence) - # homebrew/core formulae don't have non-HEAD options but they ended up in our analytics anyway. - if all_core_formulae_json - options = options.split.include?("--HEAD") ? "--HEAD" : "" - end - dimension = "#{dimension} #{options}" - end - - dimension = dimension.strip - next if dimension.match?(/[<>]/) - - count = record["count"] - - json[:total_items] += 1 - json[:total_count] += count - - json[:items] << { - number: nil, - dimension_key => dimension, - count:, - } - end - - odie "No data returned" if json[:total_count].zero? - - # Combine identical values - deduped_items = {} - - json[:items].each do |item| - key = item[dimension_key] - if deduped_items.key?(key) - deduped_items[key][:count] += item[:count] - if category == :homebrew_env_config - deduped_items[key][:non_default_count] += item[:non_default_count] - deduped_items[key][:set_default_count] += item[:set_default_count] - deduped_items[key][:unset_count] += item[:unset_count] - end - else - deduped_items[key] = item - end - end - - json[:items] = deduped_items.values - json[:total_items] = json[:items].length if category == :homebrew_env_config - - if all_core_formulae_json - core_formula_items = {} - - json[:items].each do |item| - item.delete(:number) - formula_name, = item[dimension_key].split.first - next if formula_name.include?("/") - - core_formula_items[formula_name] ||= [] - core_formula_items[formula_name] << item - end - json.delete(:items) - - core_formula_items.each_value do |items| - items.sort_by! { |item| -item[:count] } - items.each do |item| - item[:count] = format_count(item[:count]) - end - end - - json[:formulae] = core_formula_items.sort_by { |name, _| name }.to_h - else - json[:items].sort_by! do |item| - if category == :homebrew_env_config - -item[:non_default_count].to_f / item[:count] - else - -item[:count] - end - end - - json[:items].each_with_index do |item, index| - item[:number] = index + 1 - - percent = if category == :homebrew_env_config - (item[:non_default_count].to_f / item[:count]) * 100 - else - (item[:count].to_f / json[:total_count]) * 100 - end - item[:percent] = format_percent(percent) - item[:count] = format_count(item[:count]) - next if category != :homebrew_env_config - - item[:non_default_count] = format_count(item[:non_default_count]) - item[:set_default_count] = format_count(item[:set_default_count]) - item[:unset_count] = format_count(item[:unset_count]) - item[:default_value] = Homebrew::EnvConfig.default_description(item[dimension_key].to_sym) - end - end - - puts JSON.pretty_generate json - end - end - - sig { params(count: Integer).returns(String) } - def format_count(count) - count.to_s.reverse.gsub(/(\d{3})(?=\d)/, '\\1,').reverse - end - - sig { params(percent: Float).returns(String) } - def format_percent(percent) - format("%.2f", percent:).gsub(/\.00$/, "") - end - - sig { params(dimension: T.nilable(String)).returns(T.nilable(String)) } - def format_os_version_dimension(dimension) - return if dimension.blank? - - require "macos_version" - require "utils/analytics" - - wsl = dimension.end_with?(Utils::Analytics::WSL_SUFFIX) - dimension = dimension.delete_suffix(Utils::Analytics::WSL_SUFFIX) - dimension = dimension.gsub(/^Intel ?/, "") - .gsub(/^macOS ?/, "") - .gsub(/ \(.+\)$/, "") - - begin - macos_version = ::MacOSVersion.new(dimension) - release_version = macos_version.release_version - os_name = (macos_version < "10.12") ? "OS X" : "macOS" - if (release_name = macos_version.release_name) - return "#{os_name} #{release_name} (#{release_version})" - end - - return "#{os_name} #{release_version}" - rescue MacOSVersion::Error - nil - end - - formatted_dimension = case dimension - when /Ubuntu(-Server)? (14|16|18|20|22|24)\.04/ then "Ubuntu #{Regexp.last_match(2)}.04 LTS" - when /Ubuntu(-Server)? (\d+\.\d+).\d ?(LTS)?/ - "Ubuntu #{Regexp.last_match(2)} #{Regexp.last_match(3)}".strip - when %r{Debian GNU/Linux (\d+)} then "Debian #{Regexp.last_match(1)}" - when /CentOS (\w+) (\d+)/ then "CentOS #{Regexp.last_match(1)} #{Regexp.last_match(2)}" - when /Fedora Linux (\d+)[.\d]*/ then "Fedora Linux #{Regexp.last_match(1)}" - when /KDE neon .*?([\d.]+)/ then "KDE neon #{Regexp.last_match(1)}" - when /Amazon Linux (\d+)\.[.\d]*/ then "Amazon Linux #{Regexp.last_match(1)}" - when /^Armbian\S*(?: OS)? (\d+)\.0?(\d+)\S*(?: (\w+))?/ - "Armbian #{Regexp.last_match(1)}.#{Regexp.last_match(2)} #{Regexp.last_match(3)&.downcase}".strip - when /Fedora Linux Rawhide[.\dn]*/ then "Fedora Linux Rawhide" - when /Red Hat Enterprise Linux CoreOS (\d+\.\d+)[-.\d]*/ - "Red Hat Enterprise Linux CoreOS #{Regexp.last_match(1)}" - when /([A-Za-z ]+)\s+(\d+)\.\d{8}[.\d]*/ then "#{Regexp.last_match(1)} #{Regexp.last_match(2)}" - else dimension + influx.query(categories:, days_ago: (args.days_ago || 30).to_i, + all_core_formulae_json: args.all_core_formulae_json?).each do |json| + puts JSON.pretty_generate(json) end - - Utils::Analytics.with_wsl_suffix_if_needed(formatted_dimension, wsl:) end end end diff --git a/Library/Homebrew/github_packages.rb b/Library/Homebrew/github_packages.rb index 50477f52ec6c6..e102a1532bece 100644 --- a/Library/Homebrew/github_packages.rb +++ b/Library/Homebrew/github_packages.rb @@ -24,6 +24,56 @@ class GitHubPackages private_constant :URL_PREFIX private_constant :DOCKER_PREFIX + class Downloads < T::Struct + const :source, String + const :all_time, Integer + const :last_30_days, Integer + const :start_date, String + const :end_date, String + const :fetched_at, String + end + + # GitHub's package overview includes exact totals and daily chart values. + # Cache successful snapshots per UTC day for CLI and API generation callers. + sig { params(formula_name: String).returns(T.nilable(Downloads)) } + def self.downloads(formula_name) + cache = HOMEBREW_CACHE/"github-packages-downloads/#{formula_name}.json" + if cache.exist? + begin + cached = Downloads.from_hash(JSON.parse(cache.read)) + return cached if cached.fetched_at.start_with?(Time.now.utc.strftime("%F")) + rescue JSON::ParserError, TypeError, ArgumentError + nil + end + end + + url = "https://github.com/Homebrew/homebrew-core/pkgs/container/core%2F" \ + "#{image_formula_name(formula_name).gsub("/", "%2F")}" + output = Utils::Curl.curl_output("--fail", "--compressed", url) + return unless output.success? + + total = output.stdout[%r{Total downloads\s*]*title="(\d+)"}, 1] + chart = output.stdout[%r{aria-label="Downloads for the last 30 days">.*?}m] + return if total.nil? || chart.nil? + + bars = chart.scan(/]*>/m).flatten + daily = bars.filter_map do |bar| + date = bar[/\bdata-date="(\d{4}-\d{2}-\d{2})"/, 1] + count = bar[/\bdata-merge-count="(\d+)"/, 1] + next if date.nil? || count.nil? + + [date, count.to_i] + end.to_h + return if daily.empty? || daily.length != bars.length + + dates = daily.keys.sort + downloads = Downloads.new(source: url, all_time: total.to_i, last_30_days: daily.values.sum, + start_date: dates.fetch(0), end_date: dates.fetch(-1), fetched_at: Time.now.utc.iso8601) + cache.dirname.mkpath + cache.atomic_write(JSON.generate(downloads.serialize)) + downloads + end + URL_REGEX = %r{(?:#{Regexp.escape(URL_PREFIX)}|#{Regexp.escape(DOCKER_PREFIX)})([\w-]+)/([\w-]+)} # Valid OCI tag characters diff --git a/Library/Homebrew/test/dev-cmd/formula-analytics_spec.rb b/Library/Homebrew/test/dev-cmd/formula-analytics_spec.rb index c4dfe69337bff..9c8fdeeaec1ba 100644 --- a/Library/Homebrew/test/dev-cmd/formula-analytics_spec.rb +++ b/Library/Homebrew/test/dev-cmd/formula-analytics_spec.rb @@ -3,159 +3,7 @@ require "cmd/shared_examples/args_parse" require "dev-cmd/formula-analytics" -require "json" -require "utils/analytics" RSpec.describe Homebrew::DevCmd::FormulaAnalytics do it_behaves_like "parseable arguments" - - describe "#format_os_version_dimension" do - it "formats retired macOS versions" do - expect(described_class.new([]).format_os_version_dimension("10.15.7")) - .to eq("macOS Catalina (10.15)") - end - - it "normalizes Big Sur compatibility versions" do - expect(described_class.new([]).format_os_version_dimension("10.16.0")) - .to eq("macOS Big Sur (11)") - end - - it "formats retired OS X versions" do - expect(described_class.new([]).format_os_version_dimension("10.11.6")) - .to eq("OS X El Capitan (10.11)") - end - - it "formats unknown legacy macOS versions" do - expect(described_class.new([]).format_os_version_dimension("10.10.5")).to eq("OS X 10.10") - end - - it "formats unknown future macOS versions" do - expect(described_class.new([]).format_os_version_dimension("28.0")).to eq("macOS 28") - end - - it "does not format malformed macOS versions" do - expect(described_class.new([]).format_os_version_dimension("10.15.invalid")).to eq("10.15.invalid") - end - - it "formats Debian point releases as major releases" do - expect(described_class.new([]).format_os_version_dimension("Debian GNU/Linux 12.11")).to eq("Debian 12") - end - - it "formats Debian major releases" do - expect(described_class.new([]).format_os_version_dimension("Debian GNU/Linux 12")).to eq("Debian 12") - end - - it "preserves WSL in formatted Linux versions" do - expect(described_class.new([]).format_os_version_dimension( - "Ubuntu 24.04.3 LTS#{Utils::Analytics::WSL_SUFFIX}", - )).to eq("Ubuntu 24.04 LTS#{Utils::Analytics::WSL_SUFFIX}") - end - end - - describe "#influx_analytics" do - it "ranks sampled environment configurations by non-default use" do - ENV.delete("HOMEBREW_NO_ANALYTICS") - ENV["HOMEBREW_INFLUXDB_TOKEN"] = "token" - records = [ - { "env_config" => "HOMEBREW_BAT", "env_config_state" => "non_default", "count" => 2 }, - { "env_config" => "HOMEBREW_BAT", "env_config_state" => "default", "count" => 3 }, - { "env_config" => "HOMEBREW_BAT", "env_config_state" => "unset", "count" => 5 }, - { "env_config" => "HOMEBREW_NO_AUTO_UPDATE", "env_config_state" => "non_default", "count" => 1 }, - { "env_config" => "HOMEBREW_NO_AUTO_UPDATE", "env_config_state" => "unset", "count" => 1 }, - { "env_config" => "HOMEBREW_MAKE_JOBS", "env_config_state" => "default", "count" => 4 }, - { "env_config" => "HOMEBREW_TOTALLY_MADE_UP", "env_config_state" => "non_default", "count" => 100 }, - { "env_config" => "HOMEBREW_BAT", "env_config_state" => "borked", "count" => 50 }, - ] - queries = [] - command = described_class.new(["--homebrew-env-config", "--json"]) - allow(command).to receive(:each_influx_record) do |query, &block| - queries << query - records.each(&block) - end - expected = { - category: :homebrew_env_config, - total_items: 3, - start_date: Date.today - 30, - end_date: Date.today, - total_count: 16, - items: [ - { - number: 1, env_config: "HOMEBREW_NO_AUTO_UPDATE", count: "2", non_default_count: "1", - set_default_count: "0", unset_count: "1", percent: "50", default_value: nil - }, - { - number: 2, env_config: "HOMEBREW_BAT", count: "10", non_default_count: "2", - set_default_count: "3", unset_count: "5", percent: "20", default_value: nil - }, - { - number: 3, env_config: "HOMEBREW_MAKE_JOBS", count: "4", non_default_count: "0", - set_default_count: "4", unset_count: "0", percent: "0", - default_value: "The number of available CPU cores." - }, - ], - } - - expect { command.influx_analytics(command.args) } - .to output("#{JSON.pretty_generate(expected)}\n").to_stdout - expect(queries).to contain_exactly( - match(/FROM "command_run".*env_config_state IS NOT NULL GROUP BY/).and( - include('GROUP BY "env_config","env_config_state"'), - ), - ) - end - end - - describe "#each_influx_record" do - it "streams the JSON request to the bridge script and parses JSON lines" do - command = described_class.new([]) - bridge = mktmpdir/"fake-python" - bridge.write "#!/bin/sh\ncat\n" - bridge.chmod 0755 - allow(command).to receive(:venv_python).and_return(bridge) - records = [] - - command.each_influx_record("SELECT 1") { |record| records << record } - - expect(records).to eq [{ - "host" => "analytics.brew.sh", - "org" => Utils::Analytics::INFLUX_ORG, - "database" => Utils::Analytics::INFLUX_BUCKET, - "query" => "SELECT 1", - }] - end - - it "reports unauthenticated bridge errors as a token problem" do - command = described_class.new([]) - bridge = mktmpdir/"fake-python" - bridge.write <<~SH - #!/bin/sh - cat >/dev/null - echo "pyarrow.flight.FlightUnauthenticatedError: message: unauthenticated" >&2 - exit 1 - SH - bridge.chmod 0755 - allow(command).to receive(:venv_python).and_return(bridge) - - expect { command.each_influx_record("SELECT 1") { nil } } - .to raise_error(SystemExit) - .and output(/Could not authenticate with InfluxDB/).to_stderr - end - - it "reports other bridge failures with their standard error output" do - command = described_class.new([]) - bridge = mktmpdir/"fake-python" - bridge.write <<~SH - #!/bin/sh - cat >/dev/null - echo "Traceback: boom" >&2 - exit 1 - SH - bridge.chmod 0755 - allow(command).to receive(:venv_python).and_return(bridge) - - expect { command.each_influx_record("SELECT 1") { nil } } - .to raise_error(SystemExit) - .and output(/InfluxDB query failed:\nTraceback: boom/).to_stderr - end - end end diff --git a/Library/Homebrew/test/github_packages_spec.rb b/Library/Homebrew/test/github_packages_spec.rb index deefb2bb4da7a..3c5be443f967c 100644 --- a/Library/Homebrew/test/github_packages_spec.rb +++ b/Library/Homebrew/test/github_packages_spec.rb @@ -4,6 +4,52 @@ require "github_packages" RSpec.describe GitHubPackages do + describe ".downloads" do + before do + allow(Time).to receive(:now).and_return(Time.utc(2026, 10, 23, 12)) + allow(Utils::Curl).to receive(:curl_output).and_return( + instance_double(SystemCommand::Result, success?: true, stdout: <<~HTML), + Total downloads

22.8M

+
+ + +
+ HTML + ) + end + + it "reads exact package totals instead of rounded totals or recent tags" do + expect(described_class.downloads("wget")&.serialize).to include( + "all_time" => 22_776_486, "last_30_days" => 3579, + "start_date" => "2026-10-22", "end_date" => "2026-10-23", + "fetched_at" => "2026-10-23T12:00:00Z" + ) + end + + it "reuses a successful snapshot on the same UTC day" do + described_class.downloads("wget") + expect(Utils::Curl).not_to receive(:curl_output) + + described_class.downloads("wget") + end + + it "refreshes a snapshot on the next UTC day" do + described_class.downloads("wget") + allow(Time).to receive(:now).and_return(Time.utc(2026, 10, 24)) + expect(Utils::Curl).to receive(:curl_output).once + + described_class.downloads("wget") + end + + it "does not interpret missing chart data as zero downloads" do + allow(Utils::Curl).to receive(:curl_output).and_return( + instance_double(SystemCommand::Result, success?: true, stdout: '

100

'), + ) + + expect(described_class.downloads("wget")).to be_nil + end + end + describe "#upload_bottles" do it "reports progress when uploading many bottles" do github_packages = described_class.new diff --git a/Library/Homebrew/test/utils/analytics/influx_spec.rb b/Library/Homebrew/test/utils/analytics/influx_spec.rb new file mode 100644 index 0000000000000..05cd451cd0a7e --- /dev/null +++ b/Library/Homebrew/test/utils/analytics/influx_spec.rb @@ -0,0 +1,158 @@ +# typed: strict +# frozen_string_literal: true + +require "utils/analytics/influx" +require "json" +require "utils/analytics" + +RSpec.describe Utils::Analytics::Influx do + describe "#format_os_version_dimension" do + it "formats retired macOS versions" do + expect(described_class.new.format_os_version_dimension("10.15.7")) + .to eq("macOS Catalina (10.15)") + end + + it "normalizes Big Sur compatibility versions" do + expect(described_class.new.format_os_version_dimension("10.16.0")) + .to eq("macOS Big Sur (11)") + end + + it "formats retired OS X versions" do + expect(described_class.new.format_os_version_dimension("10.11.6")) + .to eq("OS X El Capitan (10.11)") + end + + it "formats unknown legacy macOS versions" do + expect(described_class.new.format_os_version_dimension("10.10.5")).to eq("OS X 10.10") + end + + it "formats unknown future macOS versions" do + expect(described_class.new.format_os_version_dimension("28.0")).to eq("macOS 28") + end + + it "does not format malformed macOS versions" do + expect(described_class.new.format_os_version_dimension("10.15.invalid")).to eq("10.15.invalid") + end + + it "formats Debian point releases as major releases" do + expect(described_class.new.format_os_version_dimension("Debian GNU/Linux 12.11")).to eq("Debian 12") + end + + it "formats Debian major releases" do + expect(described_class.new.format_os_version_dimension("Debian GNU/Linux 12")).to eq("Debian 12") + end + + it "preserves WSL in formatted Linux versions" do + expect(described_class.new.format_os_version_dimension( + "Ubuntu 24.04.3 LTS#{Utils::Analytics::WSL_SUFFIX}", + )).to eq("Ubuntu 24.04 LTS#{Utils::Analytics::WSL_SUFFIX}") + end + end + + describe "#query" do + it "ranks sampled environment configurations by non-default use" do + ENV.delete("HOMEBREW_NO_ANALYTICS") + ENV["HOMEBREW_INFLUXDB_TOKEN"] = "token" + records = [ + { "env_config" => "HOMEBREW_BAT", "env_config_state" => "non_default", "count" => 2 }, + { "env_config" => "HOMEBREW_BAT", "env_config_state" => "default", "count" => 3 }, + { "env_config" => "HOMEBREW_BAT", "env_config_state" => "unset", "count" => 5 }, + { "env_config" => "HOMEBREW_NO_AUTO_UPDATE", "env_config_state" => "non_default", "count" => 1 }, + { "env_config" => "HOMEBREW_NO_AUTO_UPDATE", "env_config_state" => "unset", "count" => 1 }, + { "env_config" => "HOMEBREW_MAKE_JOBS", "env_config_state" => "default", "count" => 4 }, + { "env_config" => "HOMEBREW_TOTALLY_MADE_UP", "env_config_state" => "non_default", "count" => 100 }, + { "env_config" => "HOMEBREW_BAT", "env_config_state" => "borked", "count" => 50 }, + ] + queries = [] + command = described_class.new + allow(command).to receive(:each_influx_record) do |query, &block| + queries << query + records.each(&block) + end + expected = { + category: :homebrew_env_config, + total_items: 3, + start_date: Date.today - 30, + end_date: Date.today, + total_count: 16, + items: [ + { + number: 1, env_config: "HOMEBREW_NO_AUTO_UPDATE", count: "2", non_default_count: "1", + set_default_count: "0", unset_count: "1", percent: "50", default_value: nil + }, + { + number: 2, env_config: "HOMEBREW_BAT", count: "10", non_default_count: "2", + set_default_count: "3", unset_count: "5", percent: "20", default_value: nil + }, + { + number: 3, env_config: "HOMEBREW_MAKE_JOBS", count: "4", non_default_count: "0", + set_default_count: "4", unset_count: "0", percent: "0", + default_value: "The number of available CPU cores." + }, + ], + } + + expect(JSON.pretty_generate(command.query(categories: [:homebrew_env_config]).fetch(0))) + .to eq(JSON.pretty_generate(expected)) + expect(queries).to contain_exactly( + match(/FROM "command_run".*env_config_state IS NOT NULL GROUP BY/).and( + include('GROUP BY "env_config","env_config_state"'), + ), + ) + end + end + + describe "#each_influx_record" do + it "streams the JSON request to the bridge script and parses JSON lines" do + command = described_class.new + bridge = mktmpdir/"fake-python" + bridge.write "#!/bin/sh\ncat\n" + bridge.chmod 0755 + allow(command).to receive(:venv_python).and_return(bridge) + records = [] + + command.each_influx_record("SELECT 1") { |record| records << record } + + expect(records).to eq [{ + "host" => "analytics.brew.sh", + "org" => Utils::Analytics::INFLUX_ORG, + "database" => Utils::Analytics::INFLUX_BUCKET, + "query" => "SELECT 1", + }] + end + + it "reports unauthenticated bridge errors as a token problem" do + command = described_class.new + bridge = mktmpdir/"fake-python" + bridge.write <<~SH + #!/bin/sh + cat >/dev/null + echo "pyarrow.flight.FlightUnauthenticatedError: message: unauthenticated" >&2 + exit 1 + SH + bridge.chmod 0755 + allow(command).to receive(:venv_python).and_return(bridge) + + expect { command.each_influx_record("SELECT 1") { nil } } + .to raise_error(SystemExit) + .and output(/Could not authenticate with InfluxDB/).to_stderr + end + + it "reports other bridge failures with their standard error output" do + command = described_class.new + bridge = mktmpdir/"fake-python" + bridge.write <<~SH + #!/bin/sh + cat >/dev/null + echo "Traceback: boom" >&2 + exit 1 + SH + bridge.chmod 0755 + allow(command).to receive(:venv_python).and_return(bridge) + + expect { command.each_influx_record("SELECT 1") { nil } } + .to raise_error(SystemExit) + .and output(/InfluxDB query failed:\nTraceback: boom/).to_stderr + end + end +end diff --git a/Library/Homebrew/test/utils/analytics_spec.rb b/Library/Homebrew/test/utils/analytics_spec.rb index 05d62852f31e1..30cbd5b3f0401 100644 --- a/Library/Homebrew/test/utils/analytics_spec.rb +++ b/Library/Homebrew/test/utils/analytics_spec.rb @@ -322,44 +322,26 @@ let(:args) { Homebrew::Cmd::Info.new(["--github-packages-downloads", "wget"]).args } let(:wget) { instance_double(Formula, name: "wget", core_formula?: true) } - def stub_github_packages_pages(downloads_by_tag) - index_html = <<~HTML - 1.25.0-2 - 1.25.0-1 - HTML - allow(Utils::Curl).to receive(:curl_output) do |*curl_args, **| - curl_args.each_cons(2) do |flag, path| - next if flag != "--output" - - downloads = downloads_by_tag[File.basename(path, ".html")] - next if downloads.nil? - - File.write(path, <<~HTML) - Last 30 days - #{downloads} - HTML - end - instance_double(SystemCommand::Result, success?: true, stdout: index_html) - end - end - - it "outputs downloads per tag, a total and the difference from analytics install events" do - stub_github_packages_pages("1.25.0-2" => "1,000", "1.25.0-1" => "1.5M") + it "outputs package totals and their ratio to analytics install events" do + allow(GitHubPackages).to receive(:downloads).with("wget").and_return( + GitHubPackages::Downloads.new(source: "https://github.com/example", all_time: 22_776_486, + last_30_days: 1_501_000, start_date: "2026-09-23", end_date: "2026-10-23", + fetched_at: "2026-10-23T12:00:00Z"), + ) expect { described_class.output_github_packages_downloads(wget, analytics, args:) } .to output( - match(/^1 +\| 1\.25\.0-1 +\| 1,500,000 \| +99\.93%$/) - .and(match(/^2 +\| 1\.25\.0-2 +\| +1,000 \| +0\.07%$/)) - .and(match(/^Total +\| +\| 1,501,000 \| +100\.00%$/)) - .and(include("Difference from 750,500 analytics install events (30 days): +100.00%")), + include("GitHub Packages downloads (since Bintray migration): 22,776,486", + "GitHub Packages downloads (30 days): 1,501,000", + "Downloads / analytics install events (30 days): 2.00x (750,500 events)"), ).to_stdout end - it "warns instead of outputting a partial total when a tag's downloads cannot be fetched" do - stub_github_packages_pages("1.25.0-2" => "1,000") + it "warns instead of reporting missing download counts as zero" do + allow(GitHubPackages).to receive(:downloads).with("wget").and_return(nil) expect { described_class.output_github_packages_downloads(wget, analytics, args:) } - .to output(/Failed to fetch GitHub Packages downloads for: 1\.25\.0-1$/).to_stderr + .to output(/Failed to fetch GitHub Packages downloads for: wget/).to_stderr .and not_to_output.to_stdout end end diff --git a/Library/Homebrew/utils/analytics.rb b/Library/Homebrew/utils/analytics.rb index 8be80b47bd590..e4b086dd42589 100644 --- a/Library/Homebrew/utils/analytics.rb +++ b/Library/Homebrew/utils/analytics.rb @@ -290,7 +290,7 @@ def output_analytics(analytics, args:) end end - # This method is undocumented because it is not intended for general use. + # This flag is hidden because it is not intended for general use. # It relies on screen scraping some GitHub HTML that's not available as an API. # This seems very likely to break in the future. # That said, it's the only way to get the data we want right now. @@ -299,55 +299,22 @@ def output_github_packages_downloads(formula, analytics, args:) return unless args.github_packages_downloads? return unless formula.core_formula? - require "tmpdir" - require "utils/curl" - - escaped_formula_name = GitHubPackages.image_formula_name(formula.name) - .gsub("/", "%2F") - formula_url_suffix = "container/core%2F#{escaped_formula_name}/" - formula_url = "https://github.com/Homebrew/homebrew-core/pkgs/#{formula_url_suffix}" - output = Utils::Curl.curl_output("--fail", "--compressed", formula_url) - return unless output.success? - - tag_urls = output.stdout - .scan(%r{/orgs/Homebrew/packages/#{formula_url_suffix}(\d+\?tag=([^"]+))}) - .to_h { |version, tag| [tag.to_s, "#{formula_url}#{version}"] } - return if tag_urls.empty? - - downloads_by_tag = Dir.mktmpdir("github-packages-downloads", HOMEBREW_TEMP) do |tmpdir| - Utils::Curl.curl_output("--fail", "--compressed", "--parallel", - *tag_urls.flat_map { |tag, url| ["--output", "#{tmpdir}/#{tag}.html", url] }) - - tag_urls.keys.filter_map do |tag| - version_html = Pathname("#{tmpdir}/#{tag}.html") - next unless version_html.exist? - - downloads = version_html.read[%r{>Last 30 days\s*]*>([\d.,]+M?)<}, 1] - next if downloads.nil? - - count = if downloads.end_with?("M") - (downloads.to_f * 1_000_000).to_i - else - downloads.tr(",", "").to_i - end - [tag, count] - end - end - missing_tags = tag_urls.keys - downloads_by_tag.map(&:first) - if missing_tags.any? - opoo "Failed to fetch GitHub Packages downloads for: #{missing_tags.join(", ")}" + downloads = GitHubPackages.downloads(formula.name) + unless downloads + opoo "Failed to fetch GitHub Packages downloads for: #{formula.name}" return end - table_output("github-packages-downloads", "30", downloads_by_tag.sort_by { |_, count| -count }.to_h, - name_header: "Tag") + puts "GitHub Packages downloads (since Bintray migration): #{format_count(downloads.all_time)}" + puts "GitHub Packages downloads (30 days): #{format_count(downloads.last_30_days)}" + puts "Download dates: #{downloads.start_date} to #{downloads.end_date}; fetched #{downloads.fetched_at}" install_count = analytics.dig("install", "30d")&.values&.sum return unless install_count&.positive? - difference = ((downloads_by_tag.sum { |_, count| count } - install_count) * 100.0) / install_count - puts "Difference from #{format_count(install_count)} analytics install events (30 days): " \ - "#{format("%+.2f", difference)}%" + puts "Downloads / analytics install events (30 days): " \ + "#{format("%.2f", downloads.last_30_days.to_f / install_count)}x " \ + "(#{format_count(install_count)} events)" end sig { params(formula: Formula, args: Homebrew::Cmd::Info::Args).void } diff --git a/Library/Homebrew/utils/analytics/influx.rb b/Library/Homebrew/utils/analytics/influx.rb new file mode 100644 index 0000000000000..45c68d0d4036c --- /dev/null +++ b/Library/Homebrew/utils/analytics/influx.rb @@ -0,0 +1,386 @@ +# typed: strict +# frozen_string_literal: true + +require "system_command" + +module Utils + module Analytics + # Query and format the analytics stored in InfluxDB. + class Influx + include Utils::Output::Mixin + + FIRST_INFLUXDB_ANALYTICS_DATE = Date.new(2023, 03, 27).freeze + STANDARD_PREFIXES = %w[/opt/homebrew /usr/local /home/linuxbrew/.linuxbrew].freeze + + sig { void } + def setup_python + uv = which("uv", ORIGINAL_PATHS) + odie <<~EOS if uv.nil? + `uv` is required. Try: + brew install uv + EOS + + vendor_python = venv_root.dirname + vendor_python.children.reject { |path| path == venv_root }.each(&:rmtree) if vendor_python.exist? + + with_env(UV_PROJECT_ENVIRONMENT: venv_root.to_s) do + SystemCommand.safe_system uv, "sync", "--frozen", "--project", formula_analytics_root, out: :err + end + end + + sig { returns(Pathname) } + def formula_analytics_root + HOMEBREW_LIBRARY/"Homebrew/formula-analytics" + end + + sig { returns(Pathname) } + def influxdb_query_script + formula_analytics_root/"influxdb-query.py" + end + + sig { returns(Pathname) } + def venv_root + python_version = (formula_analytics_root/".python-version").read.chomp + Pathname.new("~/.brew-formula-analytics/vendor/python").expand_path/python_version + end + + sig { returns(Pathname) } + def venv_python + venv_root/"bin/python" + end + + sig { params(query: String, _block: T.proc.params(record: T::Hash[String, T.untyped]).void).void } + def each_influx_record(query, &_block) + require "json" + require "tempfile" + require "utils/analytics" + require "utils/popen" + + request = { + host: URI.parse(Utils::Analytics::INFLUX_HOST).host, + org: Utils::Analytics::INFLUX_ORG, + database: Utils::Analytics::INFLUX_BUCKET, + query:, + }.to_json + + Tempfile.create("influxdb-query-stderr") do |stderr_file| + Utils.popen([venv_python.to_s, influxdb_query_script.to_s], "r+b", { err: stderr_file.path }) do |pipe| + pipe.write request + pipe.close_write + pipe.each_line { |line| yield JSON.parse(line) } + end + + next if $CHILD_STATUS.success? + + stderr = stderr_file.read + if stderr.include?("unauthenticated") + odie "Could not authenticate with InfluxDB! Please check your `$HOMEBREW_INFLUXDB_TOKEN`!" + end + odie "InfluxDB query failed:\n#{stderr}" + end + end + + sig { + params(categories: T::Array[Symbol], days_ago: Integer, + all_core_formulae_json: T::Boolean).returns(T::Array[T::Hash[Symbol, T.untyped]]) + } + def query(categories:, days_ago: 30, all_core_formulae_json: false) + require "utils/analytics" + require "json" + + odie "`$HOMEBREW_NO_ANALYTICS` is set!" if ENV["HOMEBREW_NO_ANALYTICS"] + + odie "No InfluxDB credentials found in `$HOMEBREW_INFLUXDB_TOKEN`!" unless ENV["HOMEBREW_INFLUXDB_TOKEN"] + + max_days_ago = (Date.today - FIRST_INFLUXDB_ANALYTICS_DATE).to_s.to_i + if days_ago > max_days_ago + opoo "Analytics started #{FIRST_INFLUXDB_ANALYTICS_DATE}. `--days-ago` set to maximum value." + days_ago = max_days_ago + end + if days_ago > 365 + opoo "Analytics are only retained for 1 year, setting `--days-ago=365`." + days_ago = 365 + end + + category_matching_buckets = [:build_error, :cask_install, :command_run, :test_bot_test] + + categories.map do |category| + additional_where = all_core_formulae_json ? " AND tap_name ~ '^homebrew/(core|cask)$'" : "" + bucket = if category_matching_buckets.include?(category) + category + elsif [:command_run_options, :homebrew_env_config].include?(category) + :command_run + else + :formula_install + end + + case category + when :homebrew_devcmdrun_developer + dimension_key = "devcmdrun_developer" + groups = [:devcmdrun, :developer] + when :homebrew_env_config + dimension_key = "env_config" + groups = [:env_config, :env_config_state] + # Events predating the user-set-aware `env_config_state` tag + # counted brew's own exports as configuration, so drop them. + additional_where += " AND env_config_state IS NOT NULL" + when :homebrew_os_arch_ci + dimension_key = "os_arch_ci" + groups = [:os, :arch, :ci] + when :homebrew_prefixes + dimension_key = "prefix" + groups = [:prefix, :os, :arch] + when :homebrew_versions + dimension_key = "version" + groups = [:version] + when :os_versions + dimension_key = :os_version + groups = [:os_name_and_version] + when :command_run + dimension_key = "command_run" + groups = [:command] + when :command_run_options + dimension_key = "command_run_options" + groups = [:command, :options, :devcmdrun, :developer] + when :test_bot_test + dimension_key = "test_bot_test" + groups = [:command, :passed, :arch, :os] + when :cask_install + dimension_key = :cask + groups = [:package, :tap_name] + else + dimension_key = :formula + additional_where += " AND on_request = 'true'" if category == :formula_install_on_request + groups = [:package, :tap_name, :options] + end + + sql_groups = groups.map { |e| "\"#{e}\"" }.join(",") + query = <<~EOS + SELECT #{sql_groups}, COUNT(*) AS "count" FROM "#{bucket}" WHERE time >= now() - INTERVAL '#{days_ago} day'#{additional_where} GROUP BY #{sql_groups} + EOS + + json = T.let({ + category:, + total_items: 0, + start_date: Date.today - days_ago.to_i, + end_date: Date.today, + total_count: 0, + items: [], + }, T::Hash[Symbol, T.untyped]) + + each_influx_record(query) do |record| + if category == :homebrew_env_config + state = record["env_config_state"] + env_config_name = record["env_config"].to_s + # Drop malformed events from non-standard clients and events + # for variables Homebrew no longer supports. + next if %w[unset default non_default].exclude?(state) + next unless Homebrew::EnvConfig::ENVS.key?(env_config_name.to_sym) + + count = record["count"] + json[:total_count] += count + json[:items] << { + number: nil, + dimension_key => env_config_name, + count:, + non_default_count: (state == "non_default") ? count : 0, + set_default_count: (state == "default") ? count : 0, + unset_count: (state == "unset") ? count : 0, + } + next + end + + dimension = case category + when :homebrew_devcmdrun_developer + "devcmdrun=#{record["devcmdrun"]} HOMEBREW_DEVELOPER=#{record["developer"]}" + when :homebrew_os_arch_ci + if record["ci"] == "true" + "#{record["os"]} #{record["arch"]} (CI)" + else + "#{record["os"]} #{record["arch"]}" + end + when :homebrew_prefixes + prefix = record["prefix"].to_s + if STANDARD_PREFIXES.none? { |std| std.casecmp?(prefix) } + "custom-prefix (#{record["os"]} #{record["arch"]})" + else + prefix + end + when :os_versions + format_os_version_dimension(record["os_name_and_version"]) + when :command_run_options + "#{record["command"]} #{record["options"].to_s.split.sort.join(" ")}" + when :test_bot_test + command_and_package, options = record["command"].split.partition { |arg| !arg.start_with?("-") } + + # Cleanup bad data before https://github.com/Homebrew/homebrew-test-bot/pull/1043 + # Can delete this code after 27th April 2025. + next if %w[audit install linkage style test].exclude?(command_and_package.first) + next if command_and_package.last.include?("/") + next if options.include?("--tap=") + next if options.include?("--only-dependencies") + next if options.include?("--cached") + + command_and_options = (command_and_package + options.sort).join(" ") + passed = (record["passed"] == "true") ? "PASSED" : "FAILED" + + "#{command_and_options} (#{record["os"]} #{record["arch"]}) (#{passed})" + else + record[groups.first.to_s] + end + next if dimension.blank? + + if (tap_name = record["tap_name"].presence) && + ((tap_name != "homebrew/cask" && dimension_key == :cask) || + (tap_name != "homebrew/core" && dimension_key == :formula)) + dimension = "#{tap_name}/#{dimension}" + end + + if (all_core_formulae_json || category == :build_error) && + (options = record["options"].presence) + # homebrew/core formulae don't have non-HEAD options but they ended up in our analytics anyway. + if all_core_formulae_json + options = options.split.include?("--HEAD") ? "--HEAD" : "" + end + dimension = "#{dimension} #{options}" + end + + dimension = dimension.strip + next if dimension.match?(/[<>]/) + + count = record["count"] + + json[:total_items] += 1 + json[:total_count] += count + + json[:items] << { + number: nil, + dimension_key => dimension, + count:, + } + end + + odie "No data returned" if json[:total_count].zero? + + # Combine identical values + deduped_items = {} + + json[:items].each do |item| + key = item[dimension_key] + if deduped_items.key?(key) + deduped_items[key][:count] += item[:count] + if category == :homebrew_env_config + deduped_items[key][:non_default_count] += item[:non_default_count] + deduped_items[key][:set_default_count] += item[:set_default_count] + deduped_items[key][:unset_count] += item[:unset_count] + end + else + deduped_items[key] = item + end + end + + json[:items] = deduped_items.values + json[:total_items] = json[:items].length if category == :homebrew_env_config + + if all_core_formulae_json + core_formula_items = {} + + json[:items].each do |item| + item.delete(:number) + formula_name, = item[dimension_key].split.first + next if formula_name.include?("/") + + core_formula_items[formula_name] ||= [] + core_formula_items[formula_name] << item + end + json.delete(:items) + + core_formula_items.each_value do |items| + items.sort_by! { |item| -item[:count] } + items.each do |item| + item[:count] = Utils::Analytics.format_count(item[:count]) + end + end + + json[:formulae] = core_formula_items.sort_by { |name, _| name }.to_h + else + json[:items].sort_by! do |item| + if category == :homebrew_env_config + -item[:non_default_count].to_f / item[:count] + else + -item[:count] + end + end + + json[:items].each_with_index do |item, index| + item[:number] = index + 1 + + percent = if category == :homebrew_env_config + (item[:non_default_count].to_f / item[:count]) * 100 + else + (item[:count].to_f / json[:total_count]) * 100 + end + item[:percent] = Utils::Analytics.format_percent(percent).delete_suffix(".00") + item[:count] = Utils::Analytics.format_count(item[:count]) + next if category != :homebrew_env_config + + item[:non_default_count] = Utils::Analytics.format_count(item[:non_default_count]) + item[:set_default_count] = Utils::Analytics.format_count(item[:set_default_count]) + item[:unset_count] = Utils::Analytics.format_count(item[:unset_count]) + item[:default_value] = Homebrew::EnvConfig.default_description(item[dimension_key].to_sym) + end + end + + json + end + end + + sig { params(dimension: T.nilable(String)).returns(T.nilable(String)) } + def format_os_version_dimension(dimension) + return if dimension.blank? + + require "macos_version" + require "utils/analytics" + + wsl = dimension.end_with?(Utils::Analytics::WSL_SUFFIX) + dimension = dimension.delete_suffix(Utils::Analytics::WSL_SUFFIX) + dimension = dimension.gsub(/^Intel ?/, "") + .gsub(/^macOS ?/, "") + .gsub(/ \(.+\)$/, "") + + begin + macos_version = ::MacOSVersion.new(dimension) + release_version = macos_version.release_version + os_name = (macos_version < "10.12") ? "OS X" : "macOS" + if (release_name = macos_version.release_name) + return "#{os_name} #{release_name} (#{release_version})" + end + + return "#{os_name} #{release_version}" + rescue MacOSVersion::Error + nil + end + + formatted_dimension = case dimension + when /Ubuntu(-Server)? (14|16|18|20|22|24)\.04/ then "Ubuntu #{Regexp.last_match(2)}.04 LTS" + when /Ubuntu(-Server)? (\d+\.\d+).\d ?(LTS)?/ + "Ubuntu #{Regexp.last_match(2)} #{Regexp.last_match(3)}".strip + when %r{Debian GNU/Linux (\d+)} then "Debian #{Regexp.last_match(1)}" + when /CentOS (\w+) (\d+)/ then "CentOS #{Regexp.last_match(1)} #{Regexp.last_match(2)}" + when /Fedora Linux (\d+)[.\d]*/ then "Fedora Linux #{Regexp.last_match(1)}" + when /KDE neon .*?([\d.]+)/ then "KDE neon #{Regexp.last_match(1)}" + when /Amazon Linux (\d+)\.[.\d]*/ then "Amazon Linux #{Regexp.last_match(1)}" + when /^Armbian\S*(?: OS)? (\d+)\.0?(\d+)\S*(?: (\w+))?/ + "Armbian #{Regexp.last_match(1)}.#{Regexp.last_match(2)} #{Regexp.last_match(3)&.downcase}".strip + when /Fedora Linux Rawhide[.\dn]*/ then "Fedora Linux Rawhide" + when /Red Hat Enterprise Linux CoreOS (\d+\.\d+)[-.\d]*/ + "Red Hat Enterprise Linux CoreOS #{Regexp.last_match(1)}" + when /([A-Za-z ]+)\s+(\d+)\.\d{8}[.\d]*/ then "#{Regexp.last_match(1)} #{Regexp.last_match(2)}" + else dimension + end + + Utils::Analytics.with_wsl_suffix_if_needed(formatted_dimension, wsl:) + end + end + end +end