watchfor 0.14.0 → 0.15.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5710747dad8c121a91b701267655d89cfdfdacdc895a8413f15b7e80ca1998e0
4
- data.tar.gz: ce69c62e364fdc582a8f2bdab673bfa5f3d144814918730579fd823d260345be
3
+ metadata.gz: f5ad40afc1187be8bcdf41c4c418b3c18b304b097a901219edfe777ae41087d1
4
+ data.tar.gz: b4da046bc763240825953b51a69e13e50838272d7e642bf78f450b4f7914810d
5
5
  SHA512:
6
- metadata.gz: f05f3baa7d1b773dcc32d25641f633c7577f2711139e4af7827d7b11e53e22e0cb24f3d21ce692013386cf0650030b24e02f34df1566f328ec56af471d09a289
7
- data.tar.gz: c2285f93fe30565356cc821bb60cb096869afa46246ce70e4cec05ed3689a3c66a73ad23c43689a7b58fd3a858a4d0a7e241b926e46a27e6b07420dcdc7cb910
6
+ metadata.gz: 73e41e8dc6673385ab4bfca74bb41a6e2d83d08429b88ff1bf826c835201295eda5c5b1edd76cf32518cb86ba9829b4b8d9b4641e9f1ee0f5a50743e2fe848e6
7
+ data.tar.gz: 1a4951d407a131eec5206d4b909de80a26f4833aaa916a08aa914846c6f71b8b6161cb5ceb90b982c3a0a2ce91fb465d5f7c297ce7bac2db92215e3b3481160d
data/README.md CHANGED
@@ -59,6 +59,19 @@ wf.monitors.checks(mon["id"], hours: 24, limit: 100, cursor: page["next_cursor"]
59
59
 
60
60
  # Delete a contact group that alert rules still use (409 without force)
61
61
  wf.contact_groups.delete("<group-id>", force: true)
62
+
63
+ # What the alerting engine sees right now: alerted, recovering 1/3, confirming 2 of 3…
64
+ state = wf.monitors.get(mon["id"])["alerting_state"]
65
+
66
+ # Response times on a real time axis (5-min buckets for 24h, 15 min for 7d)
67
+ up = wf.monitors.uptime(mon["id"], "24h", series: true)
68
+ (up["response_time_series"] || []).each { |b| puts "#{b['at']} #{b['avg_ms']}" }
69
+
70
+ # Pause up to 100 monitors at once; post a status-page announcement
71
+ wf.monitors.bulk("pause", ["<id-1>", "<id-2>"])
72
+ notice = wf.status_pages.create_announcement("<page-id>",
73
+ { title: "Checkout errors", body: "We are looking into it." })
74
+ wf.status_pages.update_announcement("<page-id>", notice["id"], { body: "Fixed.", status: "resolved" })
62
75
  ```
63
76
 
64
77
  ### Live diagnostics
@@ -102,11 +115,38 @@ Details: <https://watchfor.io/docs/api/diagnostics>
102
115
 
103
116
  Namespaces: `wf.monitors`, `wf.alert_rules`, `wf.incidents`,
104
117
  `wf.hosts`, `wf.status_pages`, `wf.on_call`, `wf.notification_channels`, `wf.maintenance_windows`, `wf.contacts`, `wf.contact_groups`,
105
- `wf.diagnostics`. Top-level:
118
+ `wf.diagnostics`, `wf.playwright`, `wf.variables`. Top-level:
106
119
  `wf.summary`, `wf.plan`, `wf.me`, `wf.locations`, `wf.monitor_types`,
107
- `wf.incident_stats(period)`, `wf.notifications`, `wf.activity`. Errors raise
120
+ `wf.incident_stats(period)`, `wf.report`, `wf.notifications`, `wf.activity`,
121
+ `wf.batch([...])`, `wf.metrics`. Errors raise
108
122
  `Watchfor::Error` with `#status`, `#code`, `#message`.
109
123
 
124
+ ## Organizations
125
+
126
+ An API key belongs to one organization. An **OAuth access token** acts as
127
+ the user who approved it, so it reaches every organization that user
128
+ belongs to; choose one with `organization:` — the SDK sends it as the
129
+ `X-WatchFor-Organization` header on every request:
130
+
131
+ ```ruby
132
+ acme = Watchfor::Client.new(api_key: oauth_access_token, organization: "<organization-id>")
133
+ acme.me # {"organization" => {"id", "name", "slug"}, "scope" => ...}
134
+ other = acme.with_organization("<another-organization-id>") # new client, same token
135
+ other.monitors.list
136
+ ```
137
+
138
+ - Without `organization:`, an OAuth token acts on the user's oldest owned
139
+ organization (else their oldest membership).
140
+ - The user must be a member of that organization (otherwise `403
141
+ forbidden`), and their role there applies: members read, acknowledge
142
+ incidents and run diagnostics; changes need owner or admin.
143
+ - With an API key leave it unset; it may only repeat the key's own
144
+ organization id, and any other id is `403 forbidden` (a key never
145
+ switches organization).
146
+ - `with_organization(nil)` drops the header again.
147
+
148
+ Details: <https://watchfor.io/docs/api/authentication#how-oauth-maps-to-your-data>
149
+
110
150
  ## Prometheus metrics (Pro plan and above)
111
151
 
112
152
  ```ruby
@@ -124,6 +164,12 @@ watchfor monitors
124
164
  watchfor incidents
125
165
  watchfor checks <monitor_id> [--cursor <next_cursor>]
126
166
  watchfor report --period 30d
167
+ watchfor me # which organization + scope
168
+
169
+ # OAuth token: pick the organization per call or via the environment
170
+ export WATCHFOR_API_KEY=<oauth-access-token>
171
+ watchfor --organization <organization-id> monitors
172
+ WATCHFOR_ORGANIZATION=<organization-id> watchfor summary
127
173
  ```
128
174
 
129
175
  Reference: <https://watchfor.io/openapi.json> · <https://watchfor.io/docs/api>
data/exe/watchfor CHANGED
@@ -8,6 +8,22 @@ api_key = ENV["WATCHFOR_API_KEY"]
8
8
  base_url = ENV.fetch("WATCHFOR_BASE_URL", Watchfor::DEFAULT_BASE_URL)
9
9
 
10
10
  args = ARGV.dup
11
+
12
+ # Global --organization <id> / --organization=<id>, anywhere on the line;
13
+ # WATCHFOR_ORGANIZATION is the default. OAuth tokens reach every
14
+ # organization their user belongs to; an API key only accepts its own id.
15
+ organization = ENV.fetch("WATCHFOR_ORGANIZATION", nil)
16
+ if (i = args.index("--organization"))
17
+ organization = args[i + 1].to_s
18
+ args.slice!(i, 2)
19
+ if organization.strip.empty?
20
+ warn "--organization needs an organization id"
21
+ exit 2
22
+ end
23
+ elsif (i = args.index { |a| a.start_with?("--organization=") })
24
+ organization = args.delete_at(i).split("=", 2)[1].to_s
25
+ end
26
+
11
27
  command = args.shift
12
28
 
13
29
  if command == "--version"
@@ -16,15 +32,16 @@ if command == "--version"
16
32
  end
17
33
 
18
34
  if api_key.nil? || api_key.empty?
19
- warn "No API key. Set WATCHFOR_API_KEY."
35
+ warn "No API key. Set WATCHFOR_API_KEY (an API key or an OAuth access token)."
20
36
  exit 2
21
37
  end
22
38
 
23
- wf = Watchfor::Client.new(api_key: api_key, base_url: base_url)
39
+ wf = Watchfor::Client.new(api_key: api_key, base_url: base_url, organization: organization)
24
40
 
25
41
  begin
26
42
  result =
27
43
  case command
44
+ when "me" then wf.me
28
45
  when "summary" then wf.summary
29
46
  when "plan" then wf.plan
30
47
  when "monitors" then wf.monitors.list
@@ -42,7 +59,10 @@ begin
42
59
  print wf.metrics(tag: tag, type: type)
43
60
  exit 0
44
61
  else
45
- warn "Usage: watchfor [summary|plan|monitors|locations|monitor-types|incidents|checks <monitor_id> [--cursor c]|report [--period 7d|30d]|metrics [--tag t] [--type t]]"
62
+ warn "Usage: watchfor [--organization <id>] " \
63
+ "[me|summary|plan|monitors|locations|monitor-types|incidents|checks <monitor_id> [--cursor c]|" \
64
+ "report [--period 7d|30d]|metrics [--tag t] [--type t]]\n" \
65
+ "Env: WATCHFOR_API_KEY (API key or OAuth access token), WATCHFOR_ORGANIZATION, WATCHFOR_BASE_URL"
46
66
  exit 2
47
67
  end
48
68
  puts JSON.pretty_generate(result)
@@ -6,6 +6,8 @@ require "uri"
6
6
 
7
7
  module Watchfor
8
8
  DEFAULT_BASE_URL = "https://watchfor.io/api/v1"
9
+ # The request header that selects the organization an OAuth token acts on.
10
+ ORGANIZATION_HEADER = "X-WatchFor-Organization"
9
11
 
10
12
  # Raised when the API returns a non-2xx response. Carries the HTTP
11
13
  # +status+ and the API's structured +code+ and +message+.
@@ -24,17 +26,28 @@ module Watchfor
24
26
  # wf = Watchfor::Client.new(api_key: "wf_live_...")
25
27
  # wf.summary
26
28
  # wf.monitors.list["data"].each { |m| puts "#{m['name']} #{m['status']}" }
29
+ #
30
+ # An OAuth access token reaches every organization its user belongs to;
31
+ # pick one with +organization:+ (sent as X-WatchFor-Organization):
32
+ #
33
+ # acme = Watchfor::Client.new(api_key: oauth_token, organization: "<org id>")
34
+ # other = acme.with_organization("<another org id>")
35
+ #
36
+ # An API key belongs to one organization: leave +organization:+ unset, or
37
+ # pass that same id (any other id is 403 forbidden).
27
38
  class Client
28
39
  attr_reader :monitors, :alert_rules, :incidents, :hosts, :status_pages, :on_call, :notification_channels,
29
40
  :maintenance_windows, :contacts, :contact_groups,
30
- :diagnostics, :playwright, :variables
41
+ :diagnostics, :playwright, :variables, :organization
31
42
 
32
- def initialize(api_key:, base_url: DEFAULT_BASE_URL, timeout: 30)
43
+ def initialize(api_key:, base_url: DEFAULT_BASE_URL, timeout: 30, organization: nil)
33
44
  raise ArgumentError, "api_key is required" if api_key.nil? || api_key.empty?
34
45
 
35
46
  @api_key = api_key
36
47
  @base_url = base_url.chomp("/")
37
48
  @timeout = timeout
49
+ org = organization.to_s.strip
50
+ @organization = org.empty? ? nil : org
38
51
  @monitors = Monitors.new(self)
39
52
  @alert_rules = AlertRules.new(self)
40
53
  @incidents = Incidents.new(self)
@@ -50,6 +63,14 @@ module Watchfor
50
63
  @variables = Variables.new(self)
51
64
  end
52
65
 
66
+ # A new client acting on another organization (same credential and
67
+ # settings); nil drops the header so the credential's default
68
+ # organization applies. This client is left unchanged.
69
+ def with_organization(organization)
70
+ self.class.new(api_key: @api_key, base_url: @base_url, timeout: @timeout,
71
+ organization: organization)
72
+ end
73
+
53
74
  # Core request. +query+ and +body+ are optional hashes.
54
75
  def request(method, path, query: nil, body: nil, idempotency_key: nil, text: false)
55
76
  uri = URI("#{@base_url}#{path}")
@@ -68,6 +89,7 @@ module Watchfor
68
89
  req["Accept"] = text ? "text/plain, application/json" : "application/json"
69
90
  req["User-Agent"] = "watchfor-ruby/#{Watchfor::VERSION}"
70
91
  req["Idempotency-Key"] = idempotency_key if idempotency_key
92
+ req[ORGANIZATION_HEADER] = @organization if @organization
71
93
  unless body.nil?
72
94
  req["Content-Type"] = "application/json"
73
95
  req.body = JSON.generate(body)
@@ -92,7 +114,17 @@ module Watchfor
92
114
  end
93
115
 
94
116
  # Top-level reads
117
+ # The credential's scope and the organization it acts on (with an OAuth
118
+ # token, the one +organization:+ selected).
95
119
  def me = request("GET", "/me")
120
+
121
+ # Run up to 20 operations ({ method:, path:, body: } with a relative
122
+ # /api/v1 path) in one request, with the same credential and
123
+ # organization; "results" come back in request order as {status, body}.
124
+ def batch(operations) = request("POST", "/batch", body: { operations: operations })
125
+
126
+ # The OpenAPI 3.1 document this client wraps (no key needed).
127
+ def openapi_spec = request("GET", "/openapi.json")
96
128
  def summary = request("GET", "/summary")
97
129
 
98
130
  # The organization in Prometheus text format (Pro plan and above):
@@ -108,11 +140,24 @@ module Watchfor
108
140
  # Org report vs the previous period (period: "7d"/"30d", or from/to ISO 8601
109
141
  # on advanced-reports plans): uptime, incidents, MTTR/MTBF, response-time
110
142
  # trend, per-monitor rows, type-aware sections and plain-language insights.
111
- def report(period: nil, from: nil, to: nil)
143
+ #
144
+ # Scoping (advanced-reports plans): monitor_ids:/tags: (union), sections:
145
+ # (locations, ssl, domains, cwv, heartbeat, blacklist, mcp, hosts),
146
+ # incident_status: (acknowledged, resolved, open) — arrays or
147
+ # comma-separated strings. false for response_time:/monitors_table:/
148
+ # insights: drops that block.
149
+ def report(period: nil, from: nil, to: nil, monitor_ids: nil, tags: nil, sections: nil,
150
+ response_time: nil, monitors_table: nil, insights: nil, incident_status: nil)
151
+ csv = ->(v) { v.is_a?(Array) ? v.join(",") : v }
152
+ query = { monitor_ids: csv.call(monitor_ids), tags: csv.call(tags), sections: csv.call(sections),
153
+ incident_status: csv.call(incident_status),
154
+ response_time: response_time.nil? ? nil : response_time.to_s,
155
+ monitors_table: monitors_table.nil? ? nil : monitors_table.to_s,
156
+ insights: insights.nil? ? nil : insights.to_s }
112
157
  if from || to
113
- request("GET", "/reports", query: { from: from, to: to })
158
+ request("GET", "/reports", query: query.merge(from: from, to: to))
114
159
  else
115
- request("GET", "/reports", query: { period: period })
160
+ request("GET", "/reports", query: query.merge(period: period))
116
161
  end
117
162
  end
118
163
  # The org's two report-email cadences (weekly + monthly); set toggles
@@ -131,8 +176,17 @@ module Watchfor
131
176
 
132
177
  class Monitors < Namespace
133
178
  def list(**query) = @c.request("GET", "/monitors", query: query)
134
- # The monitor plus available_metrics (alert metrics valid for its type).
179
+ # The monitor plus available_metrics (alert metrics valid for its type)
180
+ # and alerting_state — what the alerting engine sees right now (alerted,
181
+ # recovering n of m, failing/confirming n of m; nil when every rule is
182
+ # healthy).
135
183
  def get(id) = @c.request("GET", "/monitors/#{id}")
184
+ # Pause, resume or delete (+action+) up to 100 monitors in one call; ids
185
+ # that are not yours or need no change come back in "skipped".
186
+ def bulk(action, ids, idempotency_key: nil)
187
+ @c.request("POST", "/monitors/bulk", body: { action: action, ids: ids },
188
+ idempotency_key: idempotency_key)
189
+ end
136
190
  # Credentials (catalog fields marked secret: mailbox/SMTP/FTP passwords,
137
191
  # SSH keys, HTTP/API/MCP auth passwords, bearer tokens and credential
138
192
  # header values) are write-only: stored encrypted, returned as
@@ -142,7 +196,9 @@ module Watchfor
142
196
  # config is merged (omitted keys kept, nil removes). A "[redacted]"
143
197
  # credential is kept when omitted or sent back; changing the server,
144
198
  # port, TLS mode or login it is used with (HTTP: the URL's host, port
145
- # or scheme) needs it again (else 400).
199
+ # or scheme) needs it again (else 400). "check_queued" => true in the
200
+ # result means the change dispatched one immediate check, outside the
201
+ # Run-now budget.
146
202
  def update(id, body) = @c.request("PATCH", "/monitors/#{id}", body: body)
147
203
  def delete(id) = @c.request("DELETE", "/monitors/#{id}")
148
204
  def pause(id) = @c.request("POST", "/monitors/#{id}/pause")
@@ -153,7 +209,13 @@ module Watchfor
153
209
  # monitor and starts uptime over ("since reset"); the monitor and its
154
210
  # configuration, alert rules, tags and locations stay.
155
211
  def reset(id) = @c.request("POST", "/monitors/#{id}/reset")
156
- def uptime(id, period = nil) = @c.request("GET", "/monitors/#{id}/uptime", query: { period: period })
212
+ # Uptime (confirmed critical incidents only), check success and
213
+ # response-time stats. series: true (periods up to 7d) adds
214
+ # "response_time_series" — buckets of "series_bucket_seconds" (300 for
215
+ # 24h, 900 for 7d) on a real time axis, oldest first.
216
+ def uptime(id, period = nil, series: false)
217
+ @c.request("GET", "/monitors/#{id}/uptime", query: { period: period, series: series ? "true" : nil })
218
+ end
157
219
  # Recent probe results, newest first. Page with the response's next_cursor -> cursor:
158
220
  # (keep the same hours/success); "locations" is the per-location breakdown for the whole window.
159
221
  def checks(id, hours: nil, limit: nil, success: nil, cursor: nil, **query)
@@ -209,7 +271,9 @@ module Watchfor
209
271
  class AlertRules < Namespace
210
272
  def list(monitor_id) = @c.request("GET", "/monitors/#{monitor_id}/alert-rules")
211
273
  def get(monitor_id, rule_id) = @c.request("GET", "/monitors/#{monitor_id}/alert-rules/#{rule_id}")
212
- def create(monitor_id, body) = @c.request("POST", "/monitors/#{monitor_id}/alert-rules", body: body)
274
+ def create(monitor_id, body, idempotency_key: nil)
275
+ @c.request("POST", "/monitors/#{monitor_id}/alert-rules", body: body, idempotency_key: idempotency_key)
276
+ end
213
277
  def update(monitor_id, rule_id, body) = @c.request("PATCH", "/monitors/#{monitor_id}/alert-rules/#{rule_id}", body: body)
214
278
  def delete(monitor_id, rule_id) = @c.request("DELETE", "/monitors/#{monitor_id}/alert-rules/#{rule_id}")
215
279
  end
@@ -225,12 +289,40 @@ module Watchfor
225
289
  def exclude(id, reason: nil) = @c.request("POST", "/incidents/#{id}/exclude", body: reason.nil? ? {} : { reason: reason })
226
290
  # Undo #exclude - the incident counts as downtime again.
227
291
  def include(id) = @c.request("POST", "/incidents/#{id}/include")
292
+ # Retroactively count a RESOLVED incident as planned maintenance: it
293
+ # leaves uptime and SLA. Returns the "maintenance_window" recorded for it
294
+ # (marking again returns the same one); delete that window to undo.
295
+ def mark_maintenance(id, idempotency_key: nil)
296
+ @c.request("POST", "/incidents/#{id}/mark-maintenance", idempotency_key: idempotency_key)
297
+ end
228
298
  end
229
299
 
230
300
  class StatusPages < Namespace
231
301
  def list(**query) = @c.request("GET", "/status-pages", query: query)
232
302
  def get(id) = @c.request("GET", "/status-pages/#{id}")
303
+ # Create a page (slug, name, optional components...). Password protection,
304
+ # logo upload and custom subdomains are dashboard-only.
305
+ def create(body, idempotency_key: nil) = @c.request("POST", "/status-pages", body: body, idempotency_key: idempotency_key)
306
+ # Partial update; a slug change frees the old URL at once.
307
+ def update(id, body) = @c.request("PATCH", "/status-pages/#{id}", body: body)
308
+ def delete(id) = @c.request("DELETE", "/status-pages/#{id}")
309
+ # Announcements, newest first.
233
310
  def announcements(id, **query) = @c.request("GET", "/status-pages/#{id}/announcements", query: query)
311
+ def get_announcement(id, announcement_id) = @c.request("GET", "/status-pages/#{id}/announcements/#{announcement_id}")
312
+ # Publish an incident notice (title, body, optional status, severity,
313
+ # affectedComponentIds); body becomes the first timeline entry.
314
+ def create_announcement(id, body, idempotency_key: nil)
315
+ @c.request("POST", "/status-pages/#{id}/announcements", body: body, idempotency_key: idempotency_key)
316
+ end
317
+ # Post an update (body + status): appends to the public timeline;
318
+ # "resolved" closes it.
319
+ def update_announcement(id, announcement_id, body)
320
+ @c.request("PATCH", "/status-pages/#{id}/announcements/#{announcement_id}", body: body)
321
+ end
322
+ # Remove from the public page (soft delete).
323
+ def delete_announcement(id, announcement_id)
324
+ @c.request("DELETE", "/status-pages/#{id}/announcements/#{announcement_id}")
325
+ end
234
326
  end
235
327
 
236
328
  class OnCall < Namespace
@@ -249,6 +341,7 @@ module Watchfor
249
341
  end
250
342
 
251
343
  class MaintenanceWindows < Namespace
344
+ # state: "active" / "upcoming" keeps only live windows.
252
345
  def list(**query) = @c.request("GET", "/maintenance-windows", query: query)
253
346
  def get(id) = @c.request("GET", "/maintenance-windows/#{id}")
254
347
  def create(body, idempotency_key: nil) = @c.request("POST", "/maintenance-windows", body: body, idempotency_key: idempotency_key)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Watchfor
4
- VERSION = "0.14.0"
4
+ VERSION = "0.15.0"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: watchfor
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.14.0
4
+ version: 0.15.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - WatchFor
@@ -24,11 +24,11 @@ files:
24
24
  - lib/watchfor.rb
25
25
  - lib/watchfor/client.rb
26
26
  - lib/watchfor/version.rb
27
- homepage: https://watchfor.io/docs/api
27
+ homepage: https://watchfor.io
28
28
  licenses:
29
29
  - MIT
30
30
  metadata:
31
- homepage_uri: https://watchfor.io/docs/api
31
+ homepage_uri: https://watchfor.io
32
32
  documentation_uri: https://watchfor.io/docs/api
33
33
  changelog_uri: https://watchfor.io/changelog
34
34
  rubygems_mfa_required: 'true'