-- eye_spy/perf.lua -- Performance metrics collection module for the eye_spy mod. -- Extracted from functions.lua; loaded after functions.lua and config.lua. -- -- Exposes: -- eye_spy.perf (table) live metrics state -- eye_spy.perf_set_enabled(b) enable/disable + persist to storage -- eye_spy.perf_reset() clear all samples and counters -- eye_spy.perf_inc_counter(k,d) increment a named event counter -- eye_spy.perf_record(k,us) record a timed sample in microseconds -- eye_spy.perf_report() return a formatted report string -- -- All public functions are no-ops when eye_spy.perf.enabled == false. -- --------------------------------------------------------------------------- -- Internal constants -- --------------------------------------------------------------------------- -- Maximum number of rolling samples kept per metric. -- Older samples are overwritten once the ring buffer is full. local PERF_SAMPLE_CAP = 200 -- --------------------------------------------------------------------------- -- Initialise eye_spy.perf state -- --------------------------------------------------------------------------- -- eye_spy and eye_spy.config are guaranteed to exist at this point. -- Restore the persisted enabled-flag from mod storage if present; -- otherwise fall back to the minetest.conf value read in config.lua. do local persisted = eye_spy.storage:get_string("perf_metrics_enabled") local enabled if persisted == "true" then enabled = true elseif persisted == "false" then enabled = false else -- No persisted override — use the value from minetest.conf. enabled = eye_spy.config.perf_metrics_enabled end -- Preserve any table that was already created (e.g. by a /lua reload). eye_spy.perf = eye_spy.perf or {} eye_spy.perf.enabled = enabled eye_spy.perf.metrics = eye_spy.perf.metrics or {} eye_spy.perf.counters = eye_spy.perf.counters or {} end -- --------------------------------------------------------------------------- -- Local helpers -- --------------------------------------------------------------------------- --- Compute the 95th-percentile sample value for a metric. -- Uses only the samples that have actually been filled so far. -- @param metric A metric entry from eye_spy.perf.metrics. -- @return The p95 value in microseconds, or 0 if no samples. local function metric_p95(metric) local n = metric and metric.sample_filled or 0 if n <= 0 then return 0 end -- Copy filled samples into a temporary table for sorting. local values = {} for i = 1, n do values[i] = metric.samples[i] or 0 end table.sort(values) local idx = math.max(1, math.ceil(n * 0.95)) return values[idx] or 0 end -- --------------------------------------------------------------------------- -- Public API -- --------------------------------------------------------------------------- --- Enable or disable performance metrics collection and persist the choice. -- When disabled all other perf functions become no-ops on the next call. -- @param enabled boolean — true to enable, false/nil to disable. function eye_spy.perf_set_enabled(enabled) local value = (enabled == true) eye_spy.perf.enabled = value eye_spy.storage:set_string("perf_metrics_enabled", value and "true" or "false") end --- Reset all collected metrics and event counters. -- Useful before starting a fresh measurement session. function eye_spy.perf_reset() eye_spy.perf.metrics = {} eye_spy.perf.counters = {} end --- Increment a named event counter by delta (default 1). -- Silently ignored when metrics are disabled or the key is empty. -- @param counter_name string Name of the counter to increment. -- @param delta number Amount to add (default 1; may be negative). function eye_spy.perf_inc_counter(counter_name, delta) if not eye_spy.perf.enabled then return end local key = tostring(counter_name or "") if key == "" then return end local step = tonumber(delta) or 1 if step == 0 then return end local counters = eye_spy.perf.counters counters[key] = (counters[key] or 0) + step end --- Record a timing sample for a named metric. -- Samples are stored in a fixed-size ring buffer (PERF_SAMPLE_CAP entries). -- Silently ignored when metrics are disabled or duration is negative. -- @param metric_name string Name of the metric (e.g. "update_player"). -- @param duration_us number Elapsed time for this sample, in microseconds. function eye_spy.perf_record(metric_name, duration_us) if not eye_spy.perf.enabled then return end local us = tonumber(duration_us) or 0 if us < 0 then return end local metrics = eye_spy.perf.metrics local metric = metrics[metric_name] -- Lazily create the metric entry on first use. if not metric then metric = { count = 0, -- total number of samples recorded total_us = 0, -- cumulative sum of all sample durations max_us = 0, -- single worst-case duration seen samples = {}, -- ring buffer of recent raw durations sample_index = 1, -- write head for the ring buffer (1-based) sample_filled = 0, -- how many slots are populated (<= PERF_SAMPLE_CAP) } metrics[metric_name] = metric end -- Update aggregate statistics. metric.count = metric.count + 1 metric.total_us = metric.total_us + us metric.max_us = math.max(metric.max_us, us) -- Write into ring buffer and advance the write head. metric.samples[metric.sample_index] = us metric.sample_index = metric.sample_index + 1 if metric.sample_index > PERF_SAMPLE_CAP then metric.sample_index = 1 end if metric.sample_filled < PERF_SAMPLE_CAP then metric.sample_filled = metric.sample_filled + 1 end end --- Build and return a human-readable performance report string. -- Lists every metric that has at least one sample, sorted alphabetically, -- followed by every non-zero event counter. -- Returns a short placeholder message if no data has been collected yet. -- @return string Multi-line report. function eye_spy.perf_report() -- Collect metric keys that have real data. local keys = {} for key, metric in pairs(eye_spy.perf.metrics) do if metric and metric.count > 0 then keys[#keys + 1] = key end end table.sort(keys) -- Collect counter keys that are non-zero. local counter_keys = {} for key, value in pairs(eye_spy.perf.counters or {}) do if (tonumber(value) or 0) ~= 0 then counter_keys[#counter_keys + 1] = key end end table.sort(counter_keys) if #keys == 0 and #counter_keys == 0 then return "No Eye Spy perf samples yet" end local lines = { "Eye Spy perf (avg/p95/max in ms):" } for _, key in ipairs(keys) do local metric = eye_spy.perf.metrics[key] local avg_ms = (metric.total_us / math.max(metric.count, 1)) / 1000 local p95_ms = metric_p95(metric) / 1000 local max_ms = metric.max_us / 1000 lines[#lines + 1] = string.format( "%s: avg=%.3f p95=%.3f max=%.3f n=%d", key, avg_ms, p95_ms, max_ms, metric.count ) end if #counter_keys > 0 then lines[#lines + 1] = "Eye Spy perf counters:" for _, key in ipairs(counter_keys) do lines[#lines + 1] = string.format( "%s: n=%d", key, eye_spy.perf.counters[key] or 0 ) end end return table.concat(lines, "\n") end