From 82c1d7ac51b8df95489876e86315bfb50a8e934f Mon Sep 17 00:00:00 2001 From: David Brownman Date: Fri, 10 Jul 2026 15:28:31 -0700 Subject: [PATCH 1/2] Replace source hash with telemetry UUID --- lib/stripe.rb | 2 + lib/stripe/api_requestor.rb | 29 +------ lib/stripe/telemetry_id.rb | 64 ++++++++++++++ test/stripe/api_requestor_test.rb | 28 +++---- test/stripe/telemetry_id_test.rb | 135 ++++++++++++++++++++++++++++++ 5 files changed, 217 insertions(+), 41 deletions(-) create mode 100644 lib/stripe/telemetry_id.rb create mode 100644 test/stripe/telemetry_id_test.rb 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..cd1331a0d --- /dev/null +++ b/lib/stripe/telemetry_id.rb @@ -0,0 +1,64 @@ +# 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 + + @loaded = true + + 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 + + @cached_id = SecureRandom.hex(16) + + begin + ::FileUtils.mkdir_p(dir) + ::File.write(file_path, @cached_id) + rescue SystemCallError + @cached_id = nil + return nil + end + + @cached_id + end + + 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..24c1e1786 100644 --- a/test/stripe/api_requestor_test.rb +++ b/test/stripe/api_requestor_test.rb @@ -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..4e0bc3b7d --- /dev/null +++ b/test/stripe/telemetry_id_test.rb @@ -0,0 +1,135 @@ +# 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 + 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 + 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 From d473cce7965f5c5c588b346838934dd26296f69f Mon Sep 17 00:00:00 2001 From: David Brownman Date: Mon, 13 Jul 2026 17:40:51 -0700 Subject: [PATCH 2/2] prevent race conditions --- lib/stripe/telemetry_id.rb | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/lib/stripe/telemetry_id.rb b/lib/stripe/telemetry_id.rb index cd1331a0d..6afe7e7a0 100644 --- a/lib/stripe/telemetry_id.rb +++ b/lib/stripe/telemetry_id.rb @@ -26,8 +26,6 @@ def self.config_dir def self.get return @cached_id if @loaded - @loaded = true - dir = config_dir return nil unless dir @@ -43,19 +41,22 @@ def self.get # File doesn't exist or can't be read end - @cached_id = SecureRandom.hex(16) + new_id = SecureRandom.hex(16) begin ::FileUtils.mkdir_p(dir) - ::File.write(file_path, @cached_id) + ::File.write(file_path, new_id) rescue SystemCallError - @cached_id = nil return nil end + @cached_id = new_id @cached_id + ensure + @loaded = true end + # For testing only def self.reset! @cached_id = nil @loaded = false