Skip to content

Commit 5b7644a

Browse files
cryptomailThomascountz
authored andcommitted
Add notifications support for various lifecycle conditions
Co-authored-by: Thomas Countz <thomascountz@gmail.com> Applications can configure any instrumentation backend that responds to #instrument(event_name, payload), e.g. ActiveSupport::Notifications Example: ZendeskAPI.configure do |config| config.instrumentation = ActiveSupport::Notifications end Events: zendesk.request: - When: All requests (emitted regardless of status code) - Payload: duration (ms), endpoint, method, status zendesk.rate_limit: - When: Status < 500 AND rate limit headers present - Not emitted for: 5xx errors, missing headers - Payload: endpoint, status, remaining, limit, reset - Note: Includes successful requests and 4xx errors when rate limit headers are present zendesk.cache_hit: - When: 304 Not Modified served from cache - Requires: Cache enabled, matching ETag - Payload: endpoint, status (304) zendesk.cache_miss: - When: 200 OK with ETag cached for future requests - Requires: Cache enabled, ETag header present - Payload: endpoint, status (200) zendesk.retry: - When: Before retry sleep (429, 503, or exception) - Requires: Retry enabled; exceptions need retry_on_exception: true - Payload: attempt (starts at 1), endpoint, method, delay (seconds), reason - Reasons: "rate_limited" (429), "server_error" (503), "exception" (network failures) Key assumptions/decisions: - Users must supply their own compatible config.instrumentation, this lib does not pull in activesupport - duration reports round-trip time including network call and response processing middleware - Rate limit events only for status < 500 (client/rate limit errors) - Request events emitted for all requests (not filtered by status) - Instrumentation failures logged at debug level or silently swallowed
1 parent 6ae5063 commit 5b7644a

File tree

10 files changed

+397
-20
lines changed

10 files changed

+397
-20
lines changed

lib/zendesk_api/client.rb

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
require_relative "middleware/response/parse_json"
2020
require_relative "middleware/response/raise_error"
2121
require_relative "middleware/response/logger"
22+
require_relative "middleware/response/zendesk_request_event"
2223
require_relative "delegator"
2324

2425
module ZendeskAPI
@@ -96,6 +97,7 @@ def initialize
9697
@resource_cache = {}
9798

9899
check_url
100+
check_instrumentation
99101

100102
config.retry = !!config.retry # nil -> false
101103

@@ -166,6 +168,7 @@ def build_connection
166168
Faraday.new(config.options) do |builder|
167169
# response
168170
builder.use ZendeskAPI::Middleware::Response::RaiseError
171+
builder.use ZendeskAPI::Middleware::Response::ZendeskRequestEvent, instrumentation: config.instrumentation, logger: config.logger if config.instrumentation
169172
builder.use ZendeskAPI::Middleware::Response::Callback, self
170173
builder.use ZendeskAPI::Middleware::Response::Logger, config.logger if config.logger
171174
builder.use ZendeskAPI::Middleware::Response::ParseIsoDates
@@ -181,7 +184,7 @@ def build_connection
181184
set_authentication(builder, config)
182185

183186
if config.cache
184-
builder.use ZendeskAPI::Middleware::Request::EtagCache, cache: config.cache
187+
builder.use ZendeskAPI::Middleware::Request::EtagCache, cache: config.cache, instrumentation: config.instrumentation
185188
end
186189

187190
builder.use ZendeskAPI::Middleware::Request::Upload
@@ -193,7 +196,8 @@ def build_connection
193196
builder.use ZendeskAPI::Middleware::Request::Retry,
194197
logger: config.logger,
195198
retry_codes: config.retry_codes,
196-
retry_on_exception: config.retry_on_exception
199+
retry_on_exception: config.retry_on_exception,
200+
instrumentation: config.instrumentation
197201
end
198202
if config.raise_error_when_rate_limited
199203
builder.use ZendeskAPI::Middleware::Request::RaiseRateLimited, logger: config.logger
@@ -217,6 +221,14 @@ def check_url
217221
end
218222
end
219223

224+
def check_instrumentation
225+
return unless config.instrumentation
226+
227+
unless config.instrumentation.respond_to?(:instrument)
228+
raise ArgumentError, "instrumentation must respond to #instrument"
229+
end
230+
end
231+
220232
def set_raise_error_when_rated_limited
221233
config.raise_error_when_rate_limited = if config.retry
222234
false

lib/zendesk_api/configuration.rb

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,9 @@ class Configuration
5454
# specify if you want a (network layer) exception to elicit a retry
5555
attr_accessor :retry_on_exception
5656

57+
# specify if you want instrumentation to be used
58+
attr_accessor :instrumentation
59+
5760
def initialize
5861
@client_options = {}
5962
@use_resource_cache = true

lib/zendesk_api/middleware/request/etag_cache.rb

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ module Request
99
class EtagCache < Faraday::Middleware
1010
def initialize(app, options = {})
1111
@app = app
12+
@instrumentation = options[:instrumentation] if options[:instrumentation].respond_to?(:instrument)
1213
@cache = options[:cache] ||
1314
raise("need :cache option e.g. ActiveSupport::Cache::MemoryStore.new")
1415
@cache_key_prefix = options.fetch(:cache_key_prefix, :faraday_etags)
@@ -41,8 +42,30 @@ def call(environment)
4142
content_length: cached[:response_headers][:content_length],
4243
content_encoding: cached[:response_headers][:content_encoding]
4344
)
45+
if @instrumentation
46+
begin
47+
@instrumentation.instrument("zendesk.cache_hit",
48+
{
49+
endpoint: env[:url]&.path,
50+
status: env[:status]
51+
})
52+
rescue
53+
# Swallow instrumentation errors to maintain cache behavior
54+
end
55+
end
4456
elsif env[:status] == 200 && env[:response_headers]["Etag"] # modified and cacheable
4557
@cache.write(cache_key(env), env.to_hash)
58+
if @instrumentation
59+
begin
60+
@instrumentation.instrument("zendesk.cache_miss",
61+
{
62+
endpoint: env[:url]&.path,
63+
status: env[:status]
64+
})
65+
rescue
66+
# Swallow instrumentation errors to maintain cache behavior
67+
end
68+
end
4669
end
4770
end
4871
end

lib/zendesk_api/middleware/request/retry.rb

Lines changed: 53 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -15,44 +15,79 @@ def initialize(app, options = {})
1515
@logger = options[:logger]
1616
@error_codes = (options.key?(:retry_codes) && options[:retry_codes]) ? options[:retry_codes] : DEFAULT_ERROR_CODES
1717
@retry_on_exception = (options.key?(:retry_on_exception) && options[:retry_on_exception]) ? options[:retry_on_exception] : false
18+
@instrumentation = options[:instrumentation]
1819
end
1920

2021
def call(env)
22+
# Duplicate env for retries but keep attempt counter persistent
2123
original_env = env.dup
24+
original_env[:call_attempt] = (env[:call_attempt] || 0)
25+
2226
exception_happened = false
27+
response = nil
28+
2329
if @retry_on_exception
2430
begin
2531
response = @app.call(env)
26-
rescue => e
32+
rescue => ex
2733
exception_happened = true
34+
exception = ex
2835
end
2936
else
37+
# Allow exceptions to propagate normally when not retrying
3038
response = @app.call(env)
3139
end
3240

33-
if exception_happened || @error_codes.include?(response.env[:status])
41+
if exception_happened
42+
original_env[:call_attempt] += 1
43+
seconds_left = DEFAULT_RETRY_AFTER.to_i
44+
@logger&.warn "An exception happened, waiting #{seconds_left} seconds... #{exception}"
45+
instrument_retry(original_env, "exception", seconds_left)
46+
sleep_with_logging(seconds_left)
47+
return @app.call(original_env)
48+
end
3449

35-
if exception_happened
36-
seconds_left = DEFAULT_RETRY_AFTER.to_i
37-
@logger&.warn "An exception happened, waiting #{seconds_left} seconds... #{e}"
38-
else
39-
seconds_left = (response.env[:response_headers][:retry_after] || DEFAULT_RETRY_AFTER).to_i
40-
end
50+
# Retry once if response has a retryable error code
51+
if response && @error_codes.include?(response.env[:status])
52+
original_env[:call_attempt] += 1
53+
seconds_left = (response.env[:response_headers][:retry_after] || DEFAULT_RETRY_AFTER).to_i
54+
@logger&.warn "You may have been rate limited. Retrying in #{seconds_left} seconds..."
55+
instrument_retry(original_env, (response.env[:status] == 429) ? "rate_limited" : "server_error", seconds_left)
56+
sleep_with_logging(seconds_left)
57+
response = @app.call(original_env)
58+
end
4159

42-
@logger&.warn "You have been rate limited. Retrying in #{seconds_left} seconds..."
60+
response
61+
end
4362

44-
seconds_left.times do |i|
45-
sleep 1
46-
time_left = seconds_left - i
47-
@logger&.warn "#{time_left}..." if time_left > 0 && time_left % 5 == 0
48-
end
63+
private
4964

50-
@logger&.warn ""
65+
def instrument_retry(env, reason, delay)
66+
return unless @instrumentation
5167

52-
@app.call(original_env)
53-
else
54-
response
68+
begin
69+
@instrumentation.instrument(
70+
"zendesk.retry",
71+
{
72+
attempt: env[:call_attempt],
73+
endpoint: env[:url]&.path,
74+
method: env[:method],
75+
reason: reason,
76+
delay: delay
77+
}
78+
)
79+
rescue => e
80+
@logger&.debug("zendesk.retry instrumentation failed: #{e.message}")
81+
end
82+
end
83+
84+
def sleep_with_logging(seconds_left)
85+
seconds_left.times do |i|
86+
sleep 1
87+
time_left = seconds_left - i
88+
@logger&.warn "#{time_left}..." if time_left > 0 && time_left % 5 == 0
5589
end
90+
@logger&.warn "" if seconds_left > 0
5691
end
5792
end
5893
end
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
require "faraday/response"
2+
3+
module ZendeskAPI
4+
module Middleware
5+
module Response
6+
# @private
7+
class ZendeskRequestEvent < Faraday::Middleware
8+
def initialize(app, options = {})
9+
super(app)
10+
@instrumentation = options[:instrumentation]
11+
@logger = options[:logger]
12+
end
13+
14+
def call(env)
15+
return @app.call(env) unless instrumentation
16+
17+
start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
18+
@app.call(env).on_complete do |response_env|
19+
stop_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
20+
21+
begin
22+
instrument_request(response_env, start_time, stop_time)
23+
instrument_rate_limit(response_env)
24+
rescue => e
25+
logger&.debug("Instrumentation failed: #{e.message}")
26+
end
27+
end
28+
end
29+
30+
private
31+
32+
attr_reader :instrumentation, :logger
33+
34+
def instrument_request(response_env, start_time, stop_time)
35+
duration_ms = (stop_time - start_time) * 1000.0
36+
37+
payload = {
38+
duration: duration_ms,
39+
endpoint: response_env[:url]&.path,
40+
method: response_env[:method],
41+
status: response_env[:status]
42+
}
43+
44+
instrumentation.instrument("zendesk.request", payload)
45+
end
46+
47+
def instrument_rate_limit(response_env)
48+
status = response_env[:status]
49+
return unless status && status < 500
50+
51+
headers = response_env[:response_headers]
52+
remaining, limit, reset = headers&.values_at(
53+
"X-Rate-Limit-Remaining",
54+
"X-Rate-Limit",
55+
"X-Rate-Limit-Reset"
56+
)
57+
return if [remaining, limit, reset].all?(&:nil?)
58+
59+
payload = {
60+
endpoint: response_env[:url]&.path,
61+
status: status,
62+
remaining: remaining,
63+
limit: limit,
64+
reset: reset
65+
}
66+
67+
instrumentation.instrument("zendesk.rate_limit", payload)
68+
end
69+
end
70+
end
71+
end
72+
end

spec/core/middleware/request/etag_cache_spec.rb

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
require "core/spec_helper"
2+
13
describe ZendeskAPI::Middleware::Request::EtagCache do
24
it "caches" do
35
client.config.cache.size = 1
@@ -16,4 +18,63 @@
1618
expect(response.headers[header]).to eq(first_response.headers[header])
1719
end
1820
end
21+
22+
context "instrumentation" do
23+
let(:instrumenter) { TestInstrumenter.new }
24+
let(:cache) { ZendeskAPI::LRUCache.new(5) }
25+
let(:middleware) do
26+
ZendeskAPI::Middleware::Request::EtagCache.new(
27+
->(env) { Faraday::Response.new(env) },
28+
cache: cache,
29+
instrumentation: instrumenter
30+
)
31+
end
32+
let(:env) do
33+
{
34+
url: URI("https://example.zendesk.com/api/v2/blergh"),
35+
method: :get,
36+
request_headers: {},
37+
response_headers: {"Etag" => "x"},
38+
status: nil,
39+
body: {"x" => 1},
40+
response_body: {"x" => 1}
41+
}
42+
end
43+
44+
it "instruments cache miss on first request" do
45+
env[:status] = 200
46+
middleware.call(env).on_complete { |_e| }
47+
48+
cache_events = instrumenter.find_events("zendesk.cache_miss")
49+
expect(cache_events.size).to eq(1)
50+
51+
event = cache_events.first[:payload]
52+
expect(event[:endpoint]).to eq("/api/v2/blergh")
53+
expect(event[:status]).to eq(200)
54+
end
55+
56+
it "instruments cache hit on 304 response" do
57+
cache.write(middleware.cache_key(env), env)
58+
env[:status] = 304
59+
middleware.call(env).on_complete { |_e| }
60+
61+
cache_events = instrumenter.find_events("zendesk.cache_hit")
62+
expect(cache_events.size).to eq(1)
63+
64+
event = cache_events.first[:payload]
65+
expect(event[:endpoint]).to eq("/api/v2/blergh")
66+
expect(event[:status]).to eq(304)
67+
end
68+
69+
it "does not crash when instrumentation is nil" do
70+
no_instrumentation_middleware = ZendeskAPI::Middleware::Request::EtagCache.new(
71+
->(env) { Faraday::Response.new(env) },
72+
cache: cache,
73+
instrumentation: nil
74+
)
75+
76+
env[:status] = 200
77+
expect { no_instrumentation_middleware.call(env).on_complete { |_e| } }.not_to raise_error
78+
end
79+
end
1980
end

spec/core/middleware/request/retry_spec.rb

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,4 +116,36 @@ def runtime
116116
end
117117
end
118118
end
119+
120+
context "with instrumentation on retry" do
121+
let(:instrumenter) { TestInstrumenter.new }
122+
123+
before do
124+
client.config.instrumentation = instrumenter
125+
stub_request(:get, %r{instrumented}).to_return(status: 429, headers: {retry_after: 1}).to_return(status: 200)
126+
end
127+
128+
it "instruments retry attempts with correct payload" do
129+
client.connection.get("instrumented")
130+
131+
retry_events = instrumenter.find_events("zendesk.retry")
132+
expect(retry_events.size).to eq(1)
133+
134+
event = retry_events.first[:payload]
135+
expect(event[:attempt]).to eq(1)
136+
expect(event[:endpoint]).to eq("/api/v2/instrumented")
137+
expect(event[:method]).to eq(:get)
138+
expect(event[:reason]).to eq("rate_limited")
139+
expect(event[:delay]).to be >= 0
140+
end
141+
142+
it "does not instrument when no retry occurs" do
143+
stub_request(:get, %r{no_retry}).to_return(status: 200)
144+
145+
client.connection.get("no_retry")
146+
147+
retry_events = instrumenter.find_events("zendesk.retry")
148+
expect(retry_events).to be_empty
149+
end
150+
end
119151
end

0 commit comments

Comments
 (0)