From 804bae8ee8e5ed11a86193fb3b0a6792d5d59de1 Mon Sep 17 00:00:00 2001 From: David Brownman Date: Wed, 15 Jul 2026 17:24:42 -0700 Subject: [PATCH 1/2] Replace source hash with Telemetry UUID (#1902) * Replace source hash with telemetry UUID * prevent race conditions * Add/adjust tests & CI --- .github/workflows/ci.yml | 26 ++++-- Gemfile | 6 +- lib/stripe.rb | 2 + lib/stripe/api_requestor.rb | 29 +----- lib/stripe/telemetry_id.rb | 65 ++++++++++++++ test/stripe/api_requestor_test.rb | 32 +++---- test/stripe/telemetry_id_test.rb | 142 ++++++++++++++++++++++++++++++ test/test_helper.rb | 13 +-- 8 files changed, 259 insertions(+), 56 deletions(-) create mode 100644 lib/stripe/telemetry_id.rb create mode 100644 test/stripe/telemetry_id_test.rb diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f0f85cbb7..358ea3c57 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -45,14 +45,22 @@ jobs: path: '*.gem' test: - name: Test (${{ matrix.ruby-version }}) - runs-on: ubuntu-24.04 + name: Test (${{ matrix.ruby-version }}, ${{ matrix.os }}) + runs-on: ${{ matrix.os }} permissions: contents: read strategy: + fail-fast: false matrix: - # following https://docs.stripe.com/sdks/versioning?lang=ruby#stripe-sdk-language-version-support-policy - ruby-version: [2.7, '3.0', 3.1, 3.2, 3.3, 3.4, jruby-9.4.7.0, truffleruby-25.0.0] + os: + - ubuntu-24.04 + # https://docs.stripe.com/sdks/versioning?lang=ruby#stripe-sdk-language-version-support-policy + # https://endoflife.date/ruby + ruby-version: ['2.7', '3.0', '3.1', '3.2', '3.3', '3.4', jruby-9.4.7.0, truffleruby-25.0.0] + include: + - os: windows-latest + # use any modern-ish version + ruby-version: '3.4' steps: - uses: extractions/setup-just@v2 - uses: actions/checkout@v3 @@ -62,7 +70,15 @@ jobs: ruby-version: ${{ matrix.ruby-version }} - uses: stripe/openapi/actions/stripe-mock@master - name: test - run: just test typecheck + run: just test + env: + GITHUB_TOKEN: ${{ secrets.github_token }} + # Use IP instead of 'localhost' to avoid slow DNS resolution on Windows + STRIPE_MOCK_HOST: ${{ runner.os == 'Windows' && '127.0.0.1' || 'localhost' }} + - name: typecheck + # sorbet doesn't support windows + if: runner.os != 'Windows' + run: just typecheck env: GITHUB_TOKEN: ${{ secrets.github_token }} diff --git a/Gemfile b/Gemfile index 7ed31d718..41d8c1dd8 100644 --- a/Gemfile +++ b/Gemfile @@ -27,8 +27,10 @@ group :development do # The latest version of rubocop is only compatible with Ruby 2.7+ gem "rubocop", "1.75.2" if RUBY_VERSION >= "2.7" - gem "sorbet" - gem "tapioca" + unless RUBY_PLATFORM =~ /mingw|mswin/ + gem "sorbet" + gem "tapioca" + end platforms :mri do gem "byebug" diff --git a/lib/stripe.rb b/lib/stripe.rb index f1aae0c01..2b3ffaa5e 100644 --- a/lib/stripe.rb +++ b/lib/stripe.rb @@ -8,6 +8,7 @@ require "net/http" require "openssl" require "rbconfig" +require "fileutils" require "securerandom" require "set" require "socket" @@ -36,6 +37,7 @@ require "stripe/request_params" require "stripe/stripe_context" require "stripe/util" +require "stripe/telemetry_id" require "stripe/connection_manager" require "stripe/multipart_encoder" require "stripe/api_requestor" diff --git a/lib/stripe/api_requestor.rb b/lib/stripe/api_requestor.rb index 70601ee8e..8665680b0 100644 --- a/lib/stripe/api_requestor.rb +++ b/lib/stripe/api_requestor.rb @@ -1,6 +1,5 @@ # frozen_string_literal: true -require "digest" require "socket" require "stripe/instrumentation" @@ -1068,31 +1067,6 @@ def dup_from_response_headers(headers) # in so that we can generate a rich user agent header to help debug # integrations. class SystemProfiler - UNAME_HASH = begin - parts = [] - parts << if RUBY_PLATFORM.match?(/mswin|mingw|cygwin/) - begin - `ver 2>NUL`.strip - rescue StandardError - "" - end - else - begin - `uname -a 2>/dev/null`.strip - rescue StandardError - "" - end - end - parts << begin - Socket.gethostname - rescue StandardError - "" - end - Digest::MD5.hexdigest(parts.join(" ")) - rescue StandardError - "" - end - AI_AGENTS = [ # aiAgents: The beginning of the section generated from our OpenAPI spec %w[ANTIGRAVITY_CLI_ALIAS antigravity], @@ -1130,7 +1104,8 @@ def self.user_agent if Stripe.enable_telemetry? ua[:platform] = RUBY_PLATFORM - ua[:source] = UNAME_HASH unless UNAME_HASH.empty? + tid = TelemetryId.get + ua[:telemetry_id] = tid if tid end ai_agent = detect_ai_agent diff --git a/lib/stripe/telemetry_id.rb b/lib/stripe/telemetry_id.rb new file mode 100644 index 000000000..6afe7e7a0 --- /dev/null +++ b/lib/stripe/telemetry_id.rb @@ -0,0 +1,65 @@ +# frozen_string_literal: true + +module Stripe + module TelemetryId + @cached_id = nil + @loaded = false + + def self.config_dir + if Gem.win_platform? + appdata = ENV.fetch("APPDATA", nil) + return nil unless appdata && !appdata.empty? + + ::File.join(appdata, "Stripe") + else + xdg = ENV.fetch("XDG_CONFIG_HOME", nil) + if xdg && !xdg.empty? + ::File.join(xdg, "stripe") + else + ::File.expand_path("~/.config/stripe") + end + end + rescue ArgumentError + nil + end + + def self.get + return @cached_id if @loaded + + dir = config_dir + return nil unless dir + + file_path = ::File.join(dir, "telemetry_id") + + begin + content = ::File.read(file_path).strip + unless content.empty? + @cached_id = content + return @cached_id + end + rescue SystemCallError + # File doesn't exist or can't be read + end + + new_id = SecureRandom.hex(16) + + begin + ::FileUtils.mkdir_p(dir) + ::File.write(file_path, new_id) + rescue SystemCallError + return nil + end + + @cached_id = new_id + @cached_id + ensure + @loaded = true + end + + # For testing only + def self.reset! + @cached_id = nil + @loaded = false + end + end +end diff --git a/test/stripe/api_requestor_test.rb b/test/stripe/api_requestor_test.rb index efbfff828..7e926f261 100644 --- a/test/stripe/api_requestor_test.rb +++ b/test/stripe/api_requestor_test.rb @@ -902,8 +902,8 @@ class RequestorTest < Test::Unit::TestCase client.send(request_method, :post, "/v1/charges", :api, &@read_body_chunk_block) end - assert_equal "#{APIRequestor::ERROR_MESSAGE_CONNECTION % Stripe::DEFAULT_API_BASE} Request was retried 2 times.\n\n(Network error: Connection refused)", - e.message + assert_match(/Request was retried 2 times\.\n\n\(Network error: .*refused/i, + e.message) end should "handle error response with unknown value" do @@ -1800,29 +1800,29 @@ class SystemProfilerTest < Test::Unit::TestCase Stripe.enable_telemetry = false end - should "omit source when UNAME_HASH is empty" do - original = APIRequestor::SystemProfiler::UNAME_HASH - APIRequestor::SystemProfiler.send(:remove_const, :UNAME_HASH) - APIRequestor::SystemProfiler.const_set(:UNAME_HASH, "") + should "include telemetry_id when telemetry is enabled" do Stripe.enable_telemetry = true + TelemetryId.stubs(:get).returns("abc123def456") ua = APIRequestor::SystemProfiler.user_agent - refute ua.key?(:source) + assert_equal "abc123def456", ua[:telemetry_id] ensure - APIRequestor::SystemProfiler.send(:remove_const, :UNAME_HASH) - APIRequestor::SystemProfiler.const_set(:UNAME_HASH, original) Stripe.enable_telemetry = false end - should "include source when UNAME_HASH is non-empty" do - original = APIRequestor::SystemProfiler::UNAME_HASH - APIRequestor::SystemProfiler.send(:remove_const, :UNAME_HASH) - APIRequestor::SystemProfiler.const_set(:UNAME_HASH, "abc123") + should "omit telemetry_id when telemetry is disabled" do + Stripe.enable_telemetry = false + ua = APIRequestor::SystemProfiler.user_agent + refute ua.key?(:telemetry_id) + ensure + Stripe.enable_telemetry = false + end + + should "omit telemetry_id when TelemetryId.get returns nil" do Stripe.enable_telemetry = true + TelemetryId.stubs(:get).returns(nil) ua = APIRequestor::SystemProfiler.user_agent - assert_equal "abc123", ua[:source] + refute ua.key?(:telemetry_id) ensure - APIRequestor::SystemProfiler.send(:remove_const, :UNAME_HASH) - APIRequestor::SystemProfiler.const_set(:UNAME_HASH, original) Stripe.enable_telemetry = false end end diff --git a/test/stripe/telemetry_id_test.rb b/test/stripe/telemetry_id_test.rb new file mode 100644 index 000000000..8f61f6081 --- /dev/null +++ b/test/stripe/telemetry_id_test.rb @@ -0,0 +1,142 @@ +# frozen_string_literal: true + +require File.expand_path("../test_helper", __dir__) +require "tmpdir" + +module Stripe + class TelemetryIdTest < Test::Unit::TestCase + setup do + TelemetryId.reset! + end + + teardown do + TelemetryId.reset! + end + + context ".config_dir" do + if RUBY_PLATFORM !~ /mingw|mswin/ + should "return XDG_CONFIG_HOME/stripe when XDG_CONFIG_HOME is set" do + with_env("XDG_CONFIG_HOME" => "/tmp/xdg") do + assert_equal "/tmp/xdg/stripe", TelemetryId.config_dir + end + end + + should "return ~/.config/stripe when XDG_CONFIG_HOME is not set" do + with_env("XDG_CONFIG_HOME" => nil) do + assert_equal ::File.expand_path("~/.config/stripe"), TelemetryId.config_dir + end + end + + should "fall back to ~/.config/stripe when XDG_CONFIG_HOME is empty" do + with_env("XDG_CONFIG_HOME" => "") do + assert_equal ::File.expand_path("~/.config/stripe"), TelemetryId.config_dir + end + end + else + should "return APPDATA/Stripe on Windows" do + refute_nil TelemetryId.config_dir + assert TelemetryId.config_dir.end_with?("Stripe") + end + end + end + + context ".get" do + should "return nil when config_dir returns nil" do + TelemetryId.stubs(:config_dir).returns(nil) + assert_nil TelemetryId.get + end + + should "return cached id from file when it exists" do + ::Dir.mktmpdir do |dir| + file_path = ::File.join(dir, "telemetry_id") + ::File.write(file_path, "stored_id_abc123\n") + + TelemetryId.stubs(:config_dir).returns(dir) + assert_equal "stored_id_abc123", TelemetryId.get + end + end + + should "generate and persist a new id when file does not exist" do + ::Dir.mktmpdir do |dir| + TelemetryId.stubs(:config_dir).returns(dir) + id = TelemetryId.get + refute_nil id + assert_equal 32, id.length + assert_equal id, ::File.read(::File.join(dir, "telemetry_id")) + end + end + + should "create parent directory when it does not exist" do + ::Dir.mktmpdir do |base_dir| + dir = ::File.join(base_dir, "nonexistent", "nested") + TelemetryId.stubs(:config_dir).returns(dir) + id = TelemetryId.get + refute_nil id + assert ::File.exist?(::File.join(dir, "telemetry_id")) + end + end + + should "return nil when directory cannot be created" do + TelemetryId.stubs(:config_dir).returns("/nonexistent/readonly/path") + ::FileUtils.stubs(:mkdir_p).raises(Errno::EACCES, "permission denied") + assert_nil TelemetryId.get + end + + should "cache the result after first call" do + ::Dir.mktmpdir do |dir| + TelemetryId.stubs(:config_dir).returns(dir) + first_id = TelemetryId.get + # Modify the file to confirm the second call reads from cache + ::File.write(::File.join(dir, "telemetry_id"), "different_value") + second_id = TelemetryId.get + assert_equal first_id, second_id + end + end + + should "only call config_dir once even when it returns nil" do + TelemetryId.stubs(:config_dir).returns(nil) + TelemetryId.get + # Second call should use cache; expects config_dir was only called once + TelemetryId.expects(:config_dir).never + TelemetryId.get + end + end + + context ".reset!" do + should "clear cached state so next call re-evaluates" do + ::Dir.mktmpdir do |dir| + TelemetryId.stubs(:config_dir).returns(dir) + first_id = TelemetryId.get + refute_nil first_id + + TelemetryId.reset! + ::File.write(::File.join(dir, "telemetry_id"), "new_persisted_id") + + second_id = TelemetryId.get + assert_equal "new_persisted_id", second_id + end + end + end + + private def with_env(vars) + old_values = {} + vars.each do |key, value| + old_values[key] = ENV.fetch(key, nil) + if value.nil? + ENV.delete(key) + else + ENV[key] = value + end + end + yield + ensure + old_values.each do |key, value| + if value.nil? + ENV.delete(key) + else + ENV[key] = value + end + end + end + end +end diff --git a/test/test_helper.rb b/test/test_helper.rb index 47cfcbb32..3a22f2f1d 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -14,21 +14,22 @@ MOCK_MINIMUM_VERSION = "0.109.0" MOCK_PORT = Stripe::StripeMock.start +MOCK_HOST = ENV.fetch("STRIPE_MOCK_HOST", "localhost") # Disable all real network connections except those that are outgoing to # stripe-mock. -WebMock.disable_net_connect!(allow: "localhost:#{MOCK_PORT}") +WebMock.disable_net_connect!(allow: "#{MOCK_HOST}:#{MOCK_PORT}") # Try one initial test connection to stripe-mock so that if there's a problem # we can print one error and fail fast so that it's more clear to the user how # they should fix the problem. begin - resp = Net::HTTP.get_response(URI("http://localhost:#{MOCK_PORT}/")) + resp = Net::HTTP.get_response(URI("http://#{MOCK_HOST}:#{MOCK_PORT}/")) version = resp["Stripe-Mock-Version"] if version.nil? abort("Couldn't find `Stripe-Mock-Version` in response from " \ - "`localhost:#{MOCK_PORT}`. Is the service running there stripe-mock?") + "`#{MOCK_HOST}:#{MOCK_PORT}`. Is the service running there stripe-mock?") end if version != "master" && @@ -38,7 +39,7 @@ "see its repository for upgrade instructions.") end rescue Errno::ECONNREFUSED - abort("Couldn't reach stripe-mock at `localhost:#{MOCK_PORT}`. Is " \ + abort("Couldn't reach stripe-mock at `#{MOCK_HOST}:#{MOCK_PORT}`. Is " \ "it running? Please see README for setup instructions.") end @@ -56,8 +57,8 @@ class TestCase setup do Stripe.api_key = "sk_test_123" - Stripe.api_base = "http://localhost:#{MOCK_PORT}" - Stripe.uploads_base = "http://localhost:#{MOCK_PORT}" + Stripe.api_base = "http://#{MOCK_HOST}:#{MOCK_PORT}" + Stripe.uploads_base = "http://#{MOCK_HOST}:#{MOCK_PORT}" stub_connect end From 0f910c9cb57d81eb895e433f42593f68cb0b9acf Mon Sep 17 00:00:00 2001 From: David Brownman Date: Wed, 15 Jul 2026 17:36:14 -0700 Subject: [PATCH 2/2] Bump version to 19.3.1 --- CHANGELOG.md | 4 ++++ VERSION | 2 +- lib/stripe/version.rb | 2 +- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1c426c548..a429e6427 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## 19.3.1 - 2026-07-15 +* [#1902](https://github.com/stripe/stripe-ruby/pull/1902) Replace source hash with Telemetry UUID +* [#1901](https://github.com/stripe/stripe-ruby/pull/1901) Make Error fields generated + ## 19.3.0 - 2026-06-24 This release changes the pinned API version to 2026-06-24.dahlia. diff --git a/VERSION b/VERSION index 46e5968c5..4c800a9e0 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -19.3.0 +19.3.1 diff --git a/lib/stripe/version.rb b/lib/stripe/version.rb index baaeeea7f..840ab7b50 100644 --- a/lib/stripe/version.rb +++ b/lib/stripe/version.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true module Stripe - VERSION = "19.3.0" + VERSION = "19.3.1" end