From 07c5b72baa1a5f620f1ef938e33cfb645f8873f1 Mon Sep 17 00:00:00 2001 From: Joshua Teitelbaum Date: Sun, 12 Oct 2025 15:29:58 -0700 Subject: [PATCH] Adding notifications support for various lifecycle conditions --- Gemfile.lock | 1 + lib/zendesk_api/client.rb | 7 +- lib/zendesk_api/configuration.rb | 3 + .../middleware/request/etag_cache.rb | 12 +++ lib/zendesk_api/middleware/request/retry.rb | 16 ++++ .../response/zendesk_request_event.rb | 41 +++++++++ .../middleware/request/etag_cache_spec.rb | 59 +++++++++++++ spec/core/middleware/request/retry_spec.rb | 32 +++++++ .../response/zendesk_request_event_spec.rb | 86 +++++++++++++++++++ spec/spec_helper.rb | 7 ++ zendesk_api.gemspec | 1 + 11 files changed, 263 insertions(+), 2 deletions(-) create mode 100644 lib/zendesk_api/middleware/response/zendesk_request_event.rb create mode 100644 spec/core/middleware/response/zendesk_request_event_spec.rb create mode 100644 spec/spec_helper.rb diff --git a/Gemfile.lock b/Gemfile.lock index eccc83aa..b690d937 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -2,6 +2,7 @@ PATH remote: . specs: zendesk_api (3.1.1) + activesupport faraday (> 2.0.0) faraday-multipart hashie (>= 3.5.2) diff --git a/lib/zendesk_api/client.rb b/lib/zendesk_api/client.rb index ec5fed22..d3399bf4 100644 --- a/lib/zendesk_api/client.rb +++ b/lib/zendesk_api/client.rb @@ -11,6 +11,7 @@ require 'zendesk_api/middleware/request/encode_json' require 'zendesk_api/middleware/request/url_based_access_token' require 'zendesk_api/middleware/response/callback' +require 'zendesk_api/middleware/response/zendesk_request_event' require 'zendesk_api/middleware/response/deflate' require 'zendesk_api/middleware/response/gzip' require 'zendesk_api/middleware/response/sanitize_response' @@ -146,6 +147,7 @@ def build_connection Faraday.new(config.options) do |builder| # response builder.use ZendeskAPI::Middleware::Response::RaiseError + builder.use ZendeskAPI::Middleware::Response::ZendeskRequestEvent, self if config.instrumentation.respond_to?(:instrument) builder.use ZendeskAPI::Middleware::Response::Callback, self builder.use ZendeskAPI::Middleware::Response::Logger, config.logger if config.logger builder.use ZendeskAPI::Middleware::Response::ParseIsoDates @@ -161,7 +163,7 @@ def build_connection set_authentication(builder, config) if config.cache - builder.use ZendeskAPI::Middleware::Request::EtagCache, :cache => config.cache + builder.use ZendeskAPI::Middleware::Request::EtagCache, { :cache => config.cache, :instrumentation => config.instrumentation } end builder.use ZendeskAPI::Middleware::Request::Upload @@ -173,7 +175,8 @@ def build_connection builder.use ZendeskAPI::Middleware::Request::Retry, :logger => config.logger, :retry_codes => config.retry_codes, - :retry_on_exception => config.retry_on_exception + :retry_on_exception => config.retry_on_exception, + :instrumentation => config.instrumentation end if config.raise_error_when_rate_limited builder.use ZendeskAPI::Middleware::Request::RaiseRateLimited, :logger => config.logger diff --git a/lib/zendesk_api/configuration.rb b/lib/zendesk_api/configuration.rb index 4f34df73..5dca1b03 100644 --- a/lib/zendesk_api/configuration.rb +++ b/lib/zendesk_api/configuration.rb @@ -54,6 +54,9 @@ class Configuration # specify if you want a (network layer) exception to elicit a retry attr_accessor :retry_on_exception + # specify if you wnat instrumentation to be used + attr_accessor :instrumentation + def initialize @client_options = {} @use_resource_cache = true diff --git a/lib/zendesk_api/middleware/request/etag_cache.rb b/lib/zendesk_api/middleware/request/etag_cache.rb index fef6c856..b816efb7 100644 --- a/lib/zendesk_api/middleware/request/etag_cache.rb +++ b/lib/zendesk_api/middleware/request/etag_cache.rb @@ -1,4 +1,5 @@ require "faraday/middleware" +require 'active_support/notifications' module ZendeskAPI module Middleware @@ -9,6 +10,7 @@ module Request class EtagCache < Faraday::Middleware def initialize(app, options = {}) @app = app + @instrumentation = options[:instrumentation] if options[:instrumentation].respond_to?(:instrument) @cache = options[:cache] || raise("need :cache option e.g. ActiveSupport::Cache::MemoryStore.new") @cache_key_prefix = options.fetch(:cache_key_prefix, :faraday_etags) @@ -41,8 +43,18 @@ def call(environment) :content_length => cached[:response_headers][:content_length], :content_encoding => cached[:response_headers][:content_encoding] ) + @instrumentation&.instrument("zendesk.cache_hit", + { + endpoint: env[:url].path, + status: env[:status] + }) elsif env[:status] == 200 && env[:response_headers]["Etag"] # modified and cacheable @cache.write(cache_key(env), env.to_hash) + @instrumentation&.instrument("zendesk.cache_miss", + { + endpoint: env[:url].path, + status: env[:status] + }) end end end diff --git a/lib/zendesk_api/middleware/request/retry.rb b/lib/zendesk_api/middleware/request/retry.rb index 49932bcf..4d331ada 100644 --- a/lib/zendesk_api/middleware/request/retry.rb +++ b/lib/zendesk_api/middleware/request/retry.rb @@ -14,10 +14,16 @@ def initialize(app, options = {}) @logger = options[:logger] @error_codes = options.key?(:retry_codes) && options[:retry_codes] ? options[:retry_codes] : DEFAULT_ERROR_CODES @retry_on_exception = options.key?(:retry_on_exception) && options[:retry_on_exception] ? options[:retry_on_exception] : false + @instrumentation = options[:instrumentation] end def call(env) original_env = env.dup + if original_env[:call_attempt] + original_env[:call_attempt] += 1 + else + original_env[:call_attempt] = 1 + end exception_happened = false if @retry_on_exception begin @@ -40,6 +46,16 @@ def call(env) @logger.warn "You have been rate limited. Retrying in #{seconds_left} seconds..." if @logger + if @instrumentation + @instrumentation.instrument("zendesk.retry", + { + attempt: original_env[:call_attempt], + endpoint: original_env[:url].path, + method: original_env[:method], + reason: exception_happened ? 'exception' : 'rate_limited', + delay: seconds_left + }) + end seconds_left.times do |i| sleep 1 time_left = seconds_left - i diff --git a/lib/zendesk_api/middleware/response/zendesk_request_event.rb b/lib/zendesk_api/middleware/response/zendesk_request_event.rb new file mode 100644 index 00000000..b8919f48 --- /dev/null +++ b/lib/zendesk_api/middleware/response/zendesk_request_event.rb @@ -0,0 +1,41 @@ +require "faraday/response" + +module ZendeskAPI + module Middleware + module Response + # @private + class ZendeskRequestEvent < Faraday::Middleware + def initialize(app, client) + super(app) + @client = client + end + + def call(env) + start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC) + @app.call(env).on_complete do |response_env| + end_time = Process.clock_gettime(Process::CLOCK_MONOTONIC) + duration = (end_time - start_time) * 1000.0 + instrumentation = @client.config.instrumentation + if instrumentation + instrumentation.instrument("zendesk.request", + { duration: duration, + endpoint: response_env[:url].path, + method: response_env[:method], + status: response_env[:status] }) + if response_env[:status] < 500 + instrumentation.instrument("zendesk.rate_limit", + { + endpoint: response_env[:url].path, + status: response_env[:status], + threshold: response_env[:response_headers] ? response_env[:response_headers][:x_rate_limit_remaining] : nil, + limit: response_env[:response_headers] ? response_env[:response_headers][:x_rate_limit] : nil, + reset: response_env[:response_headers] ? response_env[:response_headers][:x_rate_limit_reset] : nil + }) + end + end + end + end + end + end + end +end diff --git a/spec/core/middleware/request/etag_cache_spec.rb b/spec/core/middleware/request/etag_cache_spec.rb index 9c2fdfa5..0399f2e3 100644 --- a/spec/core/middleware/request/etag_cache_spec.rb +++ b/spec/core/middleware/request/etag_cache_spec.rb @@ -1,4 +1,5 @@ require 'core/spec_helper' +require 'active_support/cache' describe ZendeskAPI::Middleware::Request::EtagCache do it "caches" do @@ -18,4 +19,62 @@ expect(response.headers[header]).to eq(first_response.headers[header]) end end + + context "instrumentation" do + let(:instrumentation) { double("Instrumentation") } + let(:cache) { ActiveSupport::Cache::MemoryStore.new } + let(:status) { nil } + let(:middleware) do + ZendeskAPI::Middleware::Request::EtagCache.new( + ->(env) { Faraday::Response.new(env) }, + cache: cache, + instrumentation: instrumentation + ) + end + let(:env) do + { + url: URI("https://example.zendesk.com/api/v2/blergh"), + method: :get, + request_headers: {}, + response_headers: { "Etag" => "x", x_rate_limit_remaining: 10 }, + status: status, + body: { "x" => 1 }, + response_body: { "x" => 1 } + } + end + let(:no_instrumentation_middleware) do + ZendeskAPI::Middleware::Request::EtagCache.new( + ->(env) { Faraday::Response.new(env) }, + cache: cache, + instrumentation: nil + ) + end + before do + allow(instrumentation).to receive(:instrument) + end + + it "emits cache_miss on first request" do + expect(instrumentation).to receive(:instrument).with( + "zendesk.cache_miss", + hash_including(endpoint: "/api/v2/blergh", status: 200) + ) + env[:status] = 200 + middleware.call(env).on_complete { |_e| 1 } + end + + it "don't care on no instrumentation" do + env[:status] = 200 + no_instrumentation_middleware.call(env).on_complete { |_e| 1 } + end + + it "emits cache_hit on 304 response" do + cache.write(middleware.cache_key(env), env) + expect(instrumentation).to receive(:instrument).with( + "zendesk.cache_hit", + hash_including(endpoint: "/api/v2/blergh", status: 304) + ) + env[:status] = 304 + middleware.call(env).on_complete { |_e| 1 } + end + end end diff --git a/spec/core/middleware/request/retry_spec.rb b/spec/core/middleware/request/retry_spec.rb index 82b58551..0460b951 100644 --- a/spec/core/middleware/request/retry_spec.rb +++ b/spec/core/middleware/request/retry_spec.rb @@ -118,4 +118,36 @@ def runtime end end end + + context "with instrumentation on retry" do + let(:instrumentation) { double("Instrumentation") } + let(:middleware) do + ZendeskAPI::Middleware::Request::Retry.new(client.connection.builder.app) + end + + before do + allow(instrumentation).to receive(:instrument) + client.config.instrumentation = instrumentation + # Inject instrumentation into middleware instance + allow_any_instance_of(ZendeskAPI::Middleware::Request::Retry).to receive(:instrumentation).and_return(instrumentation) + stub_request(:get, %r{instrumented}).to_return(:status => 429, :headers => { :retry_after => 1 }).to_return(:status => 200) + end + + it "calls instrumentation on retry" do + expect(instrumentation).to receive(:instrument).with( + "zendesk.retry", + hash_including(:attempt, :endpoint, :method, :reason, :delay) + ).at_least(:once) + client.connection.get("instrumented") + end + + it "does not call instrumentation when no retry occurs" do + stub_request(:get, %r{no_retry}).to_return(:status => 200) + expect(instrumentation).not_to receive(:instrument).with( + "zendesk.retry", + hash_including(:attempt, :endpoint, :method, :reason, :delay) + ) + client.connection.get("no_retry") + end + end end diff --git a/spec/core/middleware/response/zendesk_request_event_spec.rb b/spec/core/middleware/response/zendesk_request_event_spec.rb new file mode 100644 index 00000000..ee5bdb7f --- /dev/null +++ b/spec/core/middleware/response/zendesk_request_event_spec.rb @@ -0,0 +1,86 @@ +require_relative '../../../spec_helper' +require 'faraday' +require 'zendesk_api/middleware/response/zendesk_request_event' + +RSpec.describe ZendeskAPI::Middleware::Response::ZendeskRequestEvent do + let(:instrumentation) { double('Instrumentation') } + let(:client) do + double('Client', config: double('Config', instrumentation: instrumentation)) + end + let(:app) { ->(env) { Faraday::Response.new(env) } } + let(:middleware) { described_class.new(app, client) } + let(:response_headers) do + { + x_rate_limit_remaining: 10, + x_rate_limit: 100, + x_rate_limit_reset: 1234567890 + } + end + let(:env) do + { + url: URI('https://example.zendesk.com/api/v2/tickets'), + method: :get, + status: status, + response_headers: response_headers + } + end + + before do + allow(instrumentation).to receive(:instrument) + end + + context 'when the response status is less than 500' do + let(:status) { 200 } + + it 'instruments zendesk.request and zendesk.rate_limit' do + expect(instrumentation).to receive(:instrument).with( + 'zendesk.request', + hash_including(:duration, endpoint: '/api/v2/tickets', method: :get, status: 200) + ) + expect(instrumentation).to receive(:instrument).with( + 'zendesk.rate_limit', + hash_including(endpoint: '/api/v2/tickets', status: 200) + ) + middleware.call(env).on_complete { |_response_env| 1 } + end + end + + context 'when the response status is 500 or greater' do + let(:status) { 500 } + + it 'instruments only zendesk.request' do + expect(instrumentation).to receive(:instrument).with( + 'zendesk.request', + hash_including(:duration, endpoint: '/api/v2/tickets', method: :get, status: 500) + ) + expect(instrumentation).not_to receive(:instrument).with('zendesk.rate_limit', anything) + middleware.call(env).on_complete { |_response_env| 1 } + end + end + + context 'duration calculation' do + let(:status) { 201 } + + it 'passes a positive duration to instrumentation' do + expect(instrumentation).to receive(:instrument) do |event, payload| + if event == 'zendesk.request' + expect(payload[:duration]).to be > 0 + end + end + expect(instrumentation).to receive(:instrument).with('zendesk.rate_limit', anything) + middleware.call(env).on_complete { |_response_env| 1 } + end + end + + context 'when instrumentation is nil' do + let(:status) { 200 } + let(:client) do + double('Client', config: double('Config', instrumentation: nil)) + end + let(:middleware) { described_class.new(app, client) } + + it 'does not raise an error' do + expect { middleware.call(env).on_complete { |_response_env| 1 } }.not_to raise_error + end + end +end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb new file mode 100644 index 00000000..186ba343 --- /dev/null +++ b/spec/spec_helper.rb @@ -0,0 +1,7 @@ +require 'rspec' + +RSpec.configure do |config| + config.expect_with :rspec do |c| + c.syntax = :expect + end +end diff --git a/zendesk_api.gemspec b/zendesk_api.gemspec index fec405c3..0b2aa620 100644 --- a/zendesk_api.gemspec +++ b/zendesk_api.gemspec @@ -33,4 +33,5 @@ Gem::Specification.new do |s| s.add_dependency "inflection" s.add_dependency "multipart-post", "~> 2.0" s.add_dependency "mini_mime" + s.add_dependency "activesupport" end