upright 0.2.0 → 0.3.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/README.md +143 -50
- data/app/assets/stylesheets/upright/uptime-bars.css +10 -1
- data/app/controllers/upright/probe_results_controller.rb +1 -0
- data/app/controllers/upright/sessions_controller.rb +1 -0
- data/app/helpers/upright/probe_results_helper.rb +3 -10
- data/app/models/concerns/upright/playwright/form_authentication.rb +0 -3
- data/app/models/concerns/upright/probe_result/stale_cleanup.rb +23 -0
- data/app/models/concerns/upright/probeable.rb +7 -1
- data/app/models/upright/http/request.rb +1 -1
- data/app/models/upright/probe_result.rb +6 -1
- data/app/models/upright/probes/status.rb +3 -3
- data/app/models/upright/probes/uptime.rb +4 -4
- data/app/views/upright/dashboards/_uptime_probe_row.html.erb +5 -3
- data/app/views/upright/dashboards/probe_statuses/show.html.erb +1 -1
- data/app/views/upright/dashboards/uptimes/show.html.erb +1 -1
- data/app/views/upright/probe_results/index.html.erb +7 -4
- data/config/ci.rb +2 -0
- data/lib/generators/upright/install/install_generator.rb +43 -2
- data/lib/generators/upright/install/templates/http_probes.yml +1 -0
- data/lib/generators/upright/install/templates/recurring.yml +25 -0
- data/lib/generators/upright/install/templates/smtp_probes.yml +3 -0
- data/lib/generators/upright/install/templates/traceroute_probes.yml +3 -0
- data/lib/generators/upright/install/templates/upright.rb +3 -0
- data/lib/generators/upright/install/templates/upright.rules.yml +5 -5
- data/lib/upright/configuration.rb +14 -0
- data/lib/upright/engine.rb +7 -0
- data/lib/upright/metrics.rb +2 -2
- data/lib/upright/probe_type_registry.rb +33 -0
- data/lib/upright/version.rb +1 -1
- data/lib/upright.rb +5 -0
- metadata +5 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: ccb561e794bdafa5e31caed2347f7d371d02e5230ca875c5f507d1d0620d7363
|
|
4
|
+
data.tar.gz: 3bc20100e850cdd93a81e58b7e9b6f3ef9377c44b17bbd7891dbe697699b23a6
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 474970b3ccbce05a396ceb2a3575a58272b602d679cf70d55d10538cc546aa31d6142e1de401ee5098c6c3a2733a624cba54e34b79a0dcd9c6af7ad7e8124f0d
|
|
7
|
+
data.tar.gz: 35ed0971379b1c50dd9583f95ed27aa2586f53002e8acd81c05851797d6f2d1c59ced8d6a08817613ecb89ab376d453a9697cf241b82be1409c11f1caa87c6d3
|
data/README.md
CHANGED
|
@@ -47,7 +47,8 @@ Upright is a self-hosted synthetic monitoring system. It provides a framework fo
|
|
|
47
47
|
|
|
48
48
|
## Installation
|
|
49
49
|
|
|
50
|
-
|
|
50
|
+
> [!NOTE]
|
|
51
|
+
> Upright is designed to be run in its own Rails app and deployed with Kamal.
|
|
51
52
|
|
|
52
53
|
### Quick Start (New Project)
|
|
53
54
|
|
|
@@ -157,6 +158,91 @@ Upright.configure do |config|
|
|
|
157
158
|
end
|
|
158
159
|
```
|
|
159
160
|
|
|
161
|
+
### Probe Result Cleanup
|
|
162
|
+
|
|
163
|
+
Upright automatically cleans up old probe results on a recurring schedule. You can configure the retention thresholds:
|
|
164
|
+
|
|
165
|
+
```ruby
|
|
166
|
+
Upright.configure do |config|
|
|
167
|
+
config.stale_success_threshold = 24.hours # Delete successful results older than this (default: 24 hours)
|
|
168
|
+
config.stale_failure_threshold = 30.days # Delete failed results older than this (default: 30 days)
|
|
169
|
+
config.failure_retention_limit = 20_000 # Keep at most this many failed results (default: 20,000)
|
|
170
|
+
end
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
## Custom Probe Types
|
|
174
|
+
|
|
175
|
+
Upright ships with four built-in probe types: HTTP, Playwright, SMTP, and Traceroute. You can register your own to extend the system.
|
|
176
|
+
|
|
177
|
+
### 1. Register the type
|
|
178
|
+
|
|
179
|
+
Add it to your initializer so Upright knows about its name and icon:
|
|
180
|
+
|
|
181
|
+
```ruby
|
|
182
|
+
# config/initializers/upright.rb
|
|
183
|
+
Upright.configure do |config|
|
|
184
|
+
config.probe_types.register :ping, name: "Ping", icon: "📶"
|
|
185
|
+
end
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
### 2. Create the probe class
|
|
189
|
+
|
|
190
|
+
Add a Ruby class in your `probes/` directory that extends `FrozenRecord::Base` and includes `Upright::Probeable` and `Upright::ProbeYamlSource`. Implement `probe_type`, `probe_target`, `check`, and `on_check_recorded`:
|
|
191
|
+
|
|
192
|
+
```ruby
|
|
193
|
+
# probes/ping_probe.rb
|
|
194
|
+
class PingProbe < FrozenRecord::Base
|
|
195
|
+
include Upright::Probeable
|
|
196
|
+
include Upright::ProbeYamlSource
|
|
197
|
+
|
|
198
|
+
stagger_by_site 3.seconds
|
|
199
|
+
|
|
200
|
+
def check
|
|
201
|
+
@ping_output, status = Open3.capture2e("ping", "-c", "1", "-W", "5", host)
|
|
202
|
+
status.success?
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def on_check_recorded(probe_result)
|
|
206
|
+
if @ping_output.present?
|
|
207
|
+
Upright::Artifact.new(name: "ping.log", content: @ping_output).attach_to(probe_result)
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def probe_type = "ping"
|
|
212
|
+
def probe_target = host
|
|
213
|
+
end
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
The `check` method should return a truthy value on success and a falsy value on failure.
|
|
217
|
+
|
|
218
|
+
### 3. Define probes in YAML
|
|
219
|
+
|
|
220
|
+
Create a YAML file matching the class name (e.g., `PingProbe` → `probes/ping_probes.yml`):
|
|
221
|
+
|
|
222
|
+
```yaml
|
|
223
|
+
# probes/ping_probes.yml
|
|
224
|
+
- name: "Cloudflare DNS"
|
|
225
|
+
host: "1.1.1.1"
|
|
226
|
+
|
|
227
|
+
- name: "Google DNS"
|
|
228
|
+
host: "8.8.8.8"
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
Fields defined in the YAML are available as methods on the probe instance (e.g., `host`).
|
|
232
|
+
|
|
233
|
+
### 4. Schedule it
|
|
234
|
+
|
|
235
|
+
Add a recurring job in `config/recurring.yml`:
|
|
236
|
+
|
|
237
|
+
```yaml
|
|
238
|
+
production:
|
|
239
|
+
ping_probes:
|
|
240
|
+
command: "PingProbe.check_and_record_all_later"
|
|
241
|
+
schedule: every 30 seconds
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
The custom type will automatically appear in all dashboard dropdowns and filter links.
|
|
245
|
+
|
|
160
246
|
## Defining Probes
|
|
161
247
|
|
|
162
248
|
### HTTP Probes
|
|
@@ -171,12 +257,15 @@ Add probes to `probes/http_probes.yml`:
|
|
|
171
257
|
- name: API Health
|
|
172
258
|
url: https://api.example.com/health
|
|
173
259
|
expected_status: 200
|
|
260
|
+
alert_severity: critical
|
|
174
261
|
|
|
175
262
|
- name: Admin Panel
|
|
176
263
|
url: https://admin.example.com
|
|
177
264
|
basic_auth_credentials: admin_auth # Key in Rails credentials
|
|
178
265
|
```
|
|
179
266
|
|
|
267
|
+
The optional `alert_severity` field controls the Prometheus alert severity when a probe fails. Values: `medium`, `high` (default), `critical`.
|
|
268
|
+
|
|
180
269
|
### SMTP Probes
|
|
181
270
|
|
|
182
271
|
Add probes to `probes/smtp_probes.yml`:
|
|
@@ -317,70 +406,82 @@ image: your-org/upright
|
|
|
317
406
|
servers:
|
|
318
407
|
web:
|
|
319
408
|
hosts:
|
|
320
|
-
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
hosts:
|
|
326
|
-
- ams.upright.example.com
|
|
327
|
-
env:
|
|
328
|
-
tags:
|
|
329
|
-
SITE_SUBDOMAIN: ams
|
|
330
|
-
|
|
409
|
+
- ams.upright.example.com: [amsterdam]
|
|
410
|
+
- nyc.upright.example.com: [new_york]
|
|
411
|
+
- sfo.upright.example.com: [san_francisco]
|
|
412
|
+
jobs:
|
|
331
413
|
hosts:
|
|
332
|
-
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
414
|
+
- ams.upright.example.com: [amsterdam]
|
|
415
|
+
- nyc.upright.example.com: [new_york]
|
|
416
|
+
- sfo.upright.example.com: [san_francisco]
|
|
417
|
+
cmd: bin/jobs
|
|
336
418
|
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
-
|
|
419
|
+
proxy:
|
|
420
|
+
app_port: 3000
|
|
421
|
+
ssl: true
|
|
422
|
+
hosts:
|
|
423
|
+
- "*.upright.example.com"
|
|
342
424
|
|
|
343
425
|
env:
|
|
344
|
-
clear:
|
|
345
|
-
RAILS_ENV: production
|
|
346
|
-
RAILS_LOG_TO_STDOUT: true
|
|
347
426
|
secret:
|
|
348
427
|
- RAILS_MASTER_KEY
|
|
349
|
-
|
|
350
|
-
|
|
428
|
+
tags:
|
|
429
|
+
amsterdam:
|
|
430
|
+
SITE_SUBDOMAIN: ams
|
|
431
|
+
new_york:
|
|
432
|
+
SITE_SUBDOMAIN: nyc
|
|
433
|
+
san_francisco:
|
|
434
|
+
SITE_SUBDOMAIN: sfo
|
|
351
435
|
|
|
352
436
|
accessories:
|
|
353
437
|
playwright:
|
|
354
|
-
image:
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
438
|
+
image: jacoblincool/playwright:chromium-server-1.55.0
|
|
439
|
+
port: "127.0.0.1:53333:53333"
|
|
440
|
+
roles:
|
|
441
|
+
- jobs
|
|
442
|
+
|
|
443
|
+
prometheus:
|
|
444
|
+
image: prom/prometheus:v3.2.1
|
|
445
|
+
hosts:
|
|
446
|
+
- ams.upright.example.com
|
|
447
|
+
cmd: >-
|
|
448
|
+
--config.file=/etc/prometheus/prometheus.yml
|
|
449
|
+
--storage.tsdb.path=/prometheus
|
|
450
|
+
--storage.tsdb.retention.time=30d
|
|
451
|
+
--web.enable-otlp-receiver
|
|
452
|
+
files:
|
|
453
|
+
- config/prometheus/prometheus.yml:/etc/prometheus/prometheus.yml
|
|
454
|
+
- config/prometheus/rules/upright.rules.yml:/etc/prometheus/rules/upright.rules.yml
|
|
455
|
+
|
|
456
|
+
alertmanager:
|
|
457
|
+
image: prom/alertmanager:v0.28.1
|
|
458
|
+
hosts:
|
|
459
|
+
- ams.upright.example.com
|
|
460
|
+
cmd: --config.file=/etc/alertmanager/alertmanager.yml
|
|
461
|
+
files:
|
|
462
|
+
- config/alertmanager/alertmanager.yml:/etc/alertmanager/alertmanager.yml
|
|
361
463
|
```
|
|
362
464
|
|
|
363
465
|
## Observability
|
|
364
466
|
|
|
365
467
|
### Prometheus
|
|
366
468
|
|
|
367
|
-
|
|
469
|
+
Metrics are exposed via a Puma plugin at `http://0.0.0.0:9394/metrics`. Configure Prometheus to scrape:
|
|
368
470
|
|
|
369
471
|
```yaml
|
|
370
472
|
scrape_configs:
|
|
371
473
|
- job_name: upright
|
|
372
474
|
static_configs:
|
|
373
|
-
- targets: ['localhost:
|
|
374
|
-
metrics_path: /metrics
|
|
475
|
+
- targets: ['localhost:9394']
|
|
375
476
|
```
|
|
376
477
|
|
|
377
478
|
### Metrics Exposed
|
|
378
479
|
|
|
379
480
|
- `upright_probe_duration_seconds` - Probe execution duration
|
|
380
|
-
- `
|
|
381
|
-
- `
|
|
481
|
+
- `upright_probe_up` - Probe status (1 = up, 0 = down)
|
|
482
|
+
- `upright_http_response_status` - HTTP response status code
|
|
382
483
|
|
|
383
|
-
Labels include: `
|
|
484
|
+
Labels include: `type`, `name`, `site_code`, `site_city`, `site_country`
|
|
384
485
|
|
|
385
486
|
### AlertManager
|
|
386
487
|
|
|
@@ -391,20 +492,12 @@ groups:
|
|
|
391
492
|
- name: upright
|
|
392
493
|
rules:
|
|
393
494
|
- alert: ProbeDown
|
|
394
|
-
expr:
|
|
395
|
-
for: 5m
|
|
396
|
-
labels:
|
|
397
|
-
severity: critical
|
|
398
|
-
annotations:
|
|
399
|
-
summary: "Probe {{ $labels.probe_name }} is down"
|
|
400
|
-
|
|
401
|
-
- alert: ProbeSlow
|
|
402
|
-
expr: upright_probe_duration_seconds > 10
|
|
495
|
+
expr: upright_probe_up == 0
|
|
403
496
|
for: 5m
|
|
404
497
|
labels:
|
|
405
|
-
severity:
|
|
498
|
+
severity: "{{ $labels.alert_severity }}"
|
|
406
499
|
annotations:
|
|
407
|
-
summary: "Probe {{ $labels.
|
|
500
|
+
summary: "Probe {{ $labels.name }} is down"
|
|
408
501
|
```
|
|
409
502
|
|
|
410
503
|
### OpenTelemetry
|
|
@@ -40,7 +40,6 @@
|
|
|
40
40
|
/* Individual uptime bar */
|
|
41
41
|
.uptime-bar {
|
|
42
42
|
border-radius: 2px;
|
|
43
|
-
cursor: default;
|
|
44
43
|
flex: 1;
|
|
45
44
|
height: 24px;
|
|
46
45
|
max-width: 12px;
|
|
@@ -48,6 +47,16 @@
|
|
|
48
47
|
transition: transform 100ms, filter 100ms;
|
|
49
48
|
}
|
|
50
49
|
|
|
50
|
+
a.uptime-bar {
|
|
51
|
+
cursor: pointer;
|
|
52
|
+
display: block;
|
|
53
|
+
text-decoration: none;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
div.uptime-bar {
|
|
57
|
+
cursor: default;
|
|
58
|
+
}
|
|
59
|
+
|
|
51
60
|
.uptime-bar:hover {
|
|
52
61
|
filter: brightness(1.1);
|
|
53
62
|
transform: scaleY(1.15);
|
|
@@ -8,6 +8,7 @@ class Upright::SessionsController < Upright::ApplicationController
|
|
|
8
8
|
end
|
|
9
9
|
|
|
10
10
|
def create
|
|
11
|
+
reset_session
|
|
11
12
|
user = Upright::User.from_omniauth(request.env["omniauth.auth"])
|
|
12
13
|
session[:user_info] = { email: user.email, name: user.name }
|
|
13
14
|
redirect_to upright.root_path
|
|
@@ -1,15 +1,7 @@
|
|
|
1
1
|
module Upright::ProbeResultsHelper
|
|
2
|
-
PROBE_TYPE_ICONS = {
|
|
3
|
-
http: "🌐",
|
|
4
|
-
playwright: "🎭",
|
|
5
|
-
ping: "📶",
|
|
6
|
-
smtp: "✉️",
|
|
7
|
-
traceroute: "🛤️"
|
|
8
|
-
}
|
|
9
|
-
|
|
10
2
|
def probe_type_icon(probe_type)
|
|
11
|
-
|
|
12
|
-
content_tag(:span, icon, title:
|
|
3
|
+
registered = Upright.probe_types.find(probe_type)
|
|
4
|
+
content_tag(:span, registered.icon, title: registered.name)
|
|
13
5
|
end
|
|
14
6
|
|
|
15
7
|
def type_filter_link(label, probe_type = nil)
|
|
@@ -44,6 +36,7 @@ module Upright::ProbeResultsHelper
|
|
|
44
36
|
parts << "for #{params[:probe_type].titleize} probes" if params[:probe_type].present?
|
|
45
37
|
parts << "named #{params[:probe_name]}" if params[:probe_name].present?
|
|
46
38
|
parts << "with status #{params[:status]}" if params[:status].present?
|
|
39
|
+
parts << "on #{Date.parse(params[:date]).to_fs(:long)}" if params[:date].present?
|
|
47
40
|
parts.join(" ")
|
|
48
41
|
end
|
|
49
42
|
end
|
|
@@ -19,9 +19,6 @@ module Upright::Playwright::FormAuthentication
|
|
|
19
19
|
end
|
|
20
20
|
|
|
21
21
|
def authenticator_for(service)
|
|
22
|
-
# First try the host app's authenticator, then fall back to engine's
|
|
23
22
|
"::Playwright::Authenticator::#{service.to_s.camelize}".constantize
|
|
24
|
-
rescue NameError
|
|
25
|
-
"Upright::Playwright::Authenticator::#{service.to_s.camelize}".constantize
|
|
26
23
|
end
|
|
27
24
|
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
module Upright::ProbeResult::StaleCleanup
|
|
2
|
+
extend ActiveSupport::Concern
|
|
3
|
+
|
|
4
|
+
class_methods do
|
|
5
|
+
def cleanup_stale
|
|
6
|
+
cleanup_stale_successes
|
|
7
|
+
cleanup_stale_failures
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def cleanup_stale_successes
|
|
11
|
+
ok.where(created_at: ...Upright.config.stale_success_threshold.ago).in_batches.destroy_all
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def cleanup_stale_failures
|
|
15
|
+
cutoff = [
|
|
16
|
+
Upright.config.stale_failure_threshold.ago,
|
|
17
|
+
fail.order(created_at: :desc).offset(Upright.config.failure_retention_limit).pick(:created_at)
|
|
18
|
+
].compact.max
|
|
19
|
+
|
|
20
|
+
fail.where(created_at: ..cutoff).in_batches.destroy_all
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -2,7 +2,7 @@ module Upright::Probeable
|
|
|
2
2
|
extend ActiveSupport::Concern
|
|
3
3
|
include Upright::Staggerable
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
ALERT_SEVERITIES = %i[ medium high critical ]
|
|
6
6
|
|
|
7
7
|
included do
|
|
8
8
|
attr_writer :logger
|
|
@@ -35,6 +35,7 @@ module Upright::Probeable
|
|
|
35
35
|
probe_name: probe_name,
|
|
36
36
|
probe_target: probe_target,
|
|
37
37
|
probe_service: probe_service,
|
|
38
|
+
probe_alert_severity: probe_alert_severity,
|
|
38
39
|
status: result[:status],
|
|
39
40
|
duration: result[:duration],
|
|
40
41
|
error: result[:error]
|
|
@@ -63,6 +64,11 @@ module Upright::Probeable
|
|
|
63
64
|
nil
|
|
64
65
|
end
|
|
65
66
|
|
|
67
|
+
def probe_alert_severity
|
|
68
|
+
severity = try(:alert_severity)&.to_sym
|
|
69
|
+
ALERT_SEVERITIES.include?(severity) ? severity : :high
|
|
70
|
+
end
|
|
71
|
+
|
|
66
72
|
private
|
|
67
73
|
def failsafe_check
|
|
68
74
|
result, error, duration = nil
|
|
@@ -1,11 +1,16 @@
|
|
|
1
1
|
class Upright::ProbeResult < Upright::ApplicationRecord
|
|
2
2
|
include Upright::ExceptionRecording
|
|
3
|
+
include Upright::ProbeResult::StaleCleanup
|
|
4
|
+
|
|
5
|
+
attr_accessor :probe_alert_severity
|
|
3
6
|
|
|
4
7
|
has_many_attached :artifacts
|
|
5
8
|
|
|
6
9
|
scope :by_type, ->(type) { where(probe_type: type) if type.present? }
|
|
7
10
|
scope :by_status, ->(status) { where(status: status) if status.present? }
|
|
8
11
|
scope :by_name, ->(name) { where(probe_name: name) if name.present? }
|
|
12
|
+
scope :by_date, ->(date) { where(created_at: Date.parse(date).all_day) if date.present? }
|
|
13
|
+
|
|
9
14
|
scope :stale, -> { where(created_at: ...24.hours.ago) }
|
|
10
15
|
|
|
11
16
|
enum :status, [ :ok, :fail ]
|
|
@@ -23,7 +28,7 @@ class Upright::ProbeResult < Upright::ApplicationRecord
|
|
|
23
28
|
|
|
24
29
|
private
|
|
25
30
|
def increment_metrics
|
|
26
|
-
labels = { type: probe_type, name: probe_name, probe_target: probe_target, probe_service: probe_service }
|
|
31
|
+
labels = { type: probe_type, name: probe_name, probe_target: probe_target, probe_service: probe_service, alert_severity: probe_alert_severity || :high }
|
|
27
32
|
|
|
28
33
|
Yabeda.upright_probe_duration_seconds.set(labels.merge(status: status), duration.to_f)
|
|
29
34
|
Yabeda.upright_probe_up.set(labels, ok? ? 1 : 0)
|
|
@@ -17,9 +17,9 @@ class Upright::Probes::Status
|
|
|
17
17
|
end
|
|
18
18
|
|
|
19
19
|
def label_selector(probe_type)
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
20
|
+
matchers = [ "alert_severity!=\"\"" ]
|
|
21
|
+
matchers << "type=\"#{probe_type}\"" if probe_type.present?
|
|
22
|
+
"{#{matchers.join(",")}}"
|
|
23
23
|
end
|
|
24
24
|
|
|
25
25
|
def prometheus_client
|
|
@@ -17,13 +17,13 @@ class Upright::Probes::Uptime
|
|
|
17
17
|
|
|
18
18
|
private
|
|
19
19
|
def query(probe_type)
|
|
20
|
-
"upright:probe_uptime_daily#{label_selector(probe_type)}"
|
|
20
|
+
"min by (name, type, probe_target) (upright:probe_uptime_daily#{label_selector(probe_type)})"
|
|
21
21
|
end
|
|
22
22
|
|
|
23
23
|
def label_selector(probe_type)
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
24
|
+
matchers = [ "alert_severity!=\"\"" ]
|
|
25
|
+
matchers << "type=\"#{probe_type}\"" if probe_type.present?
|
|
26
|
+
"{#{matchers.join(",")}}"
|
|
27
27
|
end
|
|
28
28
|
|
|
29
29
|
def prometheus_client
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
<div class="data-table__row uptime-bars__row">
|
|
2
|
-
<%= link_to upright.site_root_url(subdomain:
|
|
2
|
+
<%= link_to upright.site_root_url(subdomain: current_or_default_site.code, probe_type: probe.type, probe_name: probe.name), class: "data-table__probe" do %>
|
|
3
3
|
<%= probe_type_icon(probe.type) %>
|
|
4
4
|
<span class="data-table__probe-name"><%= probe.name %></span>
|
|
5
5
|
<% end %>
|
|
@@ -11,8 +11,10 @@
|
|
|
11
11
|
<% else %>
|
|
12
12
|
<% uptime_percent = uptime * 100 %>
|
|
13
13
|
<% downtime_minutes = ((1 - uptime) * 24 * 60).round %>
|
|
14
|
-
|
|
15
|
-
|
|
14
|
+
<%= link_to "", upright.site_root_url(subdomain: current_or_default_site.code, probe_type: probe.type, probe_name: probe.name, date: date.iso8601),
|
|
15
|
+
class: "uptime-bar uptime-bar--#{uptime_label(uptime_percent)}",
|
|
16
|
+
title: uptime_bar_tooltip(date, uptime_percent, downtime_minutes),
|
|
17
|
+
aria: { label: uptime_bar_tooltip(date, uptime_percent, downtime_minutes) } %>
|
|
16
18
|
<% end %>
|
|
17
19
|
<% end %>
|
|
18
20
|
</div>
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
<div class="dashboard-filters">
|
|
6
6
|
<%= form_with url: dashboards_probe_status_path, method: :get, data: { controller: "form", turbo_frame: :probe_status_matrix } do %>
|
|
7
|
-
<%= collection_select nil, :probe_type, Upright
|
|
7
|
+
<%= collection_select nil, :probe_type, Upright.probe_types, :type, :name,
|
|
8
8
|
{ selected: @probe_type },
|
|
9
9
|
{ class: "input--select", data: { action: "change->form#submit" } } %>
|
|
10
10
|
<% end %>
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
<div class="dashboard-filters">
|
|
6
6
|
<%= form_with url: dashboards_uptime_path, method: :get, data: { controller: "form", turbo_frame: :uptime_bars } do %>
|
|
7
|
-
<%= collection_select nil, :probe_type, Upright
|
|
7
|
+
<%= collection_select nil, :probe_type, Upright.probe_types, :type, :name,
|
|
8
8
|
{ selected: @probe_type },
|
|
9
9
|
{ class: "input--select", data: { action: "change->form#submit" } } %>
|
|
10
10
|
<% end %>
|
|
@@ -2,10 +2,9 @@
|
|
|
2
2
|
<aside>
|
|
3
3
|
<ul>
|
|
4
4
|
<li><%= type_filter_link "All" %></li>
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
<li><%= type_filter_link "Traceroute", "traceroute" %></li>
|
|
5
|
+
<% Upright.probe_types.each do |probe_type| %>
|
|
6
|
+
<li><%= type_filter_link probe_type.name, probe_type.type %></li>
|
|
7
|
+
<% end %>
|
|
9
8
|
</ul>
|
|
10
9
|
</aside>
|
|
11
10
|
|
|
@@ -16,6 +15,7 @@
|
|
|
16
15
|
<div class="filters">
|
|
17
16
|
<%= form_with url: site_root_path, method: :get, data: { controller: "form" } do |f| %>
|
|
18
17
|
<%= hidden_field_tag :probe_type, params[:probe_type] if params[:probe_type].present? %>
|
|
18
|
+
<%= hidden_field_tag :date, params[:date] if params[:date].present? %>
|
|
19
19
|
<label for="probe_name">Name</label>
|
|
20
20
|
<%= select_tag :probe_name,
|
|
21
21
|
options_for_select([["All", ""]] + @probe_names, params[:probe_name]),
|
|
@@ -26,6 +26,9 @@
|
|
|
26
26
|
options_for_select([["All", ""]] + Upright::ProbeResult.statuses.keys.map { |s| [s.titleize, s] }, params[:status]),
|
|
27
27
|
class: "input--select",
|
|
28
28
|
data: { action: "change->form#submit" } %>
|
|
29
|
+
<% if params[:date].present? %>
|
|
30
|
+
<%= link_to "Clear date filter", site_root_path(probe_type: params[:probe_type], probe_name: params[:probe_name], status: params[:status]), class: "filter-clear" %>
|
|
31
|
+
<% end %>
|
|
29
32
|
<% end %>
|
|
30
33
|
</div>
|
|
31
34
|
|
data/config/ci.rb
CHANGED
|
@@ -3,5 +3,7 @@
|
|
|
3
3
|
CI.run do
|
|
4
4
|
step "Style: Ruby", "bin/rubocop"
|
|
5
5
|
step "Security: Brakeman code analysis", "bin/brakeman --quiet --no-pager --exit-on-warn --exit-on-error"
|
|
6
|
+
step "Lint: GitHub Actions (actionlint)", "actionlint"
|
|
7
|
+
step "Lint: GitHub Actions (zizmor)", "zizmor ."
|
|
6
8
|
step "Tests: Rails", "bin/rails test"
|
|
7
9
|
end
|
|
@@ -5,6 +5,15 @@ module Upright
|
|
|
5
5
|
|
|
6
6
|
desc "Install Upright engine into your application"
|
|
7
7
|
|
|
8
|
+
def set_ruby_version
|
|
9
|
+
ruby_version = RUBY_VERSION
|
|
10
|
+
create_file ".ruby-version", "#{ruby_version}\n"
|
|
11
|
+
create_file "mise.toml", <<~TOML
|
|
12
|
+
[tools]
|
|
13
|
+
ruby = "#{ruby_version}"
|
|
14
|
+
TOML
|
|
15
|
+
end
|
|
16
|
+
|
|
8
17
|
def copy_initializers
|
|
9
18
|
template "upright.rb", "config/initializers/upright.rb"
|
|
10
19
|
template "omniauth.rb", "config/initializers/omniauth.rb"
|
|
@@ -43,6 +52,38 @@ module Upright
|
|
|
43
52
|
template "puma.rb", "config/puma.rb"
|
|
44
53
|
end
|
|
45
54
|
|
|
55
|
+
def install_solid_queue
|
|
56
|
+
rails_command "solid_queue:install"
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def add_queue_database
|
|
60
|
+
gsub_file "config/database.yml",
|
|
61
|
+
"development:\n <<: *default\n database: storage/development.sqlite3",
|
|
62
|
+
"development:\n primary:\n <<: *default\n database: storage/development.sqlite3\n queue:\n <<: *default\n database: storage/development_queue.sqlite3\n migrations_paths: db/queue_migrate"
|
|
63
|
+
|
|
64
|
+
gsub_file "config/database.yml",
|
|
65
|
+
"test:\n <<: *default\n database: storage/test.sqlite3",
|
|
66
|
+
"test:\n primary:\n <<: *default\n database: storage/test.sqlite3\n queue:\n <<: *default\n database: storage/test_queue.sqlite3\n migrations_paths: db/queue_migrate"
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def copy_recurring_config
|
|
70
|
+
template "recurring.yml", "config/recurring.yml", force: true
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def add_jobs_to_procfile
|
|
74
|
+
procfile = File.join(destination_root, "Procfile.dev")
|
|
75
|
+
if File.exist?(procfile)
|
|
76
|
+
unless File.read(procfile).include?("jobs:")
|
|
77
|
+
append_to_file "Procfile.dev", "jobs: OBJC_DISABLE_INITIALIZE_FORK_SAFETY=YES bin/rails solid_queue:start\n"
|
|
78
|
+
end
|
|
79
|
+
else
|
|
80
|
+
create_file "Procfile.dev", <<~PROCFILE
|
|
81
|
+
web: bin/rails server -b '0.0.0.0' -p ${PORT:-3000}
|
|
82
|
+
jobs: OBJC_DISABLE_INITIALIZE_FORK_SAFETY=YES bin/rails solid_queue:start
|
|
83
|
+
PROCFILE
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
46
87
|
def add_routes
|
|
47
88
|
route 'mount Upright::Engine => "/", as: :upright'
|
|
48
89
|
end
|
|
@@ -60,14 +101,14 @@ module Upright
|
|
|
60
101
|
say "Upright has been installed!", :green
|
|
61
102
|
say ""
|
|
62
103
|
say "Next steps:"
|
|
63
|
-
say " 1.
|
|
104
|
+
say " 1. Prepare the database: bin/rails db:prepare"
|
|
64
105
|
say " 2. Configure your servers in config/deploy.yml"
|
|
65
106
|
say " 3. Configure sites in config/sites.yml"
|
|
66
107
|
say " 4. Add probes in probes/*.yml"
|
|
67
108
|
say " 5. Set ADMIN_PASSWORD env var (default: upright)"
|
|
68
109
|
say ""
|
|
69
110
|
say "For production, review config/initializers/upright.rb and update:"
|
|
70
|
-
say " config.hostname = \"
|
|
111
|
+
say " config.hostname = \"example.com\""
|
|
71
112
|
say ""
|
|
72
113
|
say "Start dev services (Prometheus, Alertmanager, Playwright):"
|
|
73
114
|
say " docker compose up -d"
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
#
|
|
8
8
|
# Optional fields:
|
|
9
9
|
# - expected_status: HTTP status code to expect (default: 200-399)
|
|
10
|
+
# - alert_severity: Alert severity when probe fails: medium, high (default), critical
|
|
10
11
|
# - basic_auth_credentials: Key in Rails credentials for basic auth
|
|
11
12
|
# - proxy: Key in Rails credentials for proxy settings
|
|
12
13
|
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
development:
|
|
2
|
+
http_probes:
|
|
3
|
+
schedule: every minute
|
|
4
|
+
command: "Upright::Probes::HTTPProbe.check_and_record_all_later"
|
|
5
|
+
|
|
6
|
+
smtp_probes:
|
|
7
|
+
schedule: every minute
|
|
8
|
+
command: "Upright::Probes::SMTPProbe.check_and_record_all_later"
|
|
9
|
+
|
|
10
|
+
production:
|
|
11
|
+
http_probes:
|
|
12
|
+
schedule: "*/30 * * * * *"
|
|
13
|
+
command: "Upright::Probes::HTTPProbe.check_and_record_all_later"
|
|
14
|
+
|
|
15
|
+
smtp_probes:
|
|
16
|
+
schedule: "*/30 * * * * *"
|
|
17
|
+
command: "Upright::Probes::SMTPProbe.check_and_record_all_later"
|
|
18
|
+
|
|
19
|
+
clear_solid_queue_finished_jobs:
|
|
20
|
+
command: "SolidQueue::Job.clear_finished_in_batches(sleep_between_batches: 0.3)"
|
|
21
|
+
schedule: every hour at minute 12
|
|
22
|
+
|
|
23
|
+
cleanup_stale_probe_results:
|
|
24
|
+
command: "Upright::ProbeResult.cleanup_stale"
|
|
25
|
+
schedule: every hour at minute 30
|
|
@@ -4,6 +4,9 @@
|
|
|
4
4
|
# Required fields:
|
|
5
5
|
# - name: Unique identifier for the probe
|
|
6
6
|
# - host: SMTP server hostname
|
|
7
|
+
#
|
|
8
|
+
# Optional fields:
|
|
9
|
+
# - alert_severity: Alert severity when probe fails: medium, high (default), critical
|
|
7
10
|
|
|
8
11
|
# - name: Example Mail Server
|
|
9
12
|
# host: mail.example.com
|
|
@@ -4,6 +4,9 @@
|
|
|
4
4
|
# Required fields:
|
|
5
5
|
# - name: Unique identifier for the probe
|
|
6
6
|
# - host: Hostname or IP address to trace
|
|
7
|
+
#
|
|
8
|
+
# Optional fields:
|
|
9
|
+
# - alert_severity: Alert severity when probe fails: medium, high (default), critical
|
|
7
10
|
|
|
8
11
|
# - name: Example Service
|
|
9
12
|
# host: example.com
|
|
@@ -18,4 +18,7 @@ Upright.configure do |config|
|
|
|
18
18
|
# client_id: ENV["OIDC_CLIENT_ID"],
|
|
19
19
|
# client_secret: ENV["OIDC_CLIENT_SECRET"]
|
|
20
20
|
# }
|
|
21
|
+
|
|
22
|
+
# Register custom probe types (built-in types: http, playwright, smtp, traceroute)
|
|
23
|
+
# config.probe_types.register :ftp_file, name: "FTP File", icon: "📂"
|
|
21
24
|
end
|
|
@@ -8,9 +8,9 @@ groups:
|
|
|
8
8
|
# Fraction of regions reporting DOWN (0.0 to 1.0)
|
|
9
9
|
- record: upright:probe_down_fraction
|
|
10
10
|
expr: |
|
|
11
|
-
count by (name, type, probe_target) (upright_probe_up == 0)
|
|
11
|
+
count by (name, type, probe_target, alert_severity) (upright_probe_up == 0)
|
|
12
12
|
/
|
|
13
|
-
count by (name, type, probe_target) (upright_probe_up)
|
|
13
|
+
count by (name, type, probe_target, alert_severity) (upright_probe_up)
|
|
14
14
|
|
|
15
15
|
# Daily uptime percentage (0.0 to 1.0)
|
|
16
16
|
# Uptime = percentage of time in past day when majority of sites reported UP
|
|
@@ -148,7 +148,7 @@ groups:
|
|
|
148
148
|
upright: "https://app.<%= app_domain %>/?probe_type={{ $labels.type }}&status=fail&probe_name={{ $labels.name | urlquery }}"
|
|
149
149
|
expr: upright:probe_down_fraction{type="http"} > 0.5
|
|
150
150
|
labels:
|
|
151
|
-
severity:
|
|
151
|
+
severity: "{{ $labels.alert_severity }}"
|
|
152
152
|
group: upright
|
|
153
153
|
|
|
154
154
|
- alert: UprightHTTPProbeDegraded
|
|
@@ -187,7 +187,7 @@ groups:
|
|
|
187
187
|
upright: "https://app.<%= app_domain %>/?probe_type={{ $labels.type }}&status=fail&probe_name={{ $labels.name | urlquery }}"
|
|
188
188
|
expr: upright:probe_down_fraction{type="smtp"} > 0.5
|
|
189
189
|
labels:
|
|
190
|
-
severity:
|
|
190
|
+
severity: "{{ $labels.alert_severity }}"
|
|
191
191
|
group: upright
|
|
192
192
|
|
|
193
193
|
- alert: UprightSMTPProbeDegraded
|
|
@@ -226,7 +226,7 @@ groups:
|
|
|
226
226
|
upright: "https://app.<%= app_domain %>/?probe_type={{ $labels.type }}&status=fail&probe_name={{ $labels.name | urlquery }}"
|
|
227
227
|
expr: upright:probe_down_fraction{type="playwright"} > 0.5
|
|
228
228
|
labels:
|
|
229
|
-
severity:
|
|
229
|
+
severity: "{{ $labels.alert_severity }}"
|
|
230
230
|
group: upright
|
|
231
231
|
|
|
232
232
|
- alert: UprightPlaywrightProbeDegraded
|
|
@@ -29,6 +29,14 @@ class Upright::Configuration
|
|
|
29
29
|
attr_accessor :prometheus_url
|
|
30
30
|
attr_accessor :alert_webhook_url
|
|
31
31
|
|
|
32
|
+
# Probe types
|
|
33
|
+
attr_reader :probe_types
|
|
34
|
+
|
|
35
|
+
# Probe result cleanup
|
|
36
|
+
attr_accessor :stale_success_threshold
|
|
37
|
+
attr_accessor :stale_failure_threshold
|
|
38
|
+
attr_accessor :failure_retention_limit
|
|
39
|
+
|
|
32
40
|
def initialize
|
|
33
41
|
@service_name = "upright"
|
|
34
42
|
@user_agent = "Upright/1.0"
|
|
@@ -41,11 +49,17 @@ class Upright::Configuration
|
|
|
41
49
|
@probes_path = nil
|
|
42
50
|
@authenticators_path = nil
|
|
43
51
|
|
|
52
|
+
@probe_types = Upright::ProbeTypeRegistry.new
|
|
53
|
+
|
|
44
54
|
@playwright_server_url = ENV["PLAYWRIGHT_SERVER_URL"]
|
|
45
55
|
@otel_endpoint = ENV["OTEL_EXPORTER_OTLP_ENDPOINT"]
|
|
46
56
|
|
|
47
57
|
@auth_provider = :static_credentials
|
|
48
58
|
@auth_options = {}
|
|
59
|
+
|
|
60
|
+
@stale_success_threshold = 24.hours
|
|
61
|
+
@stale_failure_threshold = 30.days
|
|
62
|
+
@failure_retention_limit = 20_000
|
|
49
63
|
end
|
|
50
64
|
|
|
51
65
|
def global_subdomain
|
data/lib/upright/engine.rb
CHANGED
|
@@ -56,6 +56,13 @@ class Upright::Engine < ::Rails::Engine
|
|
|
56
56
|
end
|
|
57
57
|
end
|
|
58
58
|
|
|
59
|
+
initializer "upright.probe_types", before: :load_config_initializers do
|
|
60
|
+
Upright.config.probe_types.register :http, name: "HTTP", icon: "🌐"
|
|
61
|
+
Upright.config.probe_types.register :playwright, name: "Playwright", icon: "🎭"
|
|
62
|
+
Upright.config.probe_types.register :smtp, name: "SMTP", icon: "✉️"
|
|
63
|
+
Upright.config.probe_types.register :traceroute, name: "Traceroute", icon: "🛤️"
|
|
64
|
+
end
|
|
65
|
+
|
|
59
66
|
initializer "upright.frozen_record" do
|
|
60
67
|
FrozenRecord::Base.base_path = Upright.configuration.frozen_record_path
|
|
61
68
|
end
|
data/lib/upright/metrics.rb
CHANGED
|
@@ -44,12 +44,12 @@ module Upright::Metrics
|
|
|
44
44
|
gauge :probe_duration_seconds,
|
|
45
45
|
comment: "Duration of each probe",
|
|
46
46
|
aggregation: :max,
|
|
47
|
-
tags: %i[type name probe_target probe_service status]
|
|
47
|
+
tags: %i[type name probe_target probe_service alert_severity status]
|
|
48
48
|
|
|
49
49
|
gauge :probe_up,
|
|
50
50
|
comment: "Probe status (1 = up, 0 = down)",
|
|
51
51
|
aggregation: :most_recent,
|
|
52
|
-
tags: %i[type name probe_target probe_service]
|
|
52
|
+
tags: %i[type name probe_target probe_service alert_severity]
|
|
53
53
|
|
|
54
54
|
gauge :http_response_status,
|
|
55
55
|
comment: "HTTP response status code",
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
module Upright
|
|
2
|
+
class ProbeTypeRegistry
|
|
3
|
+
ProbeType = Data.define(:type, :name, :icon)
|
|
4
|
+
|
|
5
|
+
include Enumerable
|
|
6
|
+
|
|
7
|
+
def initialize
|
|
8
|
+
@probe_types = []
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def register(type, name:, icon:)
|
|
12
|
+
type = type.to_s
|
|
13
|
+
@probe_types.reject! { |pt| pt.type == type }
|
|
14
|
+
@probe_types << ProbeType.new(type:, name:, icon:)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def types
|
|
18
|
+
@probe_types.map(&:type)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def find(type)
|
|
22
|
+
@probe_types.find { |pt| pt.type == type.to_s }
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def unregister(type)
|
|
26
|
+
@probe_types.reject! { |pt| pt.type == type.to_s }
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def each(&block)
|
|
30
|
+
@probe_types.each(&block)
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
data/lib/upright/version.rb
CHANGED
data/lib/upright.rb
CHANGED
|
@@ -19,6 +19,7 @@ require "yabeda/puma/plugin"
|
|
|
19
19
|
|
|
20
20
|
require "upright/version"
|
|
21
21
|
require "upright/configuration"
|
|
22
|
+
require "upright/probe_type_registry"
|
|
22
23
|
require "upright/geohash"
|
|
23
24
|
require "upright/site"
|
|
24
25
|
require "upright/metrics"
|
|
@@ -38,6 +39,10 @@ module Upright
|
|
|
38
39
|
yield(configuration)
|
|
39
40
|
end
|
|
40
41
|
|
|
42
|
+
def probe_types
|
|
43
|
+
configuration.probe_types
|
|
44
|
+
end
|
|
45
|
+
|
|
41
46
|
def sites
|
|
42
47
|
@sites ||= load_sites
|
|
43
48
|
end
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: upright
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.3.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Lewis Buckley
|
|
@@ -404,6 +404,7 @@ files:
|
|
|
404
404
|
- app/models/concerns/upright/playwright/logging.rb
|
|
405
405
|
- app/models/concerns/upright/playwright/otel_tracing.rb
|
|
406
406
|
- app/models/concerns/upright/playwright/video_recording.rb
|
|
407
|
+
- app/models/concerns/upright/probe_result/stale_cleanup.rb
|
|
407
408
|
- app/models/concerns/upright/probe_yaml_source.rb
|
|
408
409
|
- app/models/concerns/upright/probeable.rb
|
|
409
410
|
- app/models/concerns/upright/staggerable.rb
|
|
@@ -464,6 +465,7 @@ files:
|
|
|
464
465
|
- lib/generators/upright/install/templates/otel_collector.yml
|
|
465
466
|
- lib/generators/upright/install/templates/prometheus.yml
|
|
466
467
|
- lib/generators/upright/install/templates/puma.rb
|
|
468
|
+
- lib/generators/upright/install/templates/recurring.yml
|
|
467
469
|
- lib/generators/upright/install/templates/sites.yml
|
|
468
470
|
- lib/generators/upright/install/templates/smtp_probes.yml
|
|
469
471
|
- lib/generators/upright/install/templates/traceroute_probes.yml
|
|
@@ -480,6 +482,7 @@ files:
|
|
|
480
482
|
- lib/upright/geohash.rb
|
|
481
483
|
- lib/upright/metrics.rb
|
|
482
484
|
- lib/upright/playwright/collect_performance_metrics.js
|
|
485
|
+
- lib/upright/probe_type_registry.rb
|
|
483
486
|
- lib/upright/site.rb
|
|
484
487
|
- lib/upright/tracing.rb
|
|
485
488
|
- lib/upright/version.rb
|
|
@@ -504,7 +507,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
504
507
|
- !ruby/object:Gem::Version
|
|
505
508
|
version: '0'
|
|
506
509
|
requirements: []
|
|
507
|
-
rubygems_version: 4.0.
|
|
510
|
+
rubygems_version: 4.0.6
|
|
508
511
|
specification_version: 4
|
|
509
512
|
summary: Synthetic monitoring engine with Playwright and Prometheus metrics
|
|
510
513
|
test_files: []
|