dregs 0.1.0 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +25 -2
- data/README.md +119 -1
- data/lib/dregs/client.rb +111 -10
- data/lib/dregs/devices.rb +70 -0
- data/lib/dregs/errors.rb +43 -1
- data/lib/dregs/escalations.rb +107 -0
- data/lib/dregs/events.rb +73 -0
- data/lib/dregs/groups.rb +137 -0
- data/lib/dregs/identities.rb +135 -18
- data/lib/dregs/models.rb +670 -0
- data/lib/dregs/resource.rb +117 -0
- data/lib/dregs/version.rb +1 -1
- data/lib/dregs.rb +5 -0
- data/sig/dregs.rbs +418 -3
- metadata +7 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: bfd935e6538082a551734c1bfeeeb5fe76e8d348e96e3378ace99cf74b65425a
|
|
4
|
+
data.tar.gz: 32d8827024545f2b625f6150ca9601fd588d3b6a36c5b7b240fda3eefcb49c72
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: ebbeda4c7c941594f1d0ca31aa9b06f107fa59ea63b81e9ab4ac350e8fd39d64b5cc4b36881a23a591270fcd85dc46f06e263c01415eb491d74b837a6fe3b540
|
|
7
|
+
data.tar.gz: 13141efc8a036f336fbe7db855aaad71487c370c51110b3e70f89e1313ef3b880b4d4d7c57fe03affd7657577897dddd9a53a935802f04eaca3531835f758c3b
|
data/CHANGELOG.md
CHANGED
|
@@ -4,7 +4,30 @@ All notable changes to this project are documented here. The format follows
|
|
|
4
4
|
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to
|
|
5
5
|
[Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
6
6
|
|
|
7
|
-
## [Unreleased
|
|
7
|
+
## [1.0.0] - Unreleased
|
|
8
|
+
|
|
9
|
+
The first stable release. It catches the SDK up with the public API: groups, the rest of the identity
|
|
10
|
+
reads and writes, events, devices, and escalations, with paging throughout. Nothing from 0.1.0 changes,
|
|
11
|
+
so upgrading needs no code changes.
|
|
12
|
+
|
|
13
|
+
### Added
|
|
14
|
+
|
|
15
|
+
- `groups:` on `track`: the organizations, teams, workspaces, or other groups the user is acting in,
|
|
16
|
+
each with an `id`, an optional `type` (`"organization"` by default), and optional `data`. Dregs makes
|
|
17
|
+
the identity a member of each.
|
|
18
|
+
- `client.groups`: `list`, `list_all`, `get`, `create`, `update`, `add_member`, and `remove_member`.
|
|
19
|
+
- `identities.list`, `list_all`, `update` (to disregard an identity or merge attributes), `history`,
|
|
20
|
+
`links`, `reviews`, and `review`, and a `cycle_id:` on `analysis` for a past cycle.
|
|
21
|
+
- `client.events`: `list`, `list_all`, and `get`, with the groups each event was sent with.
|
|
22
|
+
- `client.devices`: `list`, `list_all`, `get`, and `update` to disregard a device.
|
|
23
|
+
- `client.escalations`: `list`, `list_all`, `get`, `update` to move an escalation along, and `summary`.
|
|
24
|
+
- `Dregs::Page`, an `Enumerable` page of items with its position and totals, and a `list_all` on every
|
|
25
|
+
namespace that walks the pages for you.
|
|
26
|
+
|
|
27
|
+
### Fixed
|
|
28
|
+
|
|
29
|
+
- An error response without a message of its own now reads as the status's reason phrase, such as
|
|
30
|
+
"HTTP 404: Not Found", rather than "Request failed".
|
|
8
31
|
|
|
9
32
|
## [0.1.0] - 2026-09-22
|
|
10
33
|
|
|
@@ -23,5 +46,5 @@ The first release. A server-side client for tracking events and reading scores.
|
|
|
23
46
|
- `Dregs::Webhooks.verify` for checking a webhook's signature and rejecting replays.
|
|
24
47
|
- Lenient models with a `raw` escape hatch, and RBS signatures in `sig/`.
|
|
25
48
|
|
|
26
|
-
[
|
|
49
|
+
[1.0.0]: https://github.com/dregs-sdk/dregs-sdk-ruby/compare/v0.1.0...v1.0.0
|
|
27
50
|
[0.1.0]: https://github.com/dregs-sdk/dregs-sdk-ruby/releases/tag/v0.1.0
|
data/README.md
CHANGED
|
@@ -51,6 +51,26 @@ signature, so the identity is the only thing tying the event to a user.
|
|
|
51
51
|
heavily, so send them whenever you have them. Name the keys the way your application already does and
|
|
52
52
|
map them to Dregs's canonical fields under **Settings → Mappings**; the same goes for event names.
|
|
53
53
|
|
|
54
|
+
### Groups
|
|
55
|
+
|
|
56
|
+
If your application groups users into organizations, teams, workspaces, or the like, pass the groups the
|
|
57
|
+
user is acting in. Dregs records each group and makes the identity a member of it. Each group has your
|
|
58
|
+
own `id`, a `type` that is your own name for the kind of group (`"organization"` when omitted), and
|
|
59
|
+
optional `data` that Dregs merges into the group, so later events can send the type and id alone. Dregs
|
|
60
|
+
normalizes types to lower_snake_case, so `ParentCompany` and `parent-company` are the same type, and an
|
|
61
|
+
event can carry one group of each type.
|
|
62
|
+
|
|
63
|
+
```ruby
|
|
64
|
+
client.track(
|
|
65
|
+
"user.login",
|
|
66
|
+
identity: "user_12345",
|
|
67
|
+
groups: [
|
|
68
|
+
{ type: "organization", id: "org_678", data: { name: "Acme Inc", plan: "enterprise" } },
|
|
69
|
+
{ type: "team", id: "team_42", data: { name: "Payments" } }
|
|
70
|
+
]
|
|
71
|
+
)
|
|
72
|
+
```
|
|
73
|
+
|
|
54
74
|
### Idempotency
|
|
55
75
|
|
|
56
76
|
Every event is sent with an `id`, which makes ingestion idempotent: reposting the same id returns the
|
|
@@ -133,6 +153,104 @@ client.identities.analyze("user_12345")
|
|
|
133
153
|
This queues the work and returns; it does not wait for the cycle to finish. Dregs rescores on its own
|
|
134
154
|
as events arrive, so you rarely need this outside of a support or backfill flow.
|
|
135
155
|
|
|
156
|
+
### History, links, and past cycles
|
|
157
|
+
|
|
158
|
+
```ruby
|
|
159
|
+
history = client.identities.history("user_12345", limit: 10)
|
|
160
|
+
|
|
161
|
+
history.each do |entry|
|
|
162
|
+
puts "#{entry.finished_at} #{entry.humanity} #{entry.behavior}"
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
older = client.identities.analysis("user_12345", cycle_id: history.last.cycle_id) # a past cycle
|
|
166
|
+
|
|
167
|
+
client.identities.links("user_12345").each do |link|
|
|
168
|
+
puts "#{link.identity_id} #{link.type} #{link.explanation}" # e.g. "user_999 SAME_DEVICE ..."
|
|
169
|
+
end
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
### Disregarding an identity
|
|
173
|
+
|
|
174
|
+
Leave your own staff and test accounts out of scoring and escalations:
|
|
175
|
+
|
|
176
|
+
```ruby
|
|
177
|
+
client.identities.update("qa_bot_7", disregarded: true)
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
### Asking for a review
|
|
181
|
+
|
|
182
|
+
When a score looks wrong, Dregs's AI reviewer will look over the analysis and say why. The review runs
|
|
183
|
+
in the background, so read it back later with `reviews`.
|
|
184
|
+
|
|
185
|
+
```ruby
|
|
186
|
+
review = client.identities.review("user_12345", feedback: "This is a long-standing customer.")
|
|
187
|
+
|
|
188
|
+
review.status # "PENDING", then "FINISHED"
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
## Listing and paging
|
|
192
|
+
|
|
193
|
+
Every namespace with a `list` returns one `Dregs::Page` at a time, numbered from 0, and a `list_all`
|
|
194
|
+
that walks every page for you:
|
|
195
|
+
|
|
196
|
+
```ruby
|
|
197
|
+
page = client.identities.list(term: "example.com", page_size: 50)
|
|
198
|
+
|
|
199
|
+
page.total_elements # how many match in all
|
|
200
|
+
page.has_next? # whether another page follows
|
|
201
|
+
|
|
202
|
+
client.identities.list_all(group: "org_678", sort: "humanityScore", order: :asc).each do |identity|
|
|
203
|
+
# ...
|
|
204
|
+
end
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
A page is `Enumerable` over its items. `list_all` returns an `Enumerator` that fetches each page only
|
|
208
|
+
when the items before it have been used up, so `list_all.first(10)` costs a single request; pass it a
|
|
209
|
+
block instead and it yields every item. Filters you leave out are not sent. A page holds at most 100
|
|
210
|
+
items.
|
|
211
|
+
|
|
212
|
+
## Groups
|
|
213
|
+
|
|
214
|
+
`track` records groups and memberships as events arrive. The `groups` namespace reads them back, and
|
|
215
|
+
keeps Dregs in step when something changes outside an event, such as a user leaving a team:
|
|
216
|
+
|
|
217
|
+
```ruby
|
|
218
|
+
client.groups.create("org_678", attributes: { name: "Acme Inc", plan: "enterprise" })
|
|
219
|
+
|
|
220
|
+
group = client.groups.get("organization", "org_678")
|
|
221
|
+
group.identity_count # 12
|
|
222
|
+
|
|
223
|
+
client.groups.update("organization", "org_678", { plan: "business" })
|
|
224
|
+
|
|
225
|
+
client.groups.add_member("team", "team_42", "user_12345")
|
|
226
|
+
client.groups.remove_member("team", "team_42", "user_12345")
|
|
227
|
+
|
|
228
|
+
client.groups.list_all(group_type: "team", identity: "user_12345").each do |team|
|
|
229
|
+
puts "#{team.id} #{team.display_name}"
|
|
230
|
+
end
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
`create` merges into the group when one with the same type and id already exists, so it is safe to call
|
|
234
|
+
on every sync. The type defaults to `"organization"`.
|
|
235
|
+
|
|
236
|
+
## Events, devices, and escalations
|
|
237
|
+
|
|
238
|
+
```ruby
|
|
239
|
+
client.events.list_all(identity: "user_12345").each do |event|
|
|
240
|
+
puts "#{event.timestamp} #{event.name} #{event.canonical_type}"
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
client.devices.update("a1b2c3d4e5", disregarded: true) # e.g. a shared office machine
|
|
244
|
+
|
|
245
|
+
client.escalations.list_all(status: :open, severity: :critical).each do |escalation|
|
|
246
|
+
notify_on_call(escalation.identity.id)
|
|
247
|
+
client.escalations.update(escalation.id, status: :acknowledged)
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
summary = client.escalations.summary
|
|
251
|
+
summary.open # 3
|
|
252
|
+
```
|
|
253
|
+
|
|
136
254
|
## Errors
|
|
137
255
|
|
|
138
256
|
```ruby
|
|
@@ -153,7 +271,7 @@ end
|
|
|
153
271
|
| `Dregs::AuthenticationError` | 401, the secret key was not recognized |
|
|
154
272
|
| `Dregs::QuotaExceededError` | 402, the account is over its monthly event limit |
|
|
155
273
|
| `Dregs::PermissionDeniedError` | 403, the credential may not do this |
|
|
156
|
-
| `Dregs::NotFoundError` | 404, no such identity, or it has not been analyzed |
|
|
274
|
+
| `Dregs::NotFoundError` | 404, no such identity (or group, event, device, escalation), or it has not been analyzed |
|
|
157
275
|
| `Dregs::RateLimitError` | 429, too many requests |
|
|
158
276
|
| `Dregs::ServerError` | 5xx |
|
|
159
277
|
| `Dregs::TimeoutError` | the request timed out |
|
data/lib/dregs/client.rb
CHANGED
|
@@ -7,7 +7,11 @@ require "securerandom"
|
|
|
7
7
|
require "timeout"
|
|
8
8
|
require "uri"
|
|
9
9
|
|
|
10
|
+
require_relative "devices"
|
|
10
11
|
require_relative "errors"
|
|
12
|
+
require_relative "escalations"
|
|
13
|
+
require_relative "events"
|
|
14
|
+
require_relative "groups"
|
|
11
15
|
require_relative "identities"
|
|
12
16
|
require_relative "models"
|
|
13
17
|
require_relative "version"
|
|
@@ -54,6 +58,9 @@ module Dregs
|
|
|
54
58
|
STATUS_RATE_LIMITED = "rate_limited"
|
|
55
59
|
STATUS_QUOTA_EXCEEDED = "quota_exceeded"
|
|
56
60
|
|
|
61
|
+
# The type a group is sent with when the caller does not name one.
|
|
62
|
+
DEFAULT_GROUP_TYPE = "organization"
|
|
63
|
+
|
|
57
64
|
# @return [String] the API root every request is built against, without a trailing slash
|
|
58
65
|
attr_reader :base_url
|
|
59
66
|
|
|
@@ -66,6 +73,18 @@ module Dregs
|
|
|
66
73
|
# @return [Identities] read identities, their scores, and their analysis
|
|
67
74
|
attr_reader :identities
|
|
68
75
|
|
|
76
|
+
# @return [Groups] read and manage groups and their members
|
|
77
|
+
attr_reader :groups
|
|
78
|
+
|
|
79
|
+
# @return [Events] read the events Dregs has recorded
|
|
80
|
+
attr_reader :events
|
|
81
|
+
|
|
82
|
+
# @return [Devices] read devices, and disregard them
|
|
83
|
+
attr_reader :devices
|
|
84
|
+
|
|
85
|
+
# @return [Escalations] read and work escalations
|
|
86
|
+
attr_reader :escalations
|
|
87
|
+
|
|
69
88
|
# @param secret_key [String, nil] your credential's secret key; defaults to +$DREGS_SECRET_KEY+
|
|
70
89
|
# @param base_url [String, nil] the API root; defaults to +$DREGS_BASE_URL+, then
|
|
71
90
|
# {DEFAULT_BASE_URL}
|
|
@@ -88,6 +107,10 @@ module Dregs
|
|
|
88
107
|
@timeout = timeout
|
|
89
108
|
@max_retries = max_retries
|
|
90
109
|
@identities = Identities.new(self)
|
|
110
|
+
@groups = Groups.new(self)
|
|
111
|
+
@events = Events.new(self)
|
|
112
|
+
@devices = Devices.new(self)
|
|
113
|
+
@escalations = Escalations.new(self)
|
|
91
114
|
end
|
|
92
115
|
|
|
93
116
|
# Records a backend event against an identity.
|
|
@@ -96,7 +119,8 @@ module Dregs
|
|
|
96
119
|
# "user.signup",
|
|
97
120
|
# identity: "user_12345",
|
|
98
121
|
# data: { plan: "pro", referrer: "partner-x" },
|
|
99
|
-
# identity_data: { email: "ada@example.com", name: "Ada Lovelace" }
|
|
122
|
+
# identity_data: { email: "ada@example.com", name: "Ada Lovelace" },
|
|
123
|
+
# groups: [{ type: "team", id: "team_42", data: { name: "Payments" } }]
|
|
100
124
|
# )
|
|
101
125
|
#
|
|
102
126
|
# @param event_type [String] your name for the event, such as +"user.signup"+. Map it to one
|
|
@@ -111,6 +135,11 @@ module Dregs
|
|
|
111
135
|
# Dregs merges these into the identity, and the analyzers lean on them heavily, so send them
|
|
112
136
|
# whenever you have them. Flat keys work best; name them as your application already does
|
|
113
137
|
# and map them under *Settings -> Mappings*.
|
|
138
|
+
# @param groups [Array<Hash>, nil] the groups the user is acting in, such as their organization
|
|
139
|
+
# or team, if your application has them. Each is a hash with an +id+ (your own), an optional
|
|
140
|
+
# +type+ (your own name for the kind of group, defaulting to +"organization"+), and optional
|
|
141
|
+
# +data+ that Dregs merges into the group. Dregs makes the identity a member of each. At most
|
|
142
|
+
# one group of each type. Keys may be symbols or strings.
|
|
114
143
|
# @param event_id [String, nil] your own id for the event, which makes ingestion idempotent:
|
|
115
144
|
# reposting the same id returns the original event instead of recording a second one. Pass
|
|
116
145
|
# the id your application already has (the row id of the record that triggered the event,
|
|
@@ -124,13 +153,15 @@ module Dregs
|
|
|
124
153
|
# @raise [RateLimitError] the credential is ingesting too fast
|
|
125
154
|
# @raise [AuthenticationError] the secret key was not recognized
|
|
126
155
|
# @raise [BadRequestError] the event was malformed
|
|
127
|
-
# @raise [ArgumentError] the event type, identity,
|
|
128
|
-
def track(event_type, identity:, data: {}, identity_data: {},
|
|
156
|
+
# @raise [ArgumentError] the event type, identity, event id, or groups were unusable
|
|
157
|
+
def track(event_type, identity:, data: {}, identity_data: {}, groups: nil, event_id: nil, timestamp: nil,
|
|
158
|
+
source: nil)
|
|
129
159
|
body = track_body(
|
|
130
160
|
event_type,
|
|
131
161
|
identity: identity,
|
|
132
162
|
data: data,
|
|
133
163
|
identity_data: identity_data,
|
|
164
|
+
groups: groups,
|
|
134
165
|
event_id: event_id,
|
|
135
166
|
timestamp: timestamp,
|
|
136
167
|
source: source
|
|
@@ -142,12 +173,17 @@ module Dregs
|
|
|
142
173
|
# Performs a request, retrying the failures worth retrying.
|
|
143
174
|
#
|
|
144
175
|
# @api private
|
|
145
|
-
# @param method [Symbol] +:get
|
|
146
|
-
# @param path [String] the path below the base URL, with a leading slash
|
|
147
|
-
#
|
|
176
|
+
# @param method [Symbol] +:get+, +:post+, +:put+, +:patch+, or +:delete+
|
|
177
|
+
# @param path [String] the path below the base URL, with a leading slash, its segments already
|
|
178
|
+
# escaped
|
|
179
|
+
# @param body [Hash, nil] a JSON body, for the requests that carry one
|
|
180
|
+
# @param params [Hash, nil] query parameters. Those that are nil are left out, and booleans are
|
|
181
|
+
# spelled +true+ and +false+ as the API's binder reads them.
|
|
148
182
|
# @return [Hash, Array, nil] the parsed response body
|
|
149
|
-
def request(method, path, body: nil)
|
|
183
|
+
def request(method, path, body: nil, params: nil)
|
|
150
184
|
uri = URI.parse(url_for(path))
|
|
185
|
+
query = query_string(params)
|
|
186
|
+
uri.query = query unless query.nil?
|
|
151
187
|
attempt = 0
|
|
152
188
|
|
|
153
189
|
loop do
|
|
@@ -208,7 +244,7 @@ module Dregs
|
|
|
208
244
|
# An event id is always sent. When the caller has an id of their own it is used verbatim, so
|
|
209
245
|
# reposting the same event is a no-op on the Dregs side; otherwise one is generated, which is
|
|
210
246
|
# what makes this client's own retries safe to perform.
|
|
211
|
-
def track_body(event_type, identity:, data:, identity_data:, event_id:, timestamp:, source:)
|
|
247
|
+
def track_body(event_type, identity:, data:, identity_data:, groups:, event_id:, timestamp:, source:)
|
|
212
248
|
raise ArgumentError, "event_type is required." if event_type.nil? || event_type.to_s.empty?
|
|
213
249
|
|
|
214
250
|
if identity.nil? || identity.to_s.empty?
|
|
@@ -217,6 +253,7 @@ module Dregs
|
|
|
217
253
|
"identity is the only thing tying the event to a user."
|
|
218
254
|
end
|
|
219
255
|
|
|
256
|
+
resolved_groups = groups_body(groups)
|
|
220
257
|
resolved_id = event_id || SecureRandom.uuid.delete("-")
|
|
221
258
|
|
|
222
259
|
if resolved_id.start_with?("dregs-")
|
|
@@ -233,11 +270,63 @@ module Dregs
|
|
|
233
270
|
"source" => source || "ruby-sdk"
|
|
234
271
|
}
|
|
235
272
|
|
|
273
|
+
body["groups"] = resolved_groups unless resolved_groups.empty?
|
|
236
274
|
body["timestamp"] = iso8601(timestamp) unless timestamp.nil?
|
|
237
275
|
|
|
238
276
|
body
|
|
239
277
|
end
|
|
240
278
|
|
|
279
|
+
# Builds the +groups+ array of a track body, refusing malformed entries before any request.
|
|
280
|
+
#
|
|
281
|
+
# Each group needs an +id+. Its +type+ defaults to +"organization"+, and Dregs normalizes it to
|
|
282
|
+
# lower_snake_case, so only one group of each type may go with an event.
|
|
283
|
+
def groups_body(groups)
|
|
284
|
+
return [] if groups.nil?
|
|
285
|
+
|
|
286
|
+
unless groups.is_a?(Array)
|
|
287
|
+
raise ArgumentError, "groups is an array of groups, such as [{ type: \"team\", id: \"team_42\" }]."
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
seen = []
|
|
291
|
+
|
|
292
|
+
groups.map do |group|
|
|
293
|
+
unless group.is_a?(Hash)
|
|
294
|
+
raise ArgumentError, "Each group must be a hash with an id, and optionally a type and data."
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
group_id = group_value(group, :id)
|
|
298
|
+
|
|
299
|
+
raise ArgumentError, "Each group needs an id." if group_id.nil? || group_id.to_s.empty?
|
|
300
|
+
|
|
301
|
+
group_type = group_value(group, :type)
|
|
302
|
+
group_type = DEFAULT_GROUP_TYPE if group_type.nil? || group_type == ""
|
|
303
|
+
|
|
304
|
+
unless group_type.is_a?(String)
|
|
305
|
+
raise ArgumentError, "A group's type must be a string, such as \"organization\" or \"team\"."
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
if seen.include?(group_type.downcase)
|
|
309
|
+
raise ArgumentError,
|
|
310
|
+
"Only one group of each type can go with an event; \"#{group_type}\" appears twice."
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
data = group_value(group, :data)
|
|
314
|
+
|
|
315
|
+
unless data.nil? || data.is_a?(Hash)
|
|
316
|
+
raise ArgumentError, "A group's data must be a hash, such as { name: \"Payments\" }."
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
seen << group_type.downcase
|
|
320
|
+
|
|
321
|
+
{ "type" => group_type, "id" => group_id.to_s, "data" => data || {} }
|
|
322
|
+
end
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
# Reads a group's key whether the caller spelled it as a symbol or a string.
|
|
326
|
+
def group_value(group, key)
|
|
327
|
+
group.key?(key) ? group[key] : group[key.to_s]
|
|
328
|
+
end
|
|
329
|
+
|
|
241
330
|
# Formats a timestamp the way the API's +Instant+ parser expects.
|
|
242
331
|
#
|
|
243
332
|
# A +Time+ is converted to UTC rather than sent with its offset, and a string is trusted as
|
|
@@ -254,6 +343,15 @@ module Dregs
|
|
|
254
343
|
"#{base_url}/#{path.sub(%r{\A/+}, "")}"
|
|
255
344
|
end
|
|
256
345
|
|
|
346
|
+
# Drops unset parameters and encodes the rest, or returns nil when nothing is left to send.
|
|
347
|
+
def query_string(params)
|
|
348
|
+
return nil if params.nil?
|
|
349
|
+
|
|
350
|
+
pairs = params.compact.map { |name, value| [name.to_s, value.to_s] }
|
|
351
|
+
|
|
352
|
+
pairs.empty? ? nil : URI.encode_www_form(pairs)
|
|
353
|
+
end
|
|
354
|
+
|
|
257
355
|
def headers
|
|
258
356
|
{
|
|
259
357
|
"Authorization" => "Bearer #{@secret_key}",
|
|
@@ -285,6 +383,9 @@ module Dregs
|
|
|
285
383
|
case method
|
|
286
384
|
when :get then Net::HTTP::Get.new(uri)
|
|
287
385
|
when :post then Net::HTTP::Post.new(uri)
|
|
386
|
+
when :put then Net::HTTP::Put.new(uri)
|
|
387
|
+
when :patch then Net::HTTP::Patch.new(uri)
|
|
388
|
+
when :delete then Net::HTTP::Delete.new(uri)
|
|
288
389
|
else raise ArgumentError, "Unsupported HTTP method: #{method}."
|
|
289
390
|
end
|
|
290
391
|
end
|
|
@@ -339,8 +440,8 @@ module Dregs
|
|
|
339
440
|
end
|
|
340
441
|
|
|
341
442
|
def api_error(response, payload, status)
|
|
342
|
-
message = message_from(payload) || response.message.to_s
|
|
343
|
-
message =
|
|
443
|
+
message = message_from(payload) || response.message.to_s.strip
|
|
444
|
+
message = Errors.reason_phrase(status) if message.empty?
|
|
344
445
|
|
|
345
446
|
return rate_limit_error(response, payload, message) if status == 429
|
|
346
447
|
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "models"
|
|
4
|
+
require_relative "resource"
|
|
5
|
+
|
|
6
|
+
module Dregs
|
|
7
|
+
# The +client.devices+ namespace: the browsers and devices Dregs has seen, identified by their
|
|
8
|
+
# fingerprints.
|
|
9
|
+
class Devices < Resource
|
|
10
|
+
# Returns one page of devices, most recently seen first. Use {#list_all} to walk every page.
|
|
11
|
+
#
|
|
12
|
+
# @param identity [String, nil] only devices this identity was seen on
|
|
13
|
+
# @param term [String, nil] free text matched against fingerprints, addresses, places, and user
|
|
14
|
+
# agents
|
|
15
|
+
# @param page [Integer] the page to return, counting from 0
|
|
16
|
+
# @param page_size [Integer, nil] items per page, at most 100
|
|
17
|
+
# @param sort [String, nil] the field to sort on, such as +"lastSeenAt"+ or +"country"+
|
|
18
|
+
# @param order [Symbol, String, nil] +:asc+ or +:desc+
|
|
19
|
+
# @return [Page<Device>]
|
|
20
|
+
# @raise [ArgumentError] the page is negative or the order is not one of the two
|
|
21
|
+
def list(identity: nil, term: nil, page: 0, page_size: nil, sort: nil, order: nil)
|
|
22
|
+
filters = { "identity" => identity, "term" => term }
|
|
23
|
+
|
|
24
|
+
list_page("/devices", Device, filters, page: page, page_size: page_size, sort: sort, order: order)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Walks every device that matches, fetching pages as it goes. Takes the filters {#list} does.
|
|
28
|
+
#
|
|
29
|
+
# @return [Enumerator<Device>] a lazy enumerator, which fetches a page only when the items
|
|
30
|
+
# before it have been used up; nil when a block is given, which is called with each device
|
|
31
|
+
# @raise [ArgumentError] the order is not one of the two
|
|
32
|
+
def list_all(identity: nil, term: nil, page_size: nil, sort: nil, order: nil, &block)
|
|
33
|
+
filters = { "identity" => identity, "term" => term }
|
|
34
|
+
|
|
35
|
+
list_every("/devices", Device, filters, page_size: page_size, sort: sort, order: order, &block)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Returns the device with this fingerprint.
|
|
39
|
+
#
|
|
40
|
+
# @param fingerprint [String] the device's fingerprint
|
|
41
|
+
# @return [Device]
|
|
42
|
+
# @raise [NotFoundError] no device has this fingerprint
|
|
43
|
+
def get(fingerprint)
|
|
44
|
+
Device.from_api(@client.request(:get, path_for(fingerprint)))
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Sets whether the device is disregarded, and returns it.
|
|
48
|
+
#
|
|
49
|
+
# A disregarded device, such as a shared office machine, no longer links the identities seen
|
|
50
|
+
# on it.
|
|
51
|
+
#
|
|
52
|
+
# client.devices.update("a1b2c3d4e5", disregarded: true)
|
|
53
|
+
#
|
|
54
|
+
# @param fingerprint [String] the device's fingerprint
|
|
55
|
+
# @param disregarded [Boolean] whether to disregard it
|
|
56
|
+
# @return [Device]
|
|
57
|
+
# @raise [NotFoundError] no device has this fingerprint
|
|
58
|
+
def update(fingerprint, disregarded:)
|
|
59
|
+
body = { "disregarded" => disregarded ? true : false }
|
|
60
|
+
|
|
61
|
+
Device.from_api(@client.request(:patch, path_for(fingerprint), body: body))
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
private
|
|
65
|
+
|
|
66
|
+
def path_for(fingerprint)
|
|
67
|
+
"/devices/#{segment(fingerprint, "A device fingerprint")}"
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
data/lib/dregs/errors.rb
CHANGED
|
@@ -115,7 +115,7 @@ module Dregs
|
|
|
115
115
|
# 5xx. Something went wrong inside Dregs. These are retried automatically.
|
|
116
116
|
class ServerError < APIError; end
|
|
117
117
|
|
|
118
|
-
# Maps HTTP statuses to the error class that represents them.
|
|
118
|
+
# Maps HTTP statuses to the error class that represents them, and to their reason phrases.
|
|
119
119
|
module Errors
|
|
120
120
|
STATUS_ERRORS = {
|
|
121
121
|
400 => BadRequestError,
|
|
@@ -126,6 +126,48 @@ module Dregs
|
|
|
126
126
|
429 => RateLimitError
|
|
127
127
|
}.freeze
|
|
128
128
|
|
|
129
|
+
# The standard reason phrase for each status an error response is likely to carry.
|
|
130
|
+
#
|
|
131
|
+
# An error body without a message of its own still deserves one more useful than "Request
|
|
132
|
+
# failed", and a server or proxy speaking HTTP/2 sends no reason phrase on the status line to
|
|
133
|
+
# fall back to.
|
|
134
|
+
REASON_PHRASES = {
|
|
135
|
+
400 => "Bad Request",
|
|
136
|
+
401 => "Unauthorized",
|
|
137
|
+
402 => "Payment Required",
|
|
138
|
+
403 => "Forbidden",
|
|
139
|
+
404 => "Not Found",
|
|
140
|
+
405 => "Method Not Allowed",
|
|
141
|
+
406 => "Not Acceptable",
|
|
142
|
+
408 => "Request Timeout",
|
|
143
|
+
409 => "Conflict",
|
|
144
|
+
410 => "Gone",
|
|
145
|
+
411 => "Length Required",
|
|
146
|
+
412 => "Precondition Failed",
|
|
147
|
+
413 => "Content Too Large",
|
|
148
|
+
414 => "URI Too Long",
|
|
149
|
+
415 => "Unsupported Media Type",
|
|
150
|
+
418 => "I'm a teapot",
|
|
151
|
+
422 => "Unprocessable Content",
|
|
152
|
+
425 => "Too Early",
|
|
153
|
+
428 => "Precondition Required",
|
|
154
|
+
429 => "Too Many Requests",
|
|
155
|
+
431 => "Request Header Fields Too Large",
|
|
156
|
+
500 => "Internal Server Error",
|
|
157
|
+
501 => "Not Implemented",
|
|
158
|
+
502 => "Bad Gateway",
|
|
159
|
+
503 => "Service Unavailable",
|
|
160
|
+
504 => "Gateway Timeout"
|
|
161
|
+
}.freeze
|
|
162
|
+
|
|
163
|
+
# Returns the standard reason phrase for +status_code+, such as +"Not Found"+ for 404.
|
|
164
|
+
#
|
|
165
|
+
# @param status_code [Integer]
|
|
166
|
+
# @return [String] the phrase, or +"Request failed"+ for a status without a standard one
|
|
167
|
+
def self.reason_phrase(status_code)
|
|
168
|
+
REASON_PHRASES.fetch(status_code, "Request failed")
|
|
169
|
+
end
|
|
170
|
+
|
|
129
171
|
# Returns the exception class that represents +status_code+.
|
|
130
172
|
#
|
|
131
173
|
# Anything at or above 500 is a {ServerError}; an unmapped 4xx falls back to the {APIError}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "models"
|
|
4
|
+
require_relative "resource"
|
|
5
|
+
|
|
6
|
+
module Dregs
|
|
7
|
+
# The +client.escalations+ namespace: the escalations your escalation rules open, and moving
|
|
8
|
+
# them along.
|
|
9
|
+
class Escalations < Resource
|
|
10
|
+
# The statuses an escalation moves through.
|
|
11
|
+
STATUSES = %w[OPEN ACKNOWLEDGED CLOSED].freeze
|
|
12
|
+
|
|
13
|
+
# Returns one page of escalations, newest first. Use {#list_all} to walk every page.
|
|
14
|
+
#
|
|
15
|
+
# @param status [Symbol, String, nil] only escalations in this status: +:open+,
|
|
16
|
+
# +:acknowledged+, or +:closed+
|
|
17
|
+
# @param severity [Symbol, String, nil] only escalations of this severity: +:info+, +:warning+,
|
|
18
|
+
# or +:critical+
|
|
19
|
+
# @param identity [String, nil] only escalations about this identity
|
|
20
|
+
# @param rule [Integer, String, nil] only escalations opened by the escalation rule with this id
|
|
21
|
+
# @param page [Integer] the page to return, counting from 0
|
|
22
|
+
# @param page_size [Integer, nil] items per page, at most 100
|
|
23
|
+
# @param sort [String, nil] the field to sort on, such as +"openedAt"+ or +"severity"+
|
|
24
|
+
# @param order [Symbol, String, nil] +:asc+ or +:desc+
|
|
25
|
+
# @return [Page<Escalation>]
|
|
26
|
+
# @raise [ArgumentError] the status is not one of the three, the page is negative, or the order
|
|
27
|
+
# is not one of the two
|
|
28
|
+
def list(status: nil, severity: nil, identity: nil, rule: nil, page: 0, page_size: nil, sort: nil,
|
|
29
|
+
order: nil)
|
|
30
|
+
filters = list_filters(status: status, severity: severity, identity: identity, rule: rule)
|
|
31
|
+
|
|
32
|
+
list_page(
|
|
33
|
+
"/escalations", Escalation, filters, page: page, page_size: page_size, sort: sort, order: order
|
|
34
|
+
)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Walks every escalation that matches, fetching pages as it goes. Takes the filters {#list}
|
|
38
|
+
# does.
|
|
39
|
+
#
|
|
40
|
+
# client.escalations.list_all(status: :open, severity: :critical).each do |escalation|
|
|
41
|
+
# notify_on_call(escalation.identity.id)
|
|
42
|
+
# client.escalations.update(escalation.id, status: :acknowledged)
|
|
43
|
+
# end
|
|
44
|
+
#
|
|
45
|
+
# @return [Enumerator<Escalation>] a lazy enumerator, which fetches a page only when the items
|
|
46
|
+
# before it have been used up; nil when a block is given, which is called with each escalation
|
|
47
|
+
# @raise [ArgumentError] the status is not one of the three, or the order is not one of the two
|
|
48
|
+
def list_all(status: nil, severity: nil, identity: nil, rule: nil, page_size: nil, sort: nil, order: nil,
|
|
49
|
+
&block)
|
|
50
|
+
filters = list_filters(status: status, severity: severity, identity: identity, rule: rule)
|
|
51
|
+
|
|
52
|
+
list_every("/escalations", Escalation, filters, page_size: page_size, sort: sort, order: order, &block)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Returns the escalation with this id.
|
|
56
|
+
#
|
|
57
|
+
# @param escalation_id [Integer, String]
|
|
58
|
+
# @return [Escalation]
|
|
59
|
+
# @raise [NotFoundError] no escalation has this id
|
|
60
|
+
def get(escalation_id)
|
|
61
|
+
Escalation.from_api(@client.request(:get, path_for(escalation_id)))
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Moves the escalation to +status+ and returns it.
|
|
65
|
+
#
|
|
66
|
+
# @param escalation_id [Integer, String]
|
|
67
|
+
# @param status [Symbol, String] +:open+, +:acknowledged+, or +:closed+
|
|
68
|
+
# @return [Escalation]
|
|
69
|
+
# @raise [NotFoundError] no escalation has this id
|
|
70
|
+
# @raise [ArgumentError] the status is not one of the three
|
|
71
|
+
def update(escalation_id, status:)
|
|
72
|
+
body = { "status" => status_param(status) }
|
|
73
|
+
|
|
74
|
+
Escalation.from_api(@client.request(:patch, path_for(escalation_id), body: body))
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Returns how many escalations are open, acknowledged, and closed.
|
|
78
|
+
#
|
|
79
|
+
# @return [EscalationSummary]
|
|
80
|
+
def summary
|
|
81
|
+
EscalationSummary.from_api(@client.request(:get, "/escalations/summary"))
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
private
|
|
85
|
+
|
|
86
|
+
def path_for(escalation_id)
|
|
87
|
+
"/escalations/#{segment(escalation_id, "An escalation id")}"
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def list_filters(status:, severity:, identity:, rule:)
|
|
91
|
+
{
|
|
92
|
+
"status" => status.nil? ? nil : status_param(status),
|
|
93
|
+
"severity" => severity.nil? || severity.to_s.empty? ? nil : severity.to_s.upcase,
|
|
94
|
+
"identity" => identity,
|
|
95
|
+
"rule" => rule
|
|
96
|
+
}
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def status_param(status)
|
|
100
|
+
value = status.to_s.upcase if status.is_a?(String) || status.is_a?(Symbol)
|
|
101
|
+
|
|
102
|
+
raise ArgumentError, "status is :open, :acknowledged, or :closed." unless STATUSES.include?(value)
|
|
103
|
+
|
|
104
|
+
value
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|