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.
@@ -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
@@ -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