attio 0.3.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.
- checksums.yaml +4 -4
- data/.gitignore +1 -0
- data/CHANGELOG.md +35 -2
- data/CLAUDE.md +35 -4
- data/Gemfile.lock +1 -1
- data/README.md +234 -31
- data/lib/attio/circuit_breaker.rb +299 -0
- data/lib/attio/client.rb +2 -10
- data/lib/attio/connection_pool.rb +190 -35
- data/lib/attio/enhanced_client.rb +257 -0
- data/lib/attio/http_client.rb +54 -3
- data/lib/attio/observability.rb +424 -0
- data/lib/attio/resources/base.rb +53 -0
- data/lib/attio/resources/bulk.rb +1 -1
- data/lib/attio/resources/records.rb +29 -2
- data/lib/attio/version.rb +1 -1
- data/lib/attio/webhooks.rb +220 -0
- data/lib/attio.rb +8 -1
- metadata +5 -2
- data/lib/attio/resources/meta.rb +0 -72
@@ -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
|
data/lib/attio/resources/base.rb
CHANGED
@@ -104,6 +104,59 @@ module Attio
|
|
104
104
|
private def handle_delete_request(connection, path, params)
|
105
105
|
params.empty? ? connection.delete(path) : connection.delete(path, params)
|
106
106
|
end
|
107
|
+
|
108
|
+
# Paginate through all results for a given endpoint
|
109
|
+
# @param path [String] The API endpoint path
|
110
|
+
# @param params [Hash] Query parameters including filters
|
111
|
+
# @param page_size [Integer] Number of items per page (default: 50)
|
112
|
+
# @return [Enumerator] Yields each item from all pages
|
113
|
+
private def paginate(path, params = {}, page_size: 50)
|
114
|
+
Enumerator.new do |yielder|
|
115
|
+
offset = 0
|
116
|
+
loop do
|
117
|
+
page_params = params.merge(limit: page_size, offset: offset)
|
118
|
+
# Use POST for query endpoints, GET for others
|
119
|
+
method = path.end_with?("/query") ? :post : :get
|
120
|
+
response = request(method, path, page_params)
|
121
|
+
|
122
|
+
data = response["data"] || []
|
123
|
+
data.each { |item| yielder << item }
|
124
|
+
|
125
|
+
# Stop if we got fewer items than requested (last page)
|
126
|
+
break if data.size < page_size
|
127
|
+
|
128
|
+
offset += page_size
|
129
|
+
end
|
130
|
+
end
|
131
|
+
end
|
132
|
+
|
133
|
+
# Build query parameters with filtering and sorting support
|
134
|
+
# @param options [Hash] Options including filter, sort, limit, offset
|
135
|
+
# @return [Hash] Formatted query parameters
|
136
|
+
private def build_query_params(options = {})
|
137
|
+
params = {}
|
138
|
+
|
139
|
+
# Add filtering
|
140
|
+
add_filter_param(params, options[:filter]) if options[:filter]
|
141
|
+
|
142
|
+
# Add standard parameters
|
143
|
+
%i[sort limit offset].each do |key|
|
144
|
+
params[key] = options[key] if options[key]
|
145
|
+
end
|
146
|
+
|
147
|
+
# Add any other parameters
|
148
|
+
options.each do |key, value|
|
149
|
+
next if %i[filter sort limit offset].include?(key)
|
150
|
+
|
151
|
+
params[key] = value
|
152
|
+
end
|
153
|
+
|
154
|
+
params
|
155
|
+
end
|
156
|
+
|
157
|
+
private def add_filter_param(params, filter)
|
158
|
+
params[:filter] = filter.is_a?(String) ? filter : filter.to_json
|
159
|
+
end
|
107
160
|
end
|
108
161
|
end
|
109
162
|
end
|
data/lib/attio/resources/bulk.rb
CHANGED
@@ -196,7 +196,7 @@ module Attio
|
|
196
196
|
raise ArgumentError, "Records array is required for bulk #{operation}" if records.nil?
|
197
197
|
raise ArgumentError, "Records must be an array for bulk #{operation}" unless records.is_a?(Array)
|
198
198
|
raise ArgumentError, "Records array cannot be empty for bulk #{operation}" if records.empty?
|
199
|
-
raise ArgumentError, "Too many records (max
|
199
|
+
raise ArgumentError, "Too many records (max 1000)" if records.size > MAX_BATCH_SIZE * 10
|
200
200
|
|
201
201
|
records.each_with_index do |record, index|
|
202
202
|
raise ArgumentError, "Record at index #{index} must be a hash" unless record.is_a?(Hash)
|
@@ -44,9 +44,36 @@ module Attio
|
|
44
44
|
# filters: { name: { contains: 'John' } },
|
45
45
|
# limit: 50
|
46
46
|
# )
|
47
|
-
def list(object:, **params)
|
47
|
+
def list(object:, filter: nil, sort: nil, limit: nil, offset: nil, **params)
|
48
48
|
validate_required_string!(object, "Object type")
|
49
|
-
|
49
|
+
|
50
|
+
# Build query parameters with filtering and sorting support
|
51
|
+
query_params = build_query_params({
|
52
|
+
filter: filter,
|
53
|
+
sort: sort,
|
54
|
+
limit: limit,
|
55
|
+
offset: offset,
|
56
|
+
**params,
|
57
|
+
})
|
58
|
+
|
59
|
+
request(:post, "objects/#{object}/records/query", query_params)
|
60
|
+
end
|
61
|
+
|
62
|
+
# List all records with automatic pagination
|
63
|
+
# @param object [String] The object type to query
|
64
|
+
# @param filter [Hash] Filtering criteria
|
65
|
+
# @param sort [String] Sorting option
|
66
|
+
# @param page_size [Integer] Number of records per page
|
67
|
+
# @return [Enumerator] Enumerator that yields each record
|
68
|
+
def list_all(object:, filter: nil, sort: nil, page_size: 50)
|
69
|
+
validate_required_string!(object, "Object type")
|
70
|
+
|
71
|
+
query_params = build_query_params({
|
72
|
+
filter: filter,
|
73
|
+
sort: sort,
|
74
|
+
})
|
75
|
+
|
76
|
+
paginate("objects/#{object}/records/query", query_params, page_size: page_size)
|
50
77
|
end
|
51
78
|
|
52
79
|
# Retrieve a specific record by ID.
|
data/lib/attio/version.rb
CHANGED