attio 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,424 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "logger"
5
+
6
+ module Attio
7
+ # Observability and instrumentation for Attio client
8
+ #
9
+ # @example Basic usage
10
+ # client.instrumentation = Attio::Observability.new(
11
+ # logger: Rails.logger,
12
+ # metrics_backend: :datadog
13
+ # )
14
+ module Observability
15
+ # Base instrumentation class
16
+ class Instrumentation
17
+ attr_reader :logger, :metrics, :traces
18
+
19
+ def initialize(logger: nil, metrics_backend: nil, trace_backend: nil)
20
+ @logger = logger || Logger.new($stdout)
21
+ @metrics = Metrics.for(metrics_backend) if metrics_backend
22
+ @traces = Traces.for(trace_backend) if trace_backend
23
+ @enabled = true
24
+ end
25
+
26
+ # Record an API call
27
+ #
28
+ # @param method [Symbol] HTTP method
29
+ # @param path [String] API path
30
+ # @param duration [Float] Call duration in seconds
31
+ # @param status [Integer] HTTP status code
32
+ # @param error [Exception] Error if any
33
+ def record_api_call(method:, path:, duration:, status: nil, error: nil)
34
+ return unless @enabled
35
+
36
+ log_api_call(method, path, duration, status, error)
37
+ record_api_metrics(method, path, duration, error) if @metrics
38
+ record_api_trace(method, path, status, error) if @traces
39
+ end
40
+
41
+ # Record rate limit information
42
+ #
43
+ # @param remaining [Integer] Requests remaining
44
+ # @param limit [Integer] Rate limit
45
+ # @param reset_at [Time] When limit resets
46
+ def record_rate_limit(remaining:, limit:, reset_at:)
47
+ return unless @enabled
48
+
49
+ utilization = 1.0 - (remaining.to_f / limit)
50
+
51
+ @logger.debug(format_log_entry(
52
+ event: "rate_limit",
53
+ remaining: remaining,
54
+ limit: limit,
55
+ utilization: utilization.round(3),
56
+ reset_in: (reset_at - Time.now).to_i
57
+ ))
58
+
59
+ @metrics&.gauge("attio.rate_limit.remaining", remaining)
60
+ @metrics&.gauge("attio.rate_limit.utilization", utilization)
61
+ end
62
+
63
+ # Record cache hit/miss
64
+ #
65
+ # @param key [String] Cache key
66
+ # @param hit [Boolean] Whether it was a hit
67
+ def record_cache(key:, hit:)
68
+ return unless @enabled
69
+
70
+ @logger.debug(format_log_entry(
71
+ event: "cache",
72
+ key: key,
73
+ hit: hit
74
+ ))
75
+
76
+ @metrics&.increment("attio.cache.#{hit ? 'hits' : 'misses'}")
77
+ end
78
+
79
+ # Record circuit breaker state change
80
+ #
81
+ # @param endpoint [String] Endpoint name
82
+ # @param old_state [Symbol] Previous state
83
+ # @param new_state [Symbol] New state
84
+ def record_circuit_breaker(endpoint:, old_state:, new_state:)
85
+ return unless @enabled
86
+
87
+ @logger.warn(format_log_entry(
88
+ event: "circuit_breaker",
89
+ endpoint: endpoint,
90
+ old_state: old_state,
91
+ new_state: new_state
92
+ ))
93
+
94
+ @metrics&.increment("attio.circuit_breaker.transitions", tags: {
95
+ from: old_state,
96
+ to: new_state,
97
+ })
98
+ end
99
+
100
+ # Record connection pool stats
101
+ #
102
+ # @param stats [Hash] Pool statistics
103
+ def record_pool_stats(stats)
104
+ return unless @enabled
105
+
106
+ @metrics&.gauge("attio.pool.size", stats[:size])
107
+ @metrics&.gauge("attio.pool.available", stats[:available])
108
+ @metrics&.gauge("attio.pool.allocated", stats[:allocated])
109
+ @metrics&.gauge("attio.pool.utilization", stats[:allocated].to_f / stats[:size])
110
+ end
111
+
112
+ # Disable instrumentation
113
+ def disable!
114
+ @enabled = false
115
+ end
116
+
117
+ # Enable instrumentation
118
+ def enable!
119
+ @enabled = true
120
+ end
121
+
122
+ private def format_log_entry(**fields)
123
+ fields[:timestamp] = Time.now.iso8601
124
+ fields[:service] = "attio-ruby"
125
+ JSON.generate(fields)
126
+ end
127
+
128
+ private def sanitize_path(path)
129
+ # Remove IDs from paths for metric aggregation
130
+ path.gsub(%r{/[a-f0-9-]{36}}, "/:id") # UUIDs
131
+ .gsub(%r{/[a-zA-Z]+-\d+-[a-zA-Z]+}, "/:id") # IDs like abc-123-def
132
+ .gsub(%r{/\d+}, "/:id") # Numeric IDs
133
+ end
134
+
135
+ private def log_api_call(method, path, duration, status, error)
136
+ @logger.info(format_log_entry(
137
+ event: "api_call",
138
+ method: method,
139
+ path: path,
140
+ duration_ms: (duration * 1000).round(2),
141
+ status: status,
142
+ error: error&.class&.name
143
+ ))
144
+ end
145
+
146
+ private def record_api_metrics(method, path, duration, error)
147
+ @metrics.increment("attio.api.calls", tags: { method: method, path: sanitize_path(path) })
148
+ @metrics.histogram("attio.api.duration", duration * 1000, tags: { method: method })
149
+
150
+ return unless error
151
+
152
+ @metrics.increment("attio.api.errors", tags: {
153
+ method: method,
154
+ error_class: error.class.name,
155
+ })
156
+ end
157
+
158
+ private def record_api_trace(method, path, status, error)
159
+ @traces.span("attio.api.call") do |span|
160
+ span.set_attribute("http.method", method.to_s)
161
+ span.set_attribute("http.path", path)
162
+ span.set_attribute("http.status_code", status) if status
163
+ span.set_attribute("error", true) if error
164
+ end
165
+ end
166
+ end
167
+
168
+ # Metrics backends
169
+ module Metrics
170
+ def self.for(backend)
171
+ case backend
172
+ when :datadog then Datadog.new
173
+ when :statsd then StatsD.new
174
+ when :prometheus then Prometheus.new
175
+ when :memory then Memory.new
176
+ else
177
+ raise ArgumentError, "Unknown metrics backend: #{backend}"
178
+ end
179
+ end
180
+
181
+ # In-memory metrics for testing
182
+ class Memory
183
+ attr_reader :counters, :gauges, :histograms
184
+
185
+ def initialize
186
+ @counters = Hash.new(0)
187
+ @gauges = {}
188
+ @histograms = Hash.new { |h, k| h[k] = [] }
189
+ end
190
+
191
+ def increment(metric, tags: {})
192
+ key = tags.empty? ? "#{metric}:" : "#{metric}:#{format_tags(tags)}"
193
+ @counters[key] += 1
194
+ end
195
+
196
+ def gauge(metric, value, tags: {})
197
+ key = tags.empty? ? "#{metric}:" : "#{metric}:#{format_tags(tags)}"
198
+ @gauges[key] = value
199
+ end
200
+
201
+ def histogram(metric, value, tags: {})
202
+ key = tags.empty? ? "#{metric}:" : "#{metric}:#{format_tags(tags)}"
203
+ @histograms[key] << value
204
+ end
205
+
206
+ def reset!
207
+ @counters.clear
208
+ @gauges.clear
209
+ @histograms.clear
210
+ end
211
+
212
+ private def format_tags(tags)
213
+ # Format tags in a consistent way that works across Ruby versions
214
+ sorted = tags.sort.to_h
215
+ content = sorted.map { |k, v| ":#{k}=>#{v.inspect}" }.join(", ")
216
+ "{#{content}}"
217
+ end
218
+ end
219
+
220
+ # StatsD metrics
221
+ class StatsD
222
+ def initialize
223
+ require "statsd-ruby"
224
+ @client = ::Statsd.new("localhost", 8125)
225
+ rescue LoadError
226
+ raise "Please add 'statsd-ruby' to your Gemfile"
227
+ end
228
+
229
+ def increment(metric, tags: {})
230
+ @client.increment(metric, tags: format_tags(tags))
231
+ end
232
+
233
+ def gauge(metric, value, tags: {})
234
+ @client.gauge(metric, value, tags: format_tags(tags))
235
+ end
236
+
237
+ def histogram(metric, value, tags: {})
238
+ @client.histogram(metric, value, tags: format_tags(tags))
239
+ end
240
+
241
+ private def format_tags(tags)
242
+ tags.map { |k, v| "#{k}:#{v}" }
243
+ end
244
+ end
245
+
246
+ # Datadog metrics
247
+ class Datadog
248
+ def initialize
249
+ require "datadog/statsd"
250
+ @client = ::Datadog::Statsd.new("localhost", 8125)
251
+ rescue LoadError
252
+ raise "Please add 'dogstatsd-ruby' to your Gemfile"
253
+ end
254
+
255
+ def increment(metric, tags: {})
256
+ @client.increment(metric, tags: format_tags(tags))
257
+ end
258
+
259
+ def gauge(metric, value, tags: {})
260
+ @client.gauge(metric, value, tags: format_tags(tags))
261
+ end
262
+
263
+ def histogram(metric, value, tags: {})
264
+ @client.histogram(metric, value, tags: format_tags(tags))
265
+ end
266
+
267
+ private def format_tags(tags)
268
+ tags.map { |k, v| "#{k}:#{v}" }
269
+ end
270
+ end
271
+
272
+ # Prometheus metrics
273
+ class Prometheus
274
+ def initialize
275
+ require "prometheus/client"
276
+ @registry = ::Prometheus::Client.registry
277
+ @counters = {}
278
+ @gauges = {}
279
+ @histograms = {}
280
+ rescue LoadError
281
+ raise "Please add 'prometheus-client' to your Gemfile"
282
+ end
283
+
284
+ def increment(metric, tags: {})
285
+ counter = @counters[metric] ||= @registry.counter(
286
+ metric.to_sym,
287
+ docstring: "Counter for #{metric}",
288
+ labels: tags.keys
289
+ )
290
+ counter.increment(labels: tags)
291
+ end
292
+
293
+ def gauge(metric, value, tags: {})
294
+ gauge = @gauges[metric] ||= @registry.gauge(
295
+ metric.to_sym,
296
+ docstring: "Gauge for #{metric}",
297
+ labels: tags.keys
298
+ )
299
+ gauge.set(value, labels: tags)
300
+ end
301
+
302
+ def histogram(metric, value, tags: {})
303
+ histogram = @histograms[metric] ||= @registry.histogram(
304
+ metric.to_sym,
305
+ docstring: "Histogram for #{metric}",
306
+ labels: tags.keys
307
+ )
308
+ histogram.observe(value, labels: tags)
309
+ end
310
+ end
311
+ end
312
+
313
+ # Tracing backends
314
+ module Traces
315
+ def self.for(backend)
316
+ case backend
317
+ when :opentelemetry then OpenTelemetry.new
318
+ when :datadog then DatadogAPM.new
319
+ when :memory then Memory.new
320
+ else
321
+ raise ArgumentError, "Unknown trace backend: #{backend}"
322
+ end
323
+ end
324
+
325
+ # OpenTelemetry tracing
326
+ class OpenTelemetry
327
+ def initialize
328
+ require "opentelemetry-sdk"
329
+ @tracer = ::OpenTelemetry.tracer_provider.tracer("attio-ruby")
330
+ rescue LoadError
331
+ raise "Please add 'opentelemetry-sdk' to your Gemfile"
332
+ end
333
+
334
+ def span(name, &block)
335
+ @tracer.in_span(name, &block)
336
+ end
337
+ end
338
+
339
+ # Datadog APM tracing
340
+ class DatadogAPM
341
+ def initialize
342
+ require "datadog"
343
+ @tracer = ::Datadog::Tracing
344
+ rescue LoadError
345
+ raise "Please add 'datadog' to your Gemfile"
346
+ end
347
+
348
+ def span(name)
349
+ @tracer.trace(name) do |span|
350
+ yield(span) if block_given?
351
+ end
352
+ end
353
+ end
354
+
355
+ # In-memory tracing for testing
356
+ class Memory
357
+ attr_reader :spans
358
+
359
+ def initialize
360
+ @spans = []
361
+ end
362
+
363
+ def span(name)
364
+ span = Span.new(name)
365
+ @spans << span
366
+ yield(span) if block_given?
367
+ span
368
+ end
369
+
370
+ def reset!
371
+ @spans.clear
372
+ end
373
+
374
+ class Span
375
+ attr_reader :name, :attributes
376
+
377
+ def initialize(name)
378
+ @name = name
379
+ @attributes = {}
380
+ end
381
+
382
+ def set_attribute(key, value)
383
+ @attributes[key] = value
384
+ end
385
+ end
386
+ end
387
+ end
388
+
389
+ # Middleware for automatic instrumentation
390
+ class Middleware
391
+ def initialize(app, instrumentation)
392
+ @app = app
393
+ @instrumentation = instrumentation
394
+ end
395
+
396
+ def call(env)
397
+ start_time = Time.now
398
+ method = env[:method]
399
+ path = env[:url].path
400
+
401
+ begin
402
+ response = @app.call(env)
403
+
404
+ @instrumentation.record_api_call(
405
+ method: method,
406
+ path: path,
407
+ duration: Time.now - start_time,
408
+ status: response.status
409
+ )
410
+
411
+ response
412
+ rescue StandardError => e
413
+ @instrumentation.record_api_call(
414
+ method: method,
415
+ path: path,
416
+ duration: Time.now - start_time,
417
+ error: e
418
+ )
419
+ raise
420
+ end
421
+ end
422
+ end
423
+ end
424
+ end
@@ -0,0 +1,212 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Attio
4
+ # Rate limiter with intelligent retry and backoff strategies
5
+ #
6
+ # @example Using the rate limiter
7
+ # limiter = Attio::RateLimiter.new(
8
+ # max_requests: 100,
9
+ # window_seconds: 60,
10
+ # max_retries: 3
11
+ # )
12
+ #
13
+ # limiter.execute { client.records.list }
14
+ class RateLimiter
15
+ attr_reader :max_requests, :window_seconds, :max_retries
16
+ attr_accessor :current_limit, :remaining, :reset_at
17
+
18
+ # Initialize a new rate limiter
19
+ #
20
+ # @param max_requests [Integer] Maximum requests per window
21
+ # @param window_seconds [Integer] Time window in seconds
22
+ # @param max_retries [Integer] Maximum retry attempts
23
+ # @param enable_jitter [Boolean] Add jitter to backoff delays
24
+ def initialize(max_requests: 1000, window_seconds: 3600, max_retries: 3, enable_jitter: true)
25
+ @max_requests = max_requests
26
+ @window_seconds = window_seconds
27
+ @max_retries = max_retries
28
+ @enable_jitter = enable_jitter
29
+
30
+ @current_limit = max_requests
31
+ @remaining = max_requests
32
+ @reset_at = Time.now + window_seconds
33
+
34
+ @mutex = Mutex.new
35
+ @request_queue = []
36
+ @request_times = []
37
+ end
38
+
39
+ # Execute a block with rate limiting
40
+ #
41
+ # @yield The block to execute
42
+ # @return The result of the block
43
+ def execute
44
+ raise ArgumentError, "Block required" unless block_given?
45
+
46
+ @mutex.synchronize do
47
+ wait_if_needed
48
+ track_request
49
+ end
50
+
51
+ attempt = 0
52
+ begin
53
+ result = yield
54
+ # Thread-safe header update
55
+ @mutex.synchronize do
56
+ update_from_headers(result) if result.is_a?(Hash) && result["_headers"]
57
+ end
58
+ result
59
+ rescue Attio::RateLimitError => e
60
+ attempt += 1
61
+ raise e unless attempt <= @max_retries
62
+
63
+ wait_time = calculate_backoff(attempt, e)
64
+ sleep(wait_time)
65
+ retry
66
+ end
67
+ end
68
+
69
+ # Check if rate limit is exceeded
70
+ #
71
+ # @return [Boolean] True if rate limit would be exceeded
72
+ def rate_limited?
73
+ @mutex.synchronize do
74
+ cleanup_old_requests
75
+ @request_times.size >= @max_requests
76
+ end
77
+ end
78
+
79
+ # Get current rate limit status
80
+ #
81
+ # @return [Hash] Current status
82
+ def status
83
+ @mutex.synchronize do
84
+ cleanup_old_requests
85
+ {
86
+ limit: @current_limit,
87
+ remaining: [@remaining, @max_requests - @request_times.size].min,
88
+ reset_at: @reset_at,
89
+ reset_in: [@reset_at - Time.now, 0].max.to_i,
90
+ current_usage: @request_times.size,
91
+ }
92
+ end
93
+ end
94
+
95
+ # Update rate limit info from response headers
96
+ # NOTE: This method should be called within a mutex lock
97
+ #
98
+ # @param response [Hash] Response containing headers
99
+ private def update_from_headers(response)
100
+ return unless response.is_a?(Hash)
101
+
102
+ headers = response["_headers"] || {}
103
+
104
+ @current_limit = headers["x-ratelimit-limit"].to_i if headers["x-ratelimit-limit"]
105
+ @remaining = headers["x-ratelimit-remaining"].to_i if headers["x-ratelimit-remaining"]
106
+ @reset_at = Time.at(headers["x-ratelimit-reset"].to_i) if headers["x-ratelimit-reset"]
107
+ end
108
+
109
+ # Reset the rate limiter
110
+ def reset!
111
+ @mutex.synchronize do
112
+ @request_times.clear
113
+ @remaining = @max_requests
114
+ @reset_at = Time.now + @window_seconds
115
+ end
116
+ end
117
+
118
+ # Queue a request for later execution
119
+ #
120
+ # @param priority [Integer] Priority (lower = higher priority)
121
+ # @yield Block to execute
122
+ def queue_request(priority: 5, &block)
123
+ @mutex.synchronize do
124
+ @request_queue << { priority: priority, block: block, queued_at: Time.now }
125
+ @request_queue.sort_by! { |r| [r[:priority], r[:queued_at]] }
126
+ end
127
+ end
128
+
129
+ # Process queued requests
130
+ #
131
+ # @param max_per_batch [Integer] Maximum requests to process
132
+ # @return [Array] Results from processed requests
133
+ def process_queue(max_per_batch: 10)
134
+ results = []
135
+ processed = 0
136
+
137
+ while processed < max_per_batch
138
+ request = @mutex.synchronize { @request_queue.shift }
139
+ break unless request
140
+
141
+ begin
142
+ result = execute(&request[:block])
143
+ results << { success: true, result: result }
144
+ rescue StandardError => e
145
+ results << { success: false, error: e }
146
+ end
147
+
148
+ processed += 1
149
+ end
150
+
151
+ results
152
+ end
153
+
154
+ private def wait_if_needed
155
+ cleanup_old_requests
156
+
157
+ if @request_times.size >= @max_requests
158
+ wait_time = @request_times.first + @window_seconds - Time.now
159
+ if wait_time > 0
160
+ sleep(wait_time)
161
+ cleanup_old_requests
162
+ end
163
+ end
164
+
165
+ return unless @remaining <= 0 && @reset_at > Time.now
166
+
167
+ wait_time = @reset_at - Time.now
168
+ sleep(wait_time) if wait_time > 0
169
+ end
170
+
171
+ private def track_request
172
+ @request_times << Time.now
173
+ @remaining = [@remaining - 1, 0].max
174
+ end
175
+
176
+ private def cleanup_old_requests
177
+ cutoff = Time.now - @window_seconds
178
+ @request_times.reject! { |time| time < cutoff }
179
+ end
180
+
181
+ private def calculate_backoff(attempt, error = nil)
182
+ base_wait = 2**attempt
183
+
184
+ # Use server-provided retry-after if available
185
+ base_wait = error.retry_after if error && error.respond_to?(:retry_after) && error.retry_after
186
+
187
+ # Add jitter to prevent thundering herd
188
+ if @enable_jitter
189
+ jitter = rand * base_wait * 0.1
190
+ base_wait + jitter
191
+ else
192
+ base_wait
193
+ end
194
+ end
195
+ end
196
+
197
+ # Middleware for automatic rate limiting
198
+ class RateLimitMiddleware
199
+ def initialize(app, rate_limiter)
200
+ @app = app
201
+ @rate_limiter = rate_limiter
202
+ end
203
+
204
+ def call(env)
205
+ @rate_limiter.execute do
206
+ response = @app.call(env)
207
+ # Headers are automatically updated within execute block
208
+ response
209
+ end
210
+ end
211
+ end
212
+ end