Compare commits

..

1 Commits

Author SHA1 Message Date
97d7efe1e5 chore: add license and README 2025-09-19 15:22:31 +02:00
20 changed files with 96 additions and 396 deletions

View File

@@ -1,48 +0,0 @@
name: Release
on:
workflow_dispatch:
jobs:
release:
name: Release
runs-on: ubuntu-latest
permissions:
contents: write # to be able to publish a GitHub release
issues: write # to be able to comment on released issues
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Prepare git
run: |
git config --global user.email "gitea@bjornp.com"
git config --global user.name "gitea-actions"
- name: Determine release version and changelog
run: npx commit-and-tag-version
- name: Build release
working-directory: src
run: |
VERSION=$(jq '.version' info.json | xargs echo -n)
zip -r ../prometheus-exporter_$VERSION.zip .
- name: Push changelog and tag
run: git push --follow-tags
- name: Get latest tag and changelog fragment
id: tag
run: |
echo tag="$(git describe --tags --abbrev=0)" >> $GITHUB_OUTPUT
echo "changelog<<EOF" >> $GITHUB_OUTPUT
awk '/^## / { if (version_found) exit; version_found=1 } version_found { print }' CHANGELOG.md | sed '1d' >> $GITHUB_OUTPUT
echo "EOF" >> $GITHUB_OUTPUT
- name: Create release
uses: akkuman/gitea-release-action@v1
with:
files: prometheus-exporter_*.zip
body: ${{ steps.tag.outputs.changelog }}
name: ${{ steps.tag.outputs.tag }}
tag_name: ${{ steps.tag.outputs.tag }}
md5sum: true

1
.gitignore vendored
View File

@@ -1 +0,0 @@
/factorio

View File

@@ -1,14 +0,0 @@
{
"packageFiles": [
{
"filename": "src/info.json",
"type": "json"
}
],
"bumpFiles": [
{
"filename": "src/info.json",
"type": "json"
}
]
}

3
.vscode/.gitignore vendored
View File

@@ -1,3 +0,0 @@
*
!.gitignore
!launch.json

9
.vscode/launch.json vendored
View File

@@ -1,9 +0,0 @@
{
"configurations": [
{
"type": "factoriomod",
"request": "launch",
"name": "Factorio Mod Debug"
}
]
}

View File

@@ -1,5 +0,0 @@
# Changelog
All notable changes to this project will be documented in this file. See [commit-and-tag-version](https://github.com/absolute-version/commit-and-tag-version) for commit guidelines.
## 0.0.2 (2025-10-07)

View File

@@ -1,39 +1,9 @@
![Prometheus Exporter](./support/banner.png) ![](./banner.png)
The prometheus-exporter mod exports metrics from your Factorio game into a Prometheus format. This can then be read by Prometheus and visualized using Grafana (or other platforms that support Prometheus or its metrics format). This repo houses the Factorio mods that I have made or will make. See each respective mod directory for more details, or see below for a short description of each mod.
## Available metrics prometheus-exporter
: The prometheus-exporter writes several metrics about your Factorio game to a metrics file in Prometheus format. This is then ready to be imported by Prometheus and visualized by Grafana.
Currently, item and fluid production metrics are available:
`factorio_(production/consumption)_total`
: Gives a counter of item and fluid production or consumption, including the *type* (item or fluid), *surface* (a.k.a. planet), *force* (usually player), and the in-game name of the entity.
`factorio_energy_(production/consumption)_total_joules`
: Total of item and fluid production or consumption, including the *network_id*, *surface* and *name* of the entity using or producing power. Note that the player must place a **Metrics Pole** in the electricity network to track its metrics (to avoid tracking very many tiny networks).
## Usage
The mod alone cannot do everything in-game, nor do you want it to, for performance reasons. As such, it does the absolute minimum amount of work necessary to get the metrics onto your filesystem. From here, a sidecar exposes it to Prometheus.
The [support](./support) directory contains a Docker compose file. Run this using Docker to start the sidecar, Grafana and Prometheus. If you run your own instance of Prometheus and Grafana then I assume that you also know how to serve the metrics.prom file to Prometheus.
The compose file expects your Factorio script-output to live in `~/.factorio/script-output`, but this might not be where your Factorio data lives. Be sure to update this if your Factorio uses a non-standard location.
Note that this compose expects two volumes named factorio_grafana and factorio_prometheus to exist. The reason for this is that relative folder binds are annoying and volumes work way more reliably across systems.
In short, to run the provided setup, from the support folder:
```bash
• docker volume create factorio_grafana
• docker volume create factorio_prometheus
• docker compose up
```
Grafana will be served on [localhost:3000](http://localhost:3000), with default credentials being admin/admin. I assume you know what to do from here!
## Why does this mod exist? Hasn't this been done before?
There have been Factorio -> Prometheus mods before. However, many of these weren't updated to 2.0 or were very hungry for your UPS. I would like to commit to efficiency while making this mod, meaning that it should be suitable for very large saves. Ideally, the size of the save shouldn't even matter that much. I *will* prefer runtime efficiency over adding new features if a choice has to be made.
--- ---

Binary file not shown.

Before

Width:  |  Height:  |  Size: 108 KiB

After

Width:  |  Height:  |  Size: 40 KiB

View File

@@ -0,0 +1,83 @@
-- Config
local steps_per_tick = 1 -- process 1 surface×force per tick
local flush_interval_ticks = 60 * 15 -- flush every n ticks
-- State
local queue = {}
local buffer = {}
local last_flush_tick = 0
-- Initialize queue with all surface×force pairs
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})
end
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(
"production{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(
"consumption{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(
"production{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(
"consumption{type=\"fluid\",surface=\"%s\",force=\"%s\",name=\"%s\"} %d",
surface_name, force_name, fluid, count
))
end
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()
process_queue()
maybe_flush()
end)
-- Bootstrap
script.on_init(function()
last_flush_tick = game.tick
prepare_queue()
end)

View File

@@ -0,0 +1,9 @@
{
"name": "prometheus-exporter",
"version": "0.0.1",
"title": "Prometheus Exporter for Factorio",
"author": "Bjorn Pijnacker",
"factorio_version": "2.0",
"dependencies": ["base >= 2.0"],
"description": "Exports certain production and consumption metrics to Prometheus format, to be used by Prometheus/Grafana monitoring stack"
}

View File

@@ -1,55 +0,0 @@
local metrics = require("utils.metrics")
-- config
local flush_interval_ticks = 60 -- flush every n ticks
-- state
--- @type table<number, fun()>
local queue = {}
--- @type table<number, string>
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 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
local function process_queue()
local entry = table.remove(queue, 1)
if entry then
entry()
end
end
local function tick()
process_queue()
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_reset = game.tick
prepare_queue()
end)

View File

@@ -1 +0,0 @@
require("prototypes.metrics-pole").setup()

View File

@@ -1,11 +0,0 @@
{
"name": "prometheus-exporter",
"version": "0.0.2",
"title": "Prometheus Exporter for Factorio",
"author": "Bjorn Pijnacker",
"factorio_version": "2.0",
"dependencies": [
"base >= 2.0"
],
"description": "Exports certain production and consumption metrics to Prometheus format, to be used by Prometheus/Grafana monitoring stack"
}

View File

@@ -1,62 +0,0 @@
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

View File

@@ -1,75 +0,0 @@
local prometheus = require("utils.prometheus")
local metrics_pole = require('prototypes.metrics-pole')
local M = {}
--- @param metrics_table table<number, string>
--- @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<number, string>
--- @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<number, string>
--- @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

View File

@@ -1,17 +0,0 @@
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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 122 KiB

View File

@@ -1,31 +0,0 @@
services:
grafana:
image: grafana/grafana-oss
container_name: factorio_grafana
restart: unless-stopped
ports:
- 3000:3000
volumes:
- ./grafana/config:/etc/grafana/provisioning/datasources:ro
- factorio_grafana:/var/lib/grafana:Z
prometheus:
image: prom/prometheus
container_name: factorio_prometheus
restart: unless-stopped
volumes:
- factorio_prometheus:/prometheus:Z
- ./prometheus/config:/etc/prometheus:ro
sidecar:
image: httpd:latest
container_name: factorio_prometheus_sidecar
restart: unless-stopped
volumes:
- ~/.factorio/script-output:/usr/local/apache2/htdocs:ro
volumes:
factorio_grafana:
external: true
factorio_prometheus:
external: true

View File

@@ -1,9 +0,0 @@
apiVersion: 1
datasources:
- name: Prometheus
type: prometheus
url: http://factorio_prometheus:9090
isDefault: true
access: proxy
editable: true

View File

@@ -1,21 +0,0 @@
global:
scrape_interval: 15s
scrape_timeout: 10s
evaluation_interval: 15s
alerting:
alertmanagers:
- static_configs:
- targets: []
scheme: http
timeout: 10s
api_version: v2
scrape_configs:
- job_name: factorio
honor_timestamps: true
scrape_interval: 15s
scrape_timeout: 10s
metrics_path: /metrics.prom
scheme: http
static_configs:
- targets:
- factorio_prometheus_sidecar:9010