# encoding: utf-8 require 'net/http' require 'uri' class HealthCheckMonitor include Mongoid::Document include Mongoid::Timestamps # 視為失敗的狀態碼。0 代表連不上(逾時、DNS 失敗、憑證錯誤等) # 若只要 502 才算失敗,改成 [502] FAILURE_STATUSES = [0, 500, 502, 503, 504] # 告警視窗。只看這段時間內的檢查紀錄,同一網址在這段時間內也只寄一封 ALERT_WINDOW = 4.hours # 跨 unicorn worker 的互斥鎖,存在 multithreads collection LOCK_KEY = 'health_check' field :name, type: String field :target_urls, type: Array, default: [] field :notification_emails, type: Array, default: [] field :check_interval_minutes, type: Integer, default: 5 field :fail_threshold, type: Integer, default: 3 field :timeout, type: Integer, default: 10 field :enabled, type: Boolean, default: true field :check_now, type: Boolean, default: false field :last_checked_at, type: DateTime field :create_user_id field :update_user_id validates :name, presence: true validates :check_interval_minutes, numericality: { greater_than_or_equal_to: 1, only_integer: true } scope :is_enabled, -> { where(enabled: true) } # ── 表單用的字串轉陣列 ────────────────────────────── def target_urls_text (target_urls || []).join("\n") end def target_urls_text=(value) self.target_urls = value.to_s.split(/\r?\n/).map(&:strip).reject(&:blank?).map do |u| u.match(/\Ahttps?:\/\//) ? u : "https://#{u}" end end def notification_emails_text (notification_emails || []).join(", ") end def notification_emails_text=(value) self.notification_emails = value.to_s.split(",").map(&:strip).reject(&:blank?) end # ── 參數 ──────────────────────────────────────── def threshold fail_threshold.to_i > 0 ? fail_threshold.to_i : 3 end def interval_minutes check_interval_minutes.to_i > 0 ? check_interval_minutes.to_i : 5 end def due? return true if check_now return true if last_checked_at.nil? last_checked_at + interval_minutes.minutes <= Time.now end # ── 實際檢查 ───────────────────────────────────── def check_url(url) uri = URI.parse(url) started = Time.now http = Net::HTTP.new(uri.host, uri.port) http.use_ssl = (uri.scheme == 'https') http.verify_mode = OpenSSL::SSL::VERIFY_NONE http.open_timeout = self.timeout http.read_timeout = self.timeout response = http.request(Net::HTTP::Get.new(uri.request_uri)) elapsed = ((Time.now - started) * 1000).round(2) { :status => response.code.to_i, :response_time => elapsed, :error => nil } rescue => e { :status => 0, :response_time => 0, :error => e.message } end def check_all results = {} (target_urls || []).each do |url| results[url] = check_url(url) end results end # 手動與自動共用的唯一入口:檢查 → 寫紀錄 → 判斷 → 寄信 def run_check results = check_all alert_logs = [] results.each do |url, result| HealthCheckLog.create( :health_check_monitor_id => self.id, :url => url, :status => result[:status], :response_time => result[:response_time], :error_message => result[:error] ) target = alert_target(url) alert_logs << target if target end send_alert_email(alert_logs) if alert_logs.present? self.check_now = false self.last_checked_at = Time.now self.save results end # ── 告警判斷 ───────────────────────────────────── def failure?(status) FAILURE_STATUSES.include?(status.to_i) end # 條件:ALERT_WINDOW 內已檢查滿 threshold 次、最近 threshold 次全部失敗、 # 且 ALERT_WINDOW 內還沒寄過這個網址 def alert_target(url) n = threshold window_start = Time.now - ALERT_WINDOW logs = HealthCheckLog.where(:health_check_monitor_id => self.id, :url => url, :created_at.gte => window_start) .desc(:created_at).limit(n).to_a return nil if logs.size < n return nil unless logs.all? { |log| failure?(log.status) } return nil if alerted_recently?(url) logs.first end def alerted_recently?(url) HealthCheckAlert.where(:health_check_monitor_id => self.id, :url => url, :created_at.gte => Time.now - ALERT_WINDOW).exists? end def mark_alerted(url) HealthCheckAlert.create(:health_check_monitor_id => self.id, :url => url) end # logs 是這次剛好達標的 HealthCheckLog 陣列,全部合併成一封 def send_alert_email(logs) return false if notification_emails.blank? || logs.blank? rows = logs.map do |log| { 'url' => log.url.to_s, 'status' => log.status.to_s, 'error' => log.error_message.to_s } end email = Email.new( :module_app_key => 'health_check', :mail_to => notification_emails, :mail_subject => "[網站監控] #{self.name} - #{logs.size} 個網址失敗", :template => 'email/health_check_alert.html.erb', :template_data => { 'monitor_name' => self.name.to_s, 'threshold' => threshold, 'rows' => rows, 'checked_at' => logs.first.created_at.strftime('%Y-%m-%d %H:%M:%S') }, :mail_sentdate => Time.now - 1.minute ) email.save begin email.deliver logs.each { |log| mark_alerted(log.url) } true rescue => e Rails.logger.error("[health_check] 寄信失敗: #{e}") false end end # ── 自動檢測進入點,由 CronWorker 每分鐘呼叫一次 ────────── def self.run_due_checks return unless claim_lock begin is_enabled.each do |monitor| begin monitor.run_check if monitor.due? rescue => e Rails.logger.error("[health_check] #{monitor.name} 檢查失敗: #{e}") end end ensure release_lock end end # 用 Mongo 的 findAndModify 做原子性搶鎖,只有搶到的 worker 會執行 def self.claim_lock Multithread.create(:key => LOCK_KEY, :status => {}) if Multithread.where(:key => LOCK_KEY).count == 0 Multithread.where(:key => LOCK_KEY).to_a[1..-1].to_a.each { |m| m.destroy } claimed = Multithread.where(:key => LOCK_KEY).any_of( { 'status.locked_until' => nil }, { :'status.locked_until'.lt => Time.now } ).find_one_and_update({ '$set' => { 'status.locked_until' => Time.now + 10.minutes } }) !claimed.nil? rescue => e Rails.logger.error("[health_check] 搶鎖失敗: #{e}") false end def self.release_lock Multithread.where(:key => LOCK_KEY).update_all('$set' => { 'status.locked_until' => nil }) rescue => e Rails.logger.error("[health_check] 釋放鎖失敗: #{e}") end end