attio 0.3.0 → 0.5.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
@@ -9,6 +9,20 @@ module Attio
9
9
  #
10
10
  # @example Listing attributes
11
11
  # client.attributes.list(object: "people")
12
+ #
13
+ # @example Creating a custom attribute
14
+ # client.attributes.create(
15
+ # object: "deals",
16
+ # data: {
17
+ # title: "Deal Stage",
18
+ # api_slug: "deal_stage",
19
+ # type: "select",
20
+ # options: [
21
+ # { title: "Lead", value: "lead" },
22
+ # { title: "Qualified", value: "qualified" }
23
+ # ]
24
+ # }
25
+ # )
12
26
  class Attributes < Base
13
27
  def list(object:, **params)
14
28
  validate_object!(object)
@@ -21,6 +35,228 @@ module Attio
21
35
  request(:get, "objects/#{object}/attributes/#{id_or_slug}")
22
36
  end
23
37
 
38
+ # Create a custom attribute for an object
39
+ #
40
+ # @param object [String] The object type or slug
41
+ # @param data [Hash] The attribute configuration
42
+ # @option data [String] :title The display title of the attribute
43
+ # @option data [String] :api_slug The API slug for the attribute
44
+ # @option data [String] :type The attribute type (text, number, select, date, etc.)
45
+ # @option data [String] :description Optional description
46
+ # @option data [Boolean] :is_required Whether the attribute is required
47
+ # @option data [Boolean] :is_unique Whether the attribute must be unique
48
+ # @option data [Boolean] :is_multiselect For select types, whether multiple values are allowed
49
+ # @option data [Array<Hash>] :options For select types, the available options
50
+ # @return [Hash] The created attribute
51
+ # @example Create a select attribute
52
+ # client.attributes.create(
53
+ # object: "trips",
54
+ # data: {
55
+ # title: "Status",
56
+ # api_slug: "status",
57
+ # type: "select",
58
+ # options: [
59
+ # { title: "Pending", value: "pending" },
60
+ # { title: "Active", value: "active" }
61
+ # ]
62
+ # }
63
+ # )
64
+ def create(object:, data:)
65
+ validate_object!(object)
66
+ validate_required_hash!(data, "Attribute data")
67
+
68
+ # Wrap data in the expected format
69
+ payload = { data: data }
70
+
71
+ request(:post, "objects/#{object}/attributes", payload)
72
+ end
73
+
74
+ # Update an existing attribute
75
+ #
76
+ # @param object [String] The object type or slug
77
+ # @param id_or_slug [String] The attribute ID or API slug
78
+ # @param data [Hash] The attribute configuration updates
79
+ # @option data [String] :title The display title of the attribute
80
+ # @option data [String] :api_slug The API slug for the attribute
81
+ # @option data [String] :description Optional description
82
+ # @option data [Boolean] :is_required Whether the attribute is required
83
+ # @option data [Boolean] :is_unique Whether the attribute must be unique
84
+ # @option data [Boolean] :is_multiselect For select types, whether multiple values are allowed
85
+ # @return [Hash] The updated attribute
86
+ # @example Update an attribute's title
87
+ # client.attributes.update(
88
+ # object: "contacts",
89
+ # id_or_slug: "status",
90
+ # data: {
91
+ # title: "Contact Status",
92
+ # description: "The current status of the contact"
93
+ # }
94
+ # )
95
+ def update(object:, id_or_slug:, data:)
96
+ validate_object!(object)
97
+ validate_id_or_slug!(id_or_slug)
98
+ validate_required_hash!(data, "Attribute data")
99
+
100
+ # Wrap data in the expected format
101
+ payload = { data: data }
102
+
103
+ request(:patch, "objects/#{object}/attributes/#{id_or_slug}", payload)
104
+ end
105
+
106
+ # List options for a select attribute
107
+ #
108
+ # @param object [String] The object type or slug
109
+ # @param id_or_slug [String] The attribute ID or API slug
110
+ # @param params [Hash] Additional query parameters
111
+ # @option params [Integer] :limit Number of results to return
112
+ # @option params [Integer] :offset Number of results to skip
113
+ # @return [Hash] The list of attribute options
114
+ # @example List options for a select attribute
115
+ # client.attributes.list_options(
116
+ # object: "deals",
117
+ # id_or_slug: "deal_stage"
118
+ # )
119
+ def list_options(object:, id_or_slug:, **params)
120
+ validate_object!(object)
121
+ validate_id_or_slug!(id_or_slug)
122
+ request(:get, "objects/#{object}/attributes/#{id_or_slug}/options", params)
123
+ end
124
+
125
+ # Create a new option for a select attribute
126
+ #
127
+ # @param object [String] The object type or slug
128
+ # @param id_or_slug [String] The attribute ID or API slug
129
+ # @param data [Hash] The option configuration
130
+ # @option data [String] :title The display title of the option
131
+ # @option data [String] :value The value of the option
132
+ # @option data [String] :color Optional color for the option
133
+ # @option data [Integer] :order Optional order for the option
134
+ # @return [Hash] The created option
135
+ # @example Create a new option
136
+ # client.attributes.create_option(
137
+ # object: "deals",
138
+ # id_or_slug: "deal_stage",
139
+ # data: {
140
+ # title: "Negotiation",
141
+ # value: "negotiation",
142
+ # color: "blue"
143
+ # }
144
+ # )
145
+ def create_option(object:, id_or_slug:, data:)
146
+ validate_object!(object)
147
+ validate_id_or_slug!(id_or_slug)
148
+ validate_required_hash!(data, "Option data")
149
+
150
+ request(:post, "objects/#{object}/attributes/#{id_or_slug}/options", data)
151
+ end
152
+
153
+ # Update an option for a select attribute
154
+ #
155
+ # @param object [String] The object type or slug
156
+ # @param id_or_slug [String] The attribute ID or API slug
157
+ # @param option [String] The option ID or value
158
+ # @param data [Hash] The option configuration updates
159
+ # @option data [String] :title The display title of the option
160
+ # @option data [String] :value The value of the option
161
+ # @option data [String] :color Optional color for the option
162
+ # @option data [Integer] :order Optional order for the option
163
+ # @return [Hash] The updated option
164
+ # @example Update an option's title
165
+ # client.attributes.update_option(
166
+ # object: "deals",
167
+ # id_or_slug: "deal_stage",
168
+ # option: "negotiation",
169
+ # data: {
170
+ # title: "In Negotiation",
171
+ # color: "orange"
172
+ # }
173
+ # )
174
+ def update_option(object:, id_or_slug:, option:, data:)
175
+ validate_object!(object)
176
+ validate_id_or_slug!(id_or_slug)
177
+ validate_option_id!(option)
178
+ validate_required_hash!(data, "Option data")
179
+
180
+ request(:patch, "objects/#{object}/attributes/#{id_or_slug}/options/#{option}", data)
181
+ end
182
+
183
+ # List statuses for a status attribute
184
+ #
185
+ # @param object [String] The object type or slug
186
+ # @param id_or_slug [String] The attribute ID or API slug
187
+ # @param params [Hash] Additional query parameters
188
+ # @option params [Integer] :limit Number of results to return
189
+ # @option params [Integer] :offset Number of results to skip
190
+ # @return [Hash] The list of attribute statuses
191
+ # @example List statuses for a status attribute
192
+ # client.attributes.list_statuses(
193
+ # object: "deals",
194
+ # id_or_slug: "deal_status"
195
+ # )
196
+ def list_statuses(object:, id_or_slug:, **params)
197
+ validate_object!(object)
198
+ validate_id_or_slug!(id_or_slug)
199
+ request(:get, "objects/#{object}/attributes/#{id_or_slug}/statuses", params)
200
+ end
201
+
202
+ # Create a new status for a status attribute
203
+ #
204
+ # @param object [String] The object type or slug
205
+ # @param id_or_slug [String] The attribute ID or API slug
206
+ # @param data [Hash] The status configuration
207
+ # @option data [String] :title The display title of the status
208
+ # @option data [String] :value The value of the status
209
+ # @option data [String] :color Optional color for the status
210
+ # @option data [Integer] :order Optional order for the status
211
+ # @return [Hash] The created status
212
+ # @example Create a new status
213
+ # client.attributes.create_status(
214
+ # object: "deals",
215
+ # id_or_slug: "deal_status",
216
+ # data: {
217
+ # title: "Under Review",
218
+ # value: "under_review",
219
+ # color: "yellow"
220
+ # }
221
+ # )
222
+ def create_status(object:, id_or_slug:, data:)
223
+ validate_object!(object)
224
+ validate_id_or_slug!(id_or_slug)
225
+ validate_required_hash!(data, "Status data")
226
+
227
+ request(:post, "objects/#{object}/attributes/#{id_or_slug}/statuses", data)
228
+ end
229
+
230
+ # Update a status for a status attribute
231
+ #
232
+ # @param object [String] The object type or slug
233
+ # @param id_or_slug [String] The attribute ID or API slug
234
+ # @param status [String] The status ID or value
235
+ # @param data [Hash] The status configuration updates
236
+ # @option data [String] :title The display title of the status
237
+ # @option data [String] :value The value of the status
238
+ # @option data [String] :color Optional color for the status
239
+ # @option data [Integer] :order Optional order for the status
240
+ # @return [Hash] The updated status
241
+ # @example Update a status's title
242
+ # client.attributes.update_status(
243
+ # object: "deals",
244
+ # id_or_slug: "deal_status",
245
+ # status: "under_review",
246
+ # data: {
247
+ # title: "Pending Review",
248
+ # color: "orange"
249
+ # }
250
+ # )
251
+ def update_status(object:, id_or_slug:, status:, data:)
252
+ validate_object!(object)
253
+ validate_id_or_slug!(id_or_slug)
254
+ validate_status_id!(status)
255
+ validate_required_hash!(data, "Status data")
256
+
257
+ request(:patch, "objects/#{object}/attributes/#{id_or_slug}/statuses/#{status}", data)
258
+ end
259
+
24
260
  private def validate_object!(object)
25
261
  raise ArgumentError, "Object type is required" if object.nil? || object.to_s.strip.empty?
26
262
  end
@@ -28,6 +264,14 @@ module Attio
28
264
  private def validate_id_or_slug!(id_or_slug)
29
265
  raise ArgumentError, "Attribute ID or slug is required" if id_or_slug.nil? || id_or_slug.to_s.strip.empty?
30
266
  end
267
+
268
+ private def validate_option_id!(option)
269
+ raise ArgumentError, "Option ID is required" if option.nil? || option.to_s.strip.empty?
270
+ end
271
+
272
+ private def validate_status_id!(status)
273
+ raise ArgumentError, "Status ID is required" if status.nil? || status.to_s.strip.empty?
274
+ end
31
275
  end
32
276
  end
33
277
  end