diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e4020a0 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/factorio \ No newline at end of file diff --git a/.vscode/.gitignore b/.vscode/.gitignore new file mode 100644 index 0000000..2eedb26 --- /dev/null +++ b/.vscode/.gitignore @@ -0,0 +1,3 @@ +* +!.gitignore +!launch.json \ No newline at end of file diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..aae97fe --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,9 @@ +{ + "configurations": [ + { + "type": "factoriomod", + "request": "launch", + "name": "Factorio Mod Debug" + } + ] +} \ No newline at end of file diff --git a/prometheus-exporter/control.lua b/prometheus-exporter/control.lua index 6087a5d..40cd228 100644 --- a/prometheus-exporter/control.lua +++ b/prometheus-exporter/control.lua @@ -1,83 +1,55 @@ --- Config -local steps_per_tick = 1 -- process 1 surfaceƗforce per tick -local flush_interval_ticks = 60 * 15 -- flush every n ticks +local metrics = require("utils.metrics") --- State +-- config +local flush_interval_ticks = 60 -- flush every n ticks + +-- state +--- @type table local queue = {} -local buffer = {} -local last_flush_tick = 0 --- Initialize queue with all surfaceƗforce pairs +--- @type table +local buffer = {} +local last_reset = 0 + +-- queue operations to perform for each round of metric processing +-- that way, avoid doing too much work in a single game tick and causing UPS inconsistencies local function prepare_queue() queue = {} - for surface_name, _ in pairs(game.surfaces) do - for force_name, _ in pairs(game.forces) do - table.insert(queue, {surface_name = surface_name, force_name = force_name}) + for _, surface in pairs(game.surfaces) do + for _, force in pairs(game.forces) do + table.insert(queue, metrics.calc_item_production_statistics(buffer, surface, force)) + table.insert(queue, metrics.calc_fluid_production_statistics(buffer, surface, force)) end + table.insert(queue, metrics.calc_power_statistics(buffer, surface)) end end --- Process a few items from the queue each tick local function process_queue() - for i = 1, steps_per_tick do - local entry = table.remove(queue, 1) - if not entry then break end - - local surface_name, force_name = entry.surface_name, entry.force_name - local force = game.forces[force_name] - - -- Items - local item_stats = force.get_item_production_statistics(surface_name) - for item, count in pairs(item_stats.input_counts) do - table.insert(buffer, string.format( - "factorio_production_total{type=\"item\",surface=\"%s\",force=\"%s\",name=\"%s\"} %d", - surface_name, force_name, item, count - )) - end - for item, count in pairs(item_stats.output_counts) do - table.insert(buffer, string.format( - "factorio_consumption_total{type=\"item\",surface=\"%s\",force=\"%s\",name=\"%s\"} %d", - surface_name, force_name, item, count - )) - end - - -- Fluids - local fluid_stats = force.get_fluid_production_statistics(surface_name) - for fluid, count in pairs(fluid_stats.input_counts) do - table.insert(buffer, string.format( - "factorio_production_total{type=\"fluid\",surface=\"%s\",force=\"%s\",name=\"%s\"} %d", - surface_name, force_name, fluid, count - )) - end - for fluid, count in pairs(fluid_stats.output_counts) do - table.insert(buffer, string.format( - "factorio_consumption_total{type=\"fluid\",surface=\"%s\",force=\"%s\",name=\"%s\"} %d", - surface_name, force_name, fluid, count - )) - end + local entry = table.remove(queue, 1) + if entry then + entry() end end --- Flush buffer to file periodically -local function maybe_flush() - if #queue == 0 and game.tick - last_flush_tick >= flush_interval_ticks then - if #buffer > 0 then - helpers.write_file("metrics.prom", table.concat(buffer, "\n"), false) - buffer = {} - end - last_flush_tick = game.tick - prepare_queue() - end -end - --- Main tick handler -script.on_event(defines.events.on_tick, function() +local function tick() process_queue() - maybe_flush() -end) --- Bootstrap + if #queue == 0 and #buffer > 0 then + helpers.write_file("metrics.prom", table.concat(buffer, "\n"), false) + buffer = {} + end + + if #queue == 0 and game.tick - last_reset >= flush_interval_ticks then + prepare_queue() + last_reset = game.tick + end +end + +-- main tick handler +script.on_event(defines.events.on_tick, tick) + +-- startup script.on_init(function() - last_flush_tick = game.tick + last_reset = game.tick prepare_queue() end) diff --git a/prometheus-exporter/data.lua b/prometheus-exporter/data.lua new file mode 100644 index 0000000..437f271 --- /dev/null +++ b/prometheus-exporter/data.lua @@ -0,0 +1 @@ +require("prototypes.metrics-pole").setup() diff --git a/prometheus-exporter/prototypes/metrics-pole.lua b/prometheus-exporter/prototypes/metrics-pole.lua new file mode 100644 index 0000000..5887aec --- /dev/null +++ b/prometheus-exporter/prototypes/metrics-pole.lua @@ -0,0 +1,62 @@ +local name = "metrics-pole" + +--- @class MetricsPole +--- @field name string +--- @field setup fun() +local M = { + name = name, + setup = function() + local function tint(alpha) + return { r = 1, g = 0, b = 1, a = alpha } + end + + -- POLE + local pole = table.deepcopy(data.raw["electric-pole"]["small-electric-pole"]) + pole.name = name + pole.minable.result = name + pole.icons = { + { + icon = pole.icon, + icon_size = pole.icon_size, + tint = tint(0.3) + }, + } + pole.is_military_target = false + pole.hidden_in_factoriopedia = true + pole.localised_name = "Metrics Pole" + pole.supply_area_distance = 0 + + for _, layer in pairs(pole.pictures.layers) do + layer.tint = tint(1) + end + + data:extend({ pole }) + + -- ITEM + local item = table.deepcopy(data.raw["item"]["small-electric-pole"]) + item.name = name + item.place_result = name + item.localised_name = "Metrics Pole" + item.icons = { + { + icon = item.icon, + icon_size = item.icon_size, + tint = tint(0.3) + }, + } + data:extend({ item }) + + -- RECIPE + data:extend({ + { + type = "recipe", + name = name, + enabled = true, + ingredients = { { name = "wood", amount = 1, type = "item" } }, + results = { { name = name, type = "item", amount = 1 } } + } + }) + end +} + +return M diff --git a/prometheus-exporter/utils/metrics.lua b/prometheus-exporter/utils/metrics.lua new file mode 100644 index 0000000..3c90ff0 --- /dev/null +++ b/prometheus-exporter/utils/metrics.lua @@ -0,0 +1,75 @@ +local prometheus = require("utils.prometheus") +local metrics_pole = require('prototypes.metrics-pole') + +local M = {} + +--- @param metrics_table table +--- @param surface LuaSurface +--- @param force LuaForce +--- @return fun() +function M.calc_item_production_statistics(metrics_table, surface, force) + return function() + local item_stats = force.get_item_production_statistics(surface.name) + for item, count in pairs(item_stats.input_counts) do + table.insert(metrics_table, + prometheus.counter("production", "", + { type = "item", surface = surface.name, force = force.name, name = item }, count)) + end + for item, count in pairs(item_stats.output_counts) do + table.insert(metrics_table, + prometheus.counter("consumption", "", + { type = "item", surface = surface.name, force = force.name, name = item }, count)) + end + end +end + +--- @param metrics_table table +--- @param surface LuaSurface +--- @param force LuaForce +--- @return fun() +function M.calc_fluid_production_statistics(metrics_table, surface, force) + return function() + local fluid_stats = force.get_fluid_production_statistics(surface.name) + for item, count in pairs(fluid_stats.input_counts) do + table.insert(metrics_table, + prometheus.counter("production", "", + { type = "fluid", surface = surface.name, force = force.name, name = item }, count)) + end + for item, count in pairs(fluid_stats.output_counts) do + table.insert(metrics_table, + prometheus.counter("consumption", "", + { type = "fluid", surface = surface.name, force = force.name, name = item }, count)) + end + end +end + +--- @param metrics_table table +--- @param surface LuaSurface +--- @return fun() +function M.calc_power_statistics(metrics_table, surface) + return function() + local seen = {} + for _, pole in pairs(surface.find_entities_filtered { name = metrics_pole.name }) do + if table[pole.electric_network_id] then -- deduplication + goto continue + end + table.insert(seen, pole.electric_network_id) + + local stats = pole.electric_network_statistics + for name, count in pairs(stats.input_counts) do + table.insert(metrics_table, + prometheus.counter("energy_consumption", "_joules", + { surface = surface.name, network_id = pole.electric_network_id, name = name }, count)) + end + for name, count in pairs(stats.output_counts) do + table.insert(metrics_table, + prometheus.counter("energy_production", "_joules", + { surface = surface.name, network_id = pole.electric_network_id, name = name }, count)) + end + + ::continue:: + end + end +end + +return M diff --git a/prometheus-exporter/utils/prometheus.lua b/prometheus-exporter/utils/prometheus.lua new file mode 100644 index 0000000..4d8a051 --- /dev/null +++ b/prometheus-exporter/utils/prometheus.lua @@ -0,0 +1,17 @@ +local M = {} + +--- Formats a name + table of labels as a Prometheus metric +--- @param name string +--- @param suffix string +--- @param labels table +--- @param value number +function M.counter(name, suffix, labels, value) + local label_parts = {} + for k, v in pairs(labels) do + table.insert(label_parts, string.format('%s="%s"', k, v)) + end + local label_str = table.concat(label_parts, ",") + return string.format("factorio_%s_total%s{%s} %d", name, suffix, label_str, value) +end + +return M