dregs 0.1.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 +7 -0
- data/CHANGELOG.md +27 -0
- data/LICENSE +21 -0
- data/README.md +247 -0
- data/SECURITY.md +36 -0
- data/lib/dregs/client.rb +402 -0
- data/lib/dregs/errors.rb +143 -0
- data/lib/dregs/identities.rb +91 -0
- data/lib/dregs/models.rb +520 -0
- data/lib/dregs/version.rb +6 -0
- data/lib/dregs/webhooks.rb +144 -0
- data/lib/dregs.rb +38 -0
- data/sig/dregs.rbs +286 -0
- metadata +64 -0
data/lib/dregs/client.rb
ADDED
|
@@ -0,0 +1,402 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "net/http"
|
|
5
|
+
require "openssl"
|
|
6
|
+
require "securerandom"
|
|
7
|
+
require "timeout"
|
|
8
|
+
require "uri"
|
|
9
|
+
|
|
10
|
+
require_relative "errors"
|
|
11
|
+
require_relative "identities"
|
|
12
|
+
require_relative "models"
|
|
13
|
+
require_relative "version"
|
|
14
|
+
|
|
15
|
+
module Dregs
|
|
16
|
+
# A Dregs client.
|
|
17
|
+
#
|
|
18
|
+
# The secret key comes from the +DREGS_SECRET_KEY+ environment variable unless you pass one.
|
|
19
|
+
# Find it under *Settings -> Credentials* in the dashboard; it is the key starting +sk_+, not
|
|
20
|
+
# the +pk_+ public key the browser tracker uses.
|
|
21
|
+
#
|
|
22
|
+
# client = Dregs::Client.new
|
|
23
|
+
#
|
|
24
|
+
# client.track("user.signup", identity: "user_12345", data: { plan: "pro" })
|
|
25
|
+
#
|
|
26
|
+
# scores = client.identities.scores("user_12345")
|
|
27
|
+
#
|
|
28
|
+
# Build one at startup and keep it. The client holds no connection state of its own, so it is
|
|
29
|
+
# safe to share across threads and to call from a background job.
|
|
30
|
+
class Client
|
|
31
|
+
# Where requests go when neither an argument nor +DREGS_BASE_URL+ says otherwise.
|
|
32
|
+
DEFAULT_BASE_URL = "https://dregs.com/api"
|
|
33
|
+
|
|
34
|
+
# Seconds before a request is abandoned, for both connecting and reading.
|
|
35
|
+
DEFAULT_TIMEOUT = 10
|
|
36
|
+
|
|
37
|
+
# How many times a failed request is retried before its error is raised.
|
|
38
|
+
DEFAULT_MAX_RETRIES = 2
|
|
39
|
+
|
|
40
|
+
# The environment variable the secret key is read from.
|
|
41
|
+
SECRET_KEY_ENV = "DREGS_SECRET_KEY"
|
|
42
|
+
|
|
43
|
+
# The environment variable the base URL is read from.
|
|
44
|
+
BASE_URL_ENV = "DREGS_BASE_URL"
|
|
45
|
+
|
|
46
|
+
# Statuses worth another attempt. 429 and 5xx are transient by definition; 408 shows up in
|
|
47
|
+
# front of some proxies.
|
|
48
|
+
RETRY_STATUSES = [408, 429, 500, 502, 503, 504].freeze
|
|
49
|
+
|
|
50
|
+
# The longest this client will wait between attempts, however long +Retry-After+ asks for.
|
|
51
|
+
MAX_BACKOFF_SECONDS = 60.0
|
|
52
|
+
|
|
53
|
+
# Body-level statuses Dregs uses on +POST /api/events+.
|
|
54
|
+
STATUS_RATE_LIMITED = "rate_limited"
|
|
55
|
+
STATUS_QUOTA_EXCEEDED = "quota_exceeded"
|
|
56
|
+
|
|
57
|
+
# @return [String] the API root every request is built against, without a trailing slash
|
|
58
|
+
attr_reader :base_url
|
|
59
|
+
|
|
60
|
+
# @return [Integer] how many times a failed request is retried
|
|
61
|
+
attr_reader :max_retries
|
|
62
|
+
|
|
63
|
+
# @return [Numeric] seconds before a request is abandoned
|
|
64
|
+
attr_reader :timeout
|
|
65
|
+
|
|
66
|
+
# @return [Identities] read identities, their scores, and their analysis
|
|
67
|
+
attr_reader :identities
|
|
68
|
+
|
|
69
|
+
# @param secret_key [String, nil] your credential's secret key; defaults to +$DREGS_SECRET_KEY+
|
|
70
|
+
# @param base_url [String, nil] the API root; defaults to +$DREGS_BASE_URL+, then
|
|
71
|
+
# {DEFAULT_BASE_URL}
|
|
72
|
+
# @param timeout [Numeric] seconds before a request is abandoned, applied to connecting,
|
|
73
|
+
# writing, and reading alike
|
|
74
|
+
# @param max_retries [Integer] how many times to retry a failed request. Retries cover
|
|
75
|
+
# connection failures, timeouts, 429s, and 5xx, with exponential backoff and jitter; the
|
|
76
|
+
# +Retry-After+ header wins when the server sends one. Pass 0 to handle it yourself.
|
|
77
|
+
# @raise [ArgumentError] when no secret key is available, when the key is a +pk_+ public key,
|
|
78
|
+
# or when +max_retries+ is negative
|
|
79
|
+
def initialize(secret_key: nil, base_url: nil, timeout: DEFAULT_TIMEOUT, max_retries: DEFAULT_MAX_RETRIES)
|
|
80
|
+
resolved_key = secret_key || ENV.fetch(SECRET_KEY_ENV, nil)
|
|
81
|
+
|
|
82
|
+
raise ArgumentError, missing_key_message if resolved_key.nil? || resolved_key.empty?
|
|
83
|
+
raise ArgumentError, public_key_message if resolved_key.start_with?("pk_")
|
|
84
|
+
raise ArgumentError, "max_retries cannot be negative." if max_retries.negative?
|
|
85
|
+
|
|
86
|
+
@secret_key = resolved_key
|
|
87
|
+
@base_url = (base_url || ENV.fetch(BASE_URL_ENV, nil) || DEFAULT_BASE_URL).sub(%r{/+\z}, "")
|
|
88
|
+
@timeout = timeout
|
|
89
|
+
@max_retries = max_retries
|
|
90
|
+
@identities = Identities.new(self)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Records a backend event against an identity.
|
|
94
|
+
#
|
|
95
|
+
# client.track(
|
|
96
|
+
# "user.signup",
|
|
97
|
+
# identity: "user_12345",
|
|
98
|
+
# data: { plan: "pro", referrer: "partner-x" },
|
|
99
|
+
# identity_data: { email: "ada@example.com", name: "Ada Lovelace" }
|
|
100
|
+
# )
|
|
101
|
+
#
|
|
102
|
+
# @param event_type [String] your name for the event, such as +"user.signup"+. Map it to one
|
|
103
|
+
# of Dregs's canonical types under *Settings -> Mappings* so the analyzers know what it
|
|
104
|
+
# means.
|
|
105
|
+
# @param identity [String] your own id for the user. This is the same id you pass to
|
|
106
|
+
# +dregs.identify()+ in the browser tracker, and the one you look scores up by. It is
|
|
107
|
+
# required: a server-side event carries no device signature, so the identity is the only
|
|
108
|
+
# thing tying it to a user.
|
|
109
|
+
# @param data [Hash] attributes of the event itself
|
|
110
|
+
# @param identity_data [Hash] attributes of the *user*, such as email, name, or username.
|
|
111
|
+
# Dregs merges these into the identity, and the analyzers lean on them heavily, so send them
|
|
112
|
+
# whenever you have them. Flat keys work best; name them as your application already does
|
|
113
|
+
# and map them under *Settings -> Mappings*.
|
|
114
|
+
# @param event_id [String, nil] your own id for the event, which makes ingestion idempotent:
|
|
115
|
+
# reposting the same id returns the original event instead of recording a second one. Pass
|
|
116
|
+
# the id your application already has (the row id of the record that triggered the event,
|
|
117
|
+
# say). When you omit it the SDK generates one, which is what makes its own retries safe. At
|
|
118
|
+
# most 64 characters, and it cannot start with +dregs-+.
|
|
119
|
+
# @param timestamp [Time, DateTime, String, nil] when the event happened, if not now. A +Time+
|
|
120
|
+
# is converted to UTC; a string is sent as given and must already be ISO-8601.
|
|
121
|
+
# @param source [String, nil] a label for where the event came from; defaults to +"ruby-sdk"+
|
|
122
|
+
# @return [TrackResult] check +accepted?+ to confirm Dregs recorded the event
|
|
123
|
+
# @raise [QuotaExceededError] the account is over its monthly event limit
|
|
124
|
+
# @raise [RateLimitError] the credential is ingesting too fast
|
|
125
|
+
# @raise [AuthenticationError] the secret key was not recognized
|
|
126
|
+
# @raise [BadRequestError] the event was malformed
|
|
127
|
+
# @raise [ArgumentError] the event type, identity, or event id was unusable
|
|
128
|
+
def track(event_type, identity:, data: {}, identity_data: {}, event_id: nil, timestamp: nil, source: nil)
|
|
129
|
+
body = track_body(
|
|
130
|
+
event_type,
|
|
131
|
+
identity: identity,
|
|
132
|
+
data: data,
|
|
133
|
+
identity_data: identity_data,
|
|
134
|
+
event_id: event_id,
|
|
135
|
+
timestamp: timestamp,
|
|
136
|
+
source: source
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
TrackResult.from_api(request(:post, "/events", body: body))
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# Performs a request, retrying the failures worth retrying.
|
|
143
|
+
#
|
|
144
|
+
# @api private
|
|
145
|
+
# @param method [Symbol] +:get+ or +:post+
|
|
146
|
+
# @param path [String] the path below the base URL, with a leading slash
|
|
147
|
+
# @param body [Hash, nil] a JSON body, for POSTs that carry one
|
|
148
|
+
# @return [Hash, Array, nil] the parsed response body
|
|
149
|
+
def request(method, path, body: nil)
|
|
150
|
+
uri = URI.parse(url_for(path))
|
|
151
|
+
attempt = 0
|
|
152
|
+
|
|
153
|
+
loop do
|
|
154
|
+
outcome = attempt_request(uri, method, body, attempt)
|
|
155
|
+
|
|
156
|
+
return outcome.fetch(:payload) if outcome.key?(:payload)
|
|
157
|
+
|
|
158
|
+
sleep(backoff(attempt, outcome[:retry_after]))
|
|
159
|
+
attempt += 1
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
private
|
|
164
|
+
|
|
165
|
+
# One attempt, returning either the parsed payload or a note that we should try again.
|
|
166
|
+
#
|
|
167
|
+
# Whether a failure is worth retrying is decided here, next to the failure, so the loop above
|
|
168
|
+
# stays readable: anything this returns without a +:payload+ key has already been judged
|
|
169
|
+
# retryable, and anything not worth retrying has already been raised.
|
|
170
|
+
def attempt_request(uri, method, body, attempt)
|
|
171
|
+
response = perform(uri, method, body)
|
|
172
|
+
|
|
173
|
+
begin
|
|
174
|
+
{ payload: process(response) }
|
|
175
|
+
rescue APIError => e
|
|
176
|
+
raise unless retry?(attempt: attempt, status_code: e.status_code)
|
|
177
|
+
|
|
178
|
+
{ retry_after: e.is_a?(RateLimitError) ? e.retry_after : nil }
|
|
179
|
+
end
|
|
180
|
+
rescue Timeout::Error
|
|
181
|
+
raise TimeoutError, "Request to #{uri} timed out." unless retry?(attempt: attempt, status_code: nil)
|
|
182
|
+
|
|
183
|
+
{}
|
|
184
|
+
rescue SocketError, SystemCallError, IOError, OpenSSL::SSL::SSLError => e
|
|
185
|
+
raise ConnectionError, unreachable(uri, e) unless retry?(attempt: attempt, status_code: nil)
|
|
186
|
+
|
|
187
|
+
{}
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def unreachable(uri, error)
|
|
191
|
+
"Could not reach Dregs at #{uri}: #{error.message}"
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def missing_key_message
|
|
195
|
+
"No Dregs secret key. Pass secret_key: ... or set the #{SECRET_KEY_ENV} environment " \
|
|
196
|
+
"variable. You will find your credential's secret key under Settings -> Credentials in " \
|
|
197
|
+
"the Dregs dashboard."
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def public_key_message
|
|
201
|
+
"That is a public key. The public key is for the browser tracker and cannot read " \
|
|
202
|
+
"identities or scores; this SDK needs the secret key from the same credential, which " \
|
|
203
|
+
"starts with 'sk_'."
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
# Builds the +POST /api/events+ body.
|
|
207
|
+
#
|
|
208
|
+
# An event id is always sent. When the caller has an id of their own it is used verbatim, so
|
|
209
|
+
# reposting the same event is a no-op on the Dregs side; otherwise one is generated, which is
|
|
210
|
+
# what makes this client's own retries safe to perform.
|
|
211
|
+
def track_body(event_type, identity:, data:, identity_data:, event_id:, timestamp:, source:)
|
|
212
|
+
raise ArgumentError, "event_type is required." if event_type.nil? || event_type.to_s.empty?
|
|
213
|
+
|
|
214
|
+
if identity.nil? || identity.to_s.empty?
|
|
215
|
+
raise ArgumentError,
|
|
216
|
+
"identity is required. A server-side event has no device signature, so the " \
|
|
217
|
+
"identity is the only thing tying the event to a user."
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
resolved_id = event_id || SecureRandom.uuid.delete("-")
|
|
221
|
+
|
|
222
|
+
if resolved_id.start_with?("dregs-")
|
|
223
|
+
raise ArgumentError, "Event ids starting with 'dregs-' are reserved for Dregs itself."
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
raise ArgumentError, "Event ids cannot be longer than 64 characters." if resolved_id.length > 64
|
|
227
|
+
|
|
228
|
+
body = {
|
|
229
|
+
"id" => resolved_id,
|
|
230
|
+
"type" => event_type.to_s,
|
|
231
|
+
"data" => data || {},
|
|
232
|
+
"identity" => { "id" => identity.to_s, "data" => identity_data || {} },
|
|
233
|
+
"source" => source || "ruby-sdk"
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
body["timestamp"] = iso8601(timestamp) unless timestamp.nil?
|
|
237
|
+
|
|
238
|
+
body
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
# Formats a timestamp the way the API's +Instant+ parser expects.
|
|
242
|
+
#
|
|
243
|
+
# A +Time+ is converted to UTC rather than sent with its offset, and a string is trusted as
|
|
244
|
+
# already being ISO-8601 — a caller who has one in hand should not have to parse it just so
|
|
245
|
+
# this method can format it again.
|
|
246
|
+
def iso8601(value)
|
|
247
|
+
return value if value.is_a?(String)
|
|
248
|
+
return value.to_time.getutc.strftime("%Y-%m-%dT%H:%M:%SZ") if value.respond_to?(:to_time)
|
|
249
|
+
|
|
250
|
+
raise ArgumentError, "timestamp must be a Time, a DateTime, or an ISO-8601 string."
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
def url_for(path)
|
|
254
|
+
"#{base_url}/#{path.sub(%r{\A/+}, "")}"
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
def headers
|
|
258
|
+
{
|
|
259
|
+
"Authorization" => "Bearer #{@secret_key}",
|
|
260
|
+
"Accept" => "application/json",
|
|
261
|
+
"Content-Type" => "application/json",
|
|
262
|
+
"User-Agent" => user_agent
|
|
263
|
+
}
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
def user_agent
|
|
267
|
+
"dregs-ruby/#{Dregs::VERSION} (ruby #{RUBY_VERSION})"
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
def perform(uri, method, body)
|
|
271
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
272
|
+
http.use_ssl = uri.scheme == "https"
|
|
273
|
+
http.open_timeout = timeout
|
|
274
|
+
http.read_timeout = timeout
|
|
275
|
+
http.write_timeout = timeout
|
|
276
|
+
|
|
277
|
+
request = build_request(uri, method)
|
|
278
|
+
headers.each { |name, value| request[name] = value }
|
|
279
|
+
request.body = JSON.generate(body) if body
|
|
280
|
+
|
|
281
|
+
http.start { |connection| connection.request(request) }
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
def build_request(uri, method)
|
|
285
|
+
case method
|
|
286
|
+
when :get then Net::HTTP::Get.new(uri)
|
|
287
|
+
when :post then Net::HTTP::Post.new(uri)
|
|
288
|
+
else raise ArgumentError, "Unsupported HTTP method: #{method}."
|
|
289
|
+
end
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
def retry?(attempt:, status_code:)
|
|
293
|
+
return false if attempt >= max_retries
|
|
294
|
+
|
|
295
|
+
status_code.nil? || RETRY_STATUSES.include?(status_code)
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
# Seconds to wait before the next attempt.
|
|
299
|
+
#
|
|
300
|
+
# +Retry-After+ wins when the server sent one. Otherwise this is exponential with full jitter,
|
|
301
|
+
# which keeps a fleet of workers that all hit the limit at once from retrying in lockstep.
|
|
302
|
+
def backoff(attempt, retry_after)
|
|
303
|
+
return [retry_after, MAX_BACKOFF_SECONDS].min if retry_after && retry_after >= 0
|
|
304
|
+
|
|
305
|
+
rand * [0.5 * (2**attempt), 8.0].min
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
# Turns a response into parsed JSON, or raises the matching error.
|
|
309
|
+
def process(response)
|
|
310
|
+
payload = json_or_nil(response)
|
|
311
|
+
status = response.code.to_i
|
|
312
|
+
|
|
313
|
+
raise api_error(response, payload, status) if status >= 400
|
|
314
|
+
|
|
315
|
+
# An older API build reported both of these as HTTP 200 with the outcome in the body.
|
|
316
|
+
# Reading the body as well as the status keeps this SDK correct against either.
|
|
317
|
+
check_body_status(response, payload) if payload.is_a?(Hash)
|
|
318
|
+
|
|
319
|
+
payload
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
def check_body_status(response, payload)
|
|
323
|
+
case payload["status"]
|
|
324
|
+
when STATUS_RATE_LIMITED
|
|
325
|
+
raise RateLimitError.new(
|
|
326
|
+
"Ingestion rate limit exceeded for this credential.",
|
|
327
|
+
body: payload,
|
|
328
|
+
request_id: request_id(response),
|
|
329
|
+
retry_after: retry_after(response)
|
|
330
|
+
)
|
|
331
|
+
when STATUS_QUOTA_EXCEEDED
|
|
332
|
+
raise QuotaExceededError.new(
|
|
333
|
+
"The account is over its monthly event limit.",
|
|
334
|
+
status_code: 402,
|
|
335
|
+
body: payload,
|
|
336
|
+
request_id: request_id(response)
|
|
337
|
+
)
|
|
338
|
+
end
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
def api_error(response, payload, status)
|
|
342
|
+
message = message_from(payload) || response.message.to_s
|
|
343
|
+
message = "Request failed" if message.empty?
|
|
344
|
+
|
|
345
|
+
return rate_limit_error(response, payload, message) if status == 429
|
|
346
|
+
|
|
347
|
+
Errors.for_status(status).new(
|
|
348
|
+
message,
|
|
349
|
+
status_code: status,
|
|
350
|
+
body: payload,
|
|
351
|
+
request_id: request_id(response)
|
|
352
|
+
)
|
|
353
|
+
end
|
|
354
|
+
|
|
355
|
+
def rate_limit_error(response, payload, message)
|
|
356
|
+
RateLimitError.new(
|
|
357
|
+
message,
|
|
358
|
+
body: payload,
|
|
359
|
+
request_id: request_id(response),
|
|
360
|
+
retry_after: retry_after(response)
|
|
361
|
+
)
|
|
362
|
+
end
|
|
363
|
+
|
|
364
|
+
def message_from(payload)
|
|
365
|
+
return nil unless payload.is_a?(Hash)
|
|
366
|
+
|
|
367
|
+
%w[message error status].each do |key|
|
|
368
|
+
value = payload[key]
|
|
369
|
+
|
|
370
|
+
return value if value.is_a?(String) && !value.empty?
|
|
371
|
+
end
|
|
372
|
+
|
|
373
|
+
nil
|
|
374
|
+
end
|
|
375
|
+
|
|
376
|
+
def json_or_nil(response)
|
|
377
|
+
body = response.body
|
|
378
|
+
|
|
379
|
+
return nil if body.nil? || body.empty?
|
|
380
|
+
|
|
381
|
+
begin
|
|
382
|
+
JSON.parse(body)
|
|
383
|
+
rescue JSON::ParserError
|
|
384
|
+
nil
|
|
385
|
+
end
|
|
386
|
+
end
|
|
387
|
+
|
|
388
|
+
def request_id(response)
|
|
389
|
+
response["X-Request-Id"]
|
|
390
|
+
end
|
|
391
|
+
|
|
392
|
+
# The header also allows an HTTP date, which is rare enough here that falling back to the
|
|
393
|
+
# client's own backoff beats dragging in a date parser.
|
|
394
|
+
def retry_after(response)
|
|
395
|
+
raw = response["Retry-After"]
|
|
396
|
+
|
|
397
|
+
return nil if raw.nil?
|
|
398
|
+
|
|
399
|
+
Float(raw, exception: false)
|
|
400
|
+
end
|
|
401
|
+
end
|
|
402
|
+
end
|
data/lib/dregs/errors.rb
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Dregs
|
|
4
|
+
# Base class for everything this library raises.
|
|
5
|
+
#
|
|
6
|
+
# A caller who only wants a coarse "the Dregs call failed" branch can rescue this one class.
|
|
7
|
+
# Errors that came back from the API derive from {APIError} and carry the HTTP status and the
|
|
8
|
+
# parsed body; errors that never reached the API (DNS, a refused connection, a timeout) derive
|
|
9
|
+
# from {ConnectionError} instead.
|
|
10
|
+
#
|
|
11
|
+
# Programmer mistakes — a missing secret key, an event id longer than the API allows — raise
|
|
12
|
+
# +ArgumentError+ rather than one of these, because they are bugs to fix rather than conditions
|
|
13
|
+
# to handle at runtime.
|
|
14
|
+
class Error < StandardError; end
|
|
15
|
+
|
|
16
|
+
# The request never reached Dregs: DNS, TCP, TLS, or a dropped connection.
|
|
17
|
+
#
|
|
18
|
+
# Retrying is usually the right response, and the client already does so; seeing this means the
|
|
19
|
+
# retries were exhausted too.
|
|
20
|
+
class ConnectionError < Error; end
|
|
21
|
+
|
|
22
|
+
# The request was still outstanding when the configured timeout elapsed.
|
|
23
|
+
#
|
|
24
|
+
# The event may or may not have been recorded. Because every event is sent with an id, reposting
|
|
25
|
+
# it is safe: Dregs returns the original event rather than recording a second one.
|
|
26
|
+
class TimeoutError < ConnectionError; end
|
|
27
|
+
|
|
28
|
+
# An incoming webhook did not verify against the channel's signing secret.
|
|
29
|
+
#
|
|
30
|
+
# Answer the delivery with a 4xx and do not act on the payload.
|
|
31
|
+
class WebhookVerificationError < Error; end
|
|
32
|
+
|
|
33
|
+
# Dregs answered, and the answer was an error.
|
|
34
|
+
#
|
|
35
|
+
# @!attribute [r] status_code
|
|
36
|
+
# @return [Integer] the HTTP status code
|
|
37
|
+
# @!attribute [r] body
|
|
38
|
+
# @return [Hash, Array, nil] the parsed JSON body, or nil when the response was not JSON
|
|
39
|
+
# @!attribute [r] request_id
|
|
40
|
+
# @return [String, nil] the +X-Request-Id+ response header, worth quoting in a support request
|
|
41
|
+
# @!attribute [r] api_message
|
|
42
|
+
# @return [String] the message as Dregs phrased it, without the status prefix {#to_s} adds
|
|
43
|
+
class APIError < Error
|
|
44
|
+
attr_reader :status_code, :body, :request_id, :api_message
|
|
45
|
+
|
|
46
|
+
# @param message [String] the human-readable message, from the response body when it carried one
|
|
47
|
+
# @param status_code [Integer] the HTTP status code
|
|
48
|
+
# @param body [Hash, Array, nil] the parsed JSON body
|
|
49
|
+
# @param request_id [String, nil] the +X-Request-Id+ response header
|
|
50
|
+
def initialize(message, status_code:, body: nil, request_id: nil)
|
|
51
|
+
super(message)
|
|
52
|
+
|
|
53
|
+
@api_message = message
|
|
54
|
+
@status_code = status_code
|
|
55
|
+
@body = body
|
|
56
|
+
@request_id = request_id
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# @return [String] the message, prefixed with the status and suffixed with the request id
|
|
60
|
+
def to_s
|
|
61
|
+
suffix = request_id ? " (request #{request_id})" : ""
|
|
62
|
+
|
|
63
|
+
"HTTP #{status_code}: #{api_message}#{suffix}"
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# 400. The request was malformed or missing something Dregs requires.
|
|
68
|
+
#
|
|
69
|
+
# For event ingestion this most often means the event carried neither an identity nor a device,
|
|
70
|
+
# or the body failed validation. Retrying unchanged will not help.
|
|
71
|
+
class BadRequestError < APIError; end
|
|
72
|
+
|
|
73
|
+
# 401. The secret key was missing, unrecognized, revoked, or expired.
|
|
74
|
+
#
|
|
75
|
+
# Check that the key is the +sk_+ secret key rather than the +pk_+ public key, and that it has
|
|
76
|
+
# not been revoked under Settings -> Credentials.
|
|
77
|
+
class AuthenticationError < APIError; end
|
|
78
|
+
|
|
79
|
+
# 402. The account is over its monthly event limit and ingestion is refused.
|
|
80
|
+
#
|
|
81
|
+
# Events are not queued while an account is over its limit, so the caller decides whether to drop
|
|
82
|
+
# the event or hold it. The limit resets with the billing period; upgrading the plan clears it
|
|
83
|
+
# immediately.
|
|
84
|
+
class QuotaExceededError < APIError; end
|
|
85
|
+
|
|
86
|
+
# 403. The credential authenticated but is not allowed to do this.
|
|
87
|
+
class PermissionDeniedError < APIError; end
|
|
88
|
+
|
|
89
|
+
# 404. No such identity, or no analysis has been run for it yet.
|
|
90
|
+
#
|
|
91
|
+
# An identity appears the first time you track an event for it, and an analysis cycle appears
|
|
92
|
+
# shortly after that, so a 404 on a brand-new user is expected rather than alarming.
|
|
93
|
+
class NotFoundError < APIError; end
|
|
94
|
+
|
|
95
|
+
# 429. The credential exceeded its request rate limit.
|
|
96
|
+
#
|
|
97
|
+
# @!attribute [r] retry_after
|
|
98
|
+
# @return [Float, nil] seconds to wait before retrying, from the +Retry-After+ header
|
|
99
|
+
class RateLimitError < APIError
|
|
100
|
+
attr_reader :retry_after
|
|
101
|
+
|
|
102
|
+
# @param message [String] the human-readable message
|
|
103
|
+
# @param status_code [Integer] the HTTP status code, 429 unless an older build reported it in
|
|
104
|
+
# the response body
|
|
105
|
+
# @param body [Hash, Array, nil] the parsed JSON body
|
|
106
|
+
# @param request_id [String, nil] the +X-Request-Id+ response header
|
|
107
|
+
# @param retry_after [Float, nil] seconds to wait, when the server said how long
|
|
108
|
+
def initialize(message, status_code: 429, body: nil, request_id: nil, retry_after: nil)
|
|
109
|
+
super(message, status_code: status_code, body: body, request_id: request_id)
|
|
110
|
+
|
|
111
|
+
@retry_after = retry_after
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# 5xx. Something went wrong inside Dregs. These are retried automatically.
|
|
116
|
+
class ServerError < APIError; end
|
|
117
|
+
|
|
118
|
+
# Maps HTTP statuses to the error class that represents them.
|
|
119
|
+
module Errors
|
|
120
|
+
STATUS_ERRORS = {
|
|
121
|
+
400 => BadRequestError,
|
|
122
|
+
401 => AuthenticationError,
|
|
123
|
+
402 => QuotaExceededError,
|
|
124
|
+
403 => PermissionDeniedError,
|
|
125
|
+
404 => NotFoundError,
|
|
126
|
+
429 => RateLimitError
|
|
127
|
+
}.freeze
|
|
128
|
+
|
|
129
|
+
# Returns the exception class that represents +status_code+.
|
|
130
|
+
#
|
|
131
|
+
# Anything at or above 500 is a {ServerError}; an unmapped 4xx falls back to the {APIError}
|
|
132
|
+
# base class rather than raising something this SDK cannot name.
|
|
133
|
+
#
|
|
134
|
+
# @param status_code [Integer]
|
|
135
|
+
# @return [Class]
|
|
136
|
+
def self.for_status(status_code)
|
|
137
|
+
return STATUS_ERRORS.fetch(status_code) if STATUS_ERRORS.key?(status_code)
|
|
138
|
+
return ServerError if status_code >= 500
|
|
139
|
+
|
|
140
|
+
APIError
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
end
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "uri"
|
|
4
|
+
|
|
5
|
+
require_relative "models"
|
|
6
|
+
|
|
7
|
+
module Dregs
|
|
8
|
+
# The +client.identities+ namespace: reading identities, their scores, and the reasoning behind
|
|
9
|
+
# them.
|
|
10
|
+
#
|
|
11
|
+
# These methods are thin. They name the endpoint and hand the response to a model; the
|
|
12
|
+
# transport, the retries, and the error mapping all live on {Client}.
|
|
13
|
+
class Identities
|
|
14
|
+
# @api private
|
|
15
|
+
# @param client [Client]
|
|
16
|
+
def initialize(client)
|
|
17
|
+
@client = client
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Returns the identity, with its current scores, badges, and attributes.
|
|
21
|
+
#
|
|
22
|
+
# identity = client.identities.get("user_12345")
|
|
23
|
+
#
|
|
24
|
+
# identity.display_email #=> "ada@example.com"
|
|
25
|
+
# identity.badges.map(&:name)
|
|
26
|
+
#
|
|
27
|
+
# @param identity_id [String] your own id for the user. It is escaped for you, so an email
|
|
28
|
+
# address or anything else with awkward characters in it works as-is.
|
|
29
|
+
# @return [Identity]
|
|
30
|
+
# @raise [NotFoundError] Dregs has never seen this identity
|
|
31
|
+
def get(identity_id)
|
|
32
|
+
Identity.from_api(@client.request(:get, path_for(identity_id)))
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Returns the current category scores.
|
|
36
|
+
#
|
|
37
|
+
# This is the cheap read and the one most integrations want. It reports the scores Dregs has
|
|
38
|
+
# already computed without triggering any work. For the observations behind them, use
|
|
39
|
+
# {#analysis}.
|
|
40
|
+
#
|
|
41
|
+
# A category that has not been scored yet is absent, so a brand-new identity comes back empty.
|
|
42
|
+
#
|
|
43
|
+
# @param identity_id [String] your own id for the user
|
|
44
|
+
# @return [Scores]
|
|
45
|
+
# @raise [NotFoundError] Dregs has never seen this identity
|
|
46
|
+
def scores(identity_id)
|
|
47
|
+
Scores.from_api(@client.request(:get, path_for(identity_id, "/scores")))
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Returns the most recent analysis cycle, with the observations behind each score.
|
|
51
|
+
#
|
|
52
|
+
# Use this when you need to show or log *why* an identity scored the way it did.
|
|
53
|
+
#
|
|
54
|
+
# @param identity_id [String] your own id for the user
|
|
55
|
+
# @return [Analysis]
|
|
56
|
+
# @raise [NotFoundError] the identity is unknown, or it has not been analyzed yet
|
|
57
|
+
def analysis(identity_id)
|
|
58
|
+
Analysis.from_api(@client.request(:get, path_for(identity_id, "/analysis")))
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Queues a re-analysis of the identity.
|
|
62
|
+
#
|
|
63
|
+
# Scoring is asynchronous: this returns as soon as the job is queued, not when it has run.
|
|
64
|
+
# Poll {#scores} or watch for a webhook rather than expecting fresh scores on the next line.
|
|
65
|
+
# Dregs rescores on its own as events arrive, so you rarely need this outside a support or
|
|
66
|
+
# backfill flow.
|
|
67
|
+
#
|
|
68
|
+
# @param identity_id [String] your own id for the user
|
|
69
|
+
# @return [nil]
|
|
70
|
+
# @raise [NotFoundError] Dregs has never seen this identity
|
|
71
|
+
def analyze(identity_id)
|
|
72
|
+
@client.request(:post, path_for(identity_id, "/actions/analyze"))
|
|
73
|
+
|
|
74
|
+
nil
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
private
|
|
78
|
+
|
|
79
|
+
def path_for(identity_id, suffix = "")
|
|
80
|
+
raise ArgumentError, "An identity id is required." if identity_id.nil? || identity_id.to_s.empty?
|
|
81
|
+
|
|
82
|
+
# Identity ids are the caller's own user ids and routinely contain characters that need
|
|
83
|
+
# escaping, an email address being the common one.
|
|
84
|
+
"/identities/#{escape(identity_id.to_s)}#{suffix}"
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def escape(value)
|
|
88
|
+
URI.encode_www_form_component(value).gsub("+", "%20")
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|