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.
@@ -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
@@ -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
@@ -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 #{MAX_BATCH_SIZE * 10})" if records.size > MAX_BATCH_SIZE * 10
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
- request(:post, "objects/#{object}/records/query", params)
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Attio
4
- VERSION = "0.3.0"
4
+ VERSION = "0.4.0"
5
5
  end