Aggiunge monitoraggio ops: health check, log scanner, dashboard e notifiche.
Introduce incidenti con dedup, job Sidekiq ogni 3 min, scan log cron, /up/deep, dashboard /admin/ops e push ntfy; include Sentry opzionale e fix test suite. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
3
backend/spec/fixtures/ops/sample.log
vendored
Normal file
3
backend/spec/fixtures/ops/sample.log
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
I, [2026-06-10T19:45:10.123456 #1] INFO -- : [abc] Completed 500 Internal Server Error in 3ms
|
||||
E, [2026-06-10T19:45:11.123456 #1] ERROR -- : PG::ConnectionBad: could not connect to server
|
||||
E, [2026-06-10T19:45:12.123456 #1] ERROR -- : No space left on device @ dir_s_mkdir - /recordings
|
||||
@@ -10,7 +10,7 @@ RSpec.describe Billing::Invoice do
|
||||
amount_cents: 4000,
|
||||
status: "issued"
|
||||
)
|
||||
expect(invoice).not_to be_valid
|
||||
expect(invoice.valid?(:issue)).to be(false)
|
||||
expect(invoice.errors[:pdf]).to be_present
|
||||
end
|
||||
|
||||
|
||||
@@ -3,6 +3,8 @@ require_relative "../config/environment"
|
||||
require "rspec/rails"
|
||||
|
||||
RSpec.configure do |config|
|
||||
config.include ActiveSupport::Testing::TimeHelpers
|
||||
|
||||
config.use_transactional_fixtures = true
|
||||
config.infer_spec_type_from_file_location!
|
||||
config.filter_rails_from_backtrace!
|
||||
|
||||
32
backend/spec/requests/ops/health_spec.rb
Normal file
32
backend/spec/requests/ops/health_spec.rb
Normal file
@@ -0,0 +1,32 @@
|
||||
require "rails_helper"
|
||||
|
||||
RSpec.describe "Ops deep health", type: :request do
|
||||
describe "GET /up/deep" do
|
||||
it "restituisce summary JSON" do
|
||||
allow_any_instance_of(Ops::HealthChecks).to receive(:summary).and_return(
|
||||
status: "ok",
|
||||
checked_at: Time.current.iso8601,
|
||||
checks: []
|
||||
)
|
||||
|
||||
get "/up/deep"
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(JSON.parse(response.body)["status"]).to eq("ok")
|
||||
end
|
||||
|
||||
it "richiede token quando OPS_HEALTH_TOKEN è impostato" do
|
||||
allow(ENV).to receive(:[]).and_call_original
|
||||
allow(ENV).to receive(:[]).with("OPS_HEALTH_TOKEN").and_return("secret-token")
|
||||
allow_any_instance_of(Ops::HealthChecks).to receive(:summary).and_return(
|
||||
status: "ok", checked_at: Time.current.iso8601, checks: []
|
||||
)
|
||||
|
||||
get "/up/deep"
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
|
||||
get "/up/deep", headers: { "X-Ops-Token" => "secret-token" }
|
||||
expect(response).to have_http_status(:ok)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -26,6 +26,15 @@ RSpec.describe "Public regia", type: :request do
|
||||
end
|
||||
|
||||
it "mette in pausa e riprende la diretta" do
|
||||
mtx = instance_double(
|
||||
Mediamtx::Client,
|
||||
set_always_available: true,
|
||||
set_path_recording: true,
|
||||
delete_path: true,
|
||||
list_paths: []
|
||||
)
|
||||
allow(Mediamtx::Client).to receive(:new).and_return(mtx)
|
||||
|
||||
token = Sessions::RegiaAccess.new(session).issue_token!
|
||||
post public_regia_pause_path(token)
|
||||
expect(response).to have_http_status(:ok)
|
||||
|
||||
39
backend/spec/services/admin/host_metrics_spec.rb
Normal file
39
backend/spec/services/admin/host_metrics_spec.rb
Normal file
@@ -0,0 +1,39 @@
|
||||
require "rails_helper"
|
||||
|
||||
RSpec.describe Admin::HostMetrics do
|
||||
subject(:metrics) { described_class.new }
|
||||
|
||||
describe "#build_disk_usage" do
|
||||
it "costruisce la struttura disco" do
|
||||
result = metrics.send(:build_disk_usage, "/", total: 115_964_116_992, used: 9_388_032_000, free: 106_576_084_992, used_percent: 9.0)
|
||||
|
||||
expect(result[:path]).to eq("/")
|
||||
expect(result[:total_bytes]).to eq(115_964_116_992)
|
||||
expect(result[:used_bytes]).to eq(9_388_032_000)
|
||||
expect(result[:free_bytes]).to eq(106_576_084_992)
|
||||
expect(result[:used_percent]).to eq(9.0)
|
||||
end
|
||||
end
|
||||
|
||||
describe "#disk_usage_for" do
|
||||
it "usa df quando statvfs non è disponibile" do
|
||||
allow(File).to receive(:respond_to?).and_call_original
|
||||
allow(File).to receive(:respond_to?).with(:statvfs).and_return(false)
|
||||
allow(Dir).to receive(:exist?).with("/recordings").and_return(true)
|
||||
allow(metrics).to receive(:disk_usage_via_statvfs).with("/recordings").and_return(nil)
|
||||
allow(metrics).to receive(:disk_usage_via_df).with("/recordings").and_return(
|
||||
path: "/recordings",
|
||||
total_bytes: 100_000,
|
||||
used_bytes: 9_000,
|
||||
free_bytes: 91_000,
|
||||
used_percent: 9.0
|
||||
)
|
||||
allow(metrics).to receive(:shell_out).with(/du -sb/).and_return("1048576\t/recordings\n")
|
||||
|
||||
result = metrics.send(:disk_usage_for, "/recordings")
|
||||
|
||||
expect(result[:total_bytes]).to eq(100_000)
|
||||
expect(result[:dir_size_bytes]).to eq(1_048_576)
|
||||
end
|
||||
end
|
||||
end
|
||||
69
backend/spec/services/ops/health_checks_spec.rb
Normal file
69
backend/spec/services/ops/health_checks_spec.rb
Normal file
@@ -0,0 +1,69 @@
|
||||
require "rails_helper"
|
||||
|
||||
RSpec.describe Ops::HealthChecks do
|
||||
describe "#call" do
|
||||
it "risolve incidenti quando il disco è sano" do
|
||||
Ops::Incident.create!(
|
||||
kind: "disk_space",
|
||||
severity: "warning",
|
||||
status: "open",
|
||||
title: "Disco",
|
||||
message: "alto",
|
||||
metadata: {},
|
||||
fingerprint: "disk_space:root",
|
||||
occurrence_count: 1,
|
||||
first_seen_at: Time.current,
|
||||
last_seen_at: Time.current
|
||||
)
|
||||
|
||||
allow_any_instance_of(described_class).to receive(:check_disk_root).and_return(
|
||||
described_class::Finding.new(
|
||||
kind: "disk_space", severity: "info", healthy: true,
|
||||
title: "OK", message: "OK", metadata: {}, fingerprint: "disk_space:root"
|
||||
)
|
||||
)
|
||||
%i[
|
||||
check_recordings_size check_postgres check_redis check_mediamtx check_garage
|
||||
check_sidekiq_heartbeat check_sidekiq_dead check_http_public
|
||||
].each do |method|
|
||||
allow_any_instance_of(described_class).to receive(method).and_return(
|
||||
described_class::Finding.new(
|
||||
kind: "test", severity: "info", healthy: true,
|
||||
title: "OK", message: "OK", metadata: {}, fingerprint: "#{method}:ok"
|
||||
)
|
||||
)
|
||||
end
|
||||
|
||||
described_class.new.call
|
||||
|
||||
expect(Ops::Incident.find_by(fingerprint: "disk_space:root").status).to eq("resolved")
|
||||
end
|
||||
end
|
||||
|
||||
describe "#summary" do
|
||||
it "restituisce status ok quando tutti i check sono sani" do
|
||||
allow_any_instance_of(described_class).to receive(:check_disk_root).and_return(
|
||||
described_class::Finding.new(
|
||||
kind: "disk_space", severity: "info", healthy: true,
|
||||
title: "OK", message: "OK", metadata: {}, fingerprint: "disk_space:root"
|
||||
)
|
||||
)
|
||||
%i[
|
||||
check_recordings_size check_postgres check_redis check_mediamtx check_garage
|
||||
check_sidekiq_heartbeat check_sidekiq_dead check_http_public
|
||||
].each do |method|
|
||||
allow_any_instance_of(described_class).to receive(method).and_return(
|
||||
described_class::Finding.new(
|
||||
kind: "test", severity: "info", healthy: true,
|
||||
title: "OK", message: "OK", metadata: {}, fingerprint: "#{method}:ok"
|
||||
)
|
||||
)
|
||||
end
|
||||
|
||||
summary = described_class.new.summary
|
||||
|
||||
expect(summary[:status]).to eq("ok")
|
||||
expect(summary[:checks].size).to eq(9)
|
||||
end
|
||||
end
|
||||
end
|
||||
55
backend/spec/services/ops/incident_recorder_spec.rb
Normal file
55
backend/spec/services/ops/incident_recorder_spec.rb
Normal file
@@ -0,0 +1,55 @@
|
||||
require "rails_helper"
|
||||
|
||||
RSpec.describe Ops::IncidentRecorder do
|
||||
describe ".record" do
|
||||
it "crea un incidente nuovo" do
|
||||
incident = described_class.record(
|
||||
kind: "disk_space",
|
||||
severity: "critical",
|
||||
title: "Disco pieno",
|
||||
message: "90% usato",
|
||||
fingerprint: "disk_space:root"
|
||||
)
|
||||
|
||||
expect(incident).to be_persisted
|
||||
expect(incident.occurrence_count).to eq(1)
|
||||
expect(incident.status).to eq("open")
|
||||
end
|
||||
|
||||
it "deduplica per fingerprint incrementando occorrenze" do
|
||||
described_class.record(
|
||||
kind: "disk_space",
|
||||
severity: "critical",
|
||||
title: "Disco pieno",
|
||||
message: "90%",
|
||||
fingerprint: "disk_space:root"
|
||||
)
|
||||
second = described_class.record(
|
||||
kind: "disk_space",
|
||||
severity: "critical",
|
||||
title: "Disco pieno",
|
||||
message: "91%",
|
||||
fingerprint: "disk_space:root"
|
||||
)
|
||||
|
||||
expect(Ops::Incident.where(fingerprint: "disk_space:root").count).to eq(1)
|
||||
expect(second.occurrence_count).to eq(2)
|
||||
end
|
||||
end
|
||||
|
||||
describe ".resolve" do
|
||||
it "chiude incidenti aperti con la stessa fingerprint" do
|
||||
incident = described_class.record(
|
||||
kind: "disk_space",
|
||||
severity: "warning",
|
||||
title: "Test",
|
||||
message: "msg",
|
||||
fingerprint: "disk_space:root"
|
||||
)
|
||||
|
||||
described_class.resolve(fingerprint: "disk_space:root")
|
||||
|
||||
expect(incident.reload.status).to eq("resolved")
|
||||
end
|
||||
end
|
||||
end
|
||||
24
backend/spec/services/ops/log_pattern_matcher_spec.rb
Normal file
24
backend/spec/services/ops/log_pattern_matcher_spec.rb
Normal file
@@ -0,0 +1,24 @@
|
||||
require "rails_helper"
|
||||
|
||||
RSpec.describe Ops::LogPatternMatcher do
|
||||
subject(:matcher) do
|
||||
described_class.new(
|
||||
patterns: [
|
||||
{ name: "http_500", severity: "warning", regex: /Completed 500/i },
|
||||
{ name: "disk_full", severity: "critical", regex: /No space left on device/i }
|
||||
]
|
||||
)
|
||||
end
|
||||
|
||||
it "trova pattern nei log" do
|
||||
log = <<~LOG
|
||||
I, [2026-06-10T19:45:10] Completed 500 Internal Server Error
|
||||
E, [2026-06-10T19:45:11] No space left on device - /recordings
|
||||
LOG
|
||||
|
||||
matches = matcher.scan(log)
|
||||
|
||||
expect(matches.map(&:name)).to contain_exactly("http_500", "disk_full")
|
||||
expect(matches.find { |m| m.name == "disk_full" }.severity).to eq("critical")
|
||||
end
|
||||
end
|
||||
9
backend/spec/services/ops/log_scanner_spec.rb
Normal file
9
backend/spec/services/ops/log_scanner_spec.rb
Normal file
@@ -0,0 +1,9 @@
|
||||
require "rails_helper"
|
||||
|
||||
RSpec.describe Ops::LogScanner do
|
||||
it "crea incidenti dai pattern trovati" do
|
||||
log = Rails.root.join("spec/fixtures/ops/sample.log").read
|
||||
|
||||
expect { described_class.new.ingest(log) }.to change(Ops::Incident, :count).by_at_least(2)
|
||||
end
|
||||
end
|
||||
43
backend/spec/services/ops/notifier_spec.rb
Normal file
43
backend/spec/services/ops/notifier_spec.rb
Normal file
@@ -0,0 +1,43 @@
|
||||
require "rails_helper"
|
||||
|
||||
RSpec.describe Ops::Notifier do
|
||||
let(:incident) do
|
||||
Ops::Incident.create!(
|
||||
kind: "test",
|
||||
severity: "critical",
|
||||
status: "open",
|
||||
title: "Test alert",
|
||||
message: "Messaggio di prova",
|
||||
metadata: {},
|
||||
fingerprint: "test:#{SecureRandom.hex(4)}",
|
||||
occurrence_count: 1,
|
||||
first_seen_at: Time.current,
|
||||
last_seen_at: Time.current
|
||||
)
|
||||
end
|
||||
|
||||
describe "#notify" do
|
||||
it "invia POST a ntfy quando configurato" do
|
||||
conn = instance_double(Faraday::Connection)
|
||||
response = instance_double(Faraday::Response, success?: true, status: 200, body: "")
|
||||
allow(Faraday).to receive(:new).and_return(conn)
|
||||
allow(conn).to receive(:post).and_return(response)
|
||||
allow(ENV).to receive(:[]).and_call_original
|
||||
allow(ENV).to receive(:[]).with("OPS_NTFY_URL").and_return("https://ntfy.example/ops")
|
||||
|
||||
described_class.new.notify(incident)
|
||||
|
||||
expect(conn).to have_received(:post).with("https://ntfy.example/ops", anything, hash_including("Title"))
|
||||
end
|
||||
end
|
||||
|
||||
describe ".notify_severity?" do
|
||||
it "rispetta OPS_NOTIFY_SEVERITIES" do
|
||||
allow(ENV).to receive(:fetch).and_call_original
|
||||
allow(ENV).to receive(:fetch).with("OPS_NOTIFY_SEVERITIES", "critical").and_return("critical,warning")
|
||||
|
||||
expect(described_class.notify_severity?("critical")).to be(true)
|
||||
expect(described_class.notify_severity?("info")).to be(false)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -4,10 +4,10 @@ RSpec.describe Youtube::BroadcastService do
|
||||
describe "#normalize_scheduled_start_time" do
|
||||
subject(:service) { described_class.allocate }
|
||||
|
||||
it "usa tra ~90 secondi se scheduled_at è assente" do
|
||||
it "usa tra ~20 secondi se scheduled_at è assente" do
|
||||
travel_to Time.zone.parse("2026-06-03 20:00:00 UTC") do
|
||||
t = service.send(:normalize_scheduled_start_time, nil)
|
||||
expect(t).to eq(Time.utc(2026, 6, 3, 20, 1, 30))
|
||||
expect(t).to eq(Time.utc(2026, 6, 3, 20, 0, 20))
|
||||
end
|
||||
end
|
||||
|
||||
@@ -15,7 +15,7 @@ RSpec.describe Youtube::BroadcastService do
|
||||
travel_to Time.zone.parse("2026-06-03 20:00:00 UTC") do
|
||||
past = Time.zone.parse("2026-06-03 10:00:00")
|
||||
t = service.send(:normalize_scheduled_start_time, past)
|
||||
expect(t).to eq(Time.utc(2026, 6, 3, 20, 1, 30))
|
||||
expect(t).to eq(Time.utc(2026, 6, 3, 20, 0, 20))
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
Reference in New Issue
Block a user