feat: init
This commit is contained in:
commit
a8f2a48a10
8 changed files with 2464 additions and 0 deletions
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
/target
|
||||||
|
/result
|
2037
Cargo.lock
generated
Normal file
2037
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
32
Cargo.toml
Normal file
32
Cargo.toml
Normal file
|
@ -0,0 +1,32 @@
|
||||||
|
[package]
|
||||||
|
name = "prometheus-satisfactory-exporter"
|
||||||
|
description = "Prometheus exporter for Satisfactory"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
[lints.clippy]
|
||||||
|
complexity = "warn"
|
||||||
|
correctness = "warn"
|
||||||
|
nursery = "warn"
|
||||||
|
perf = "warn"
|
||||||
|
style = "warn"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
anyhow = "1.0.88"
|
||||||
|
axum = "0.7.5"
|
||||||
|
clap = { version = "4.5.17", features = ["derive"] }
|
||||||
|
doc_consts = "0.2.0"
|
||||||
|
http-cache-reqwest = { version = "0.14.0", default-features = false, features = ["manager-moka"] }
|
||||||
|
prometheus-client = "0.22.3"
|
||||||
|
reqwest = { version = "0.12.7", features = ["json"] }
|
||||||
|
reqwest-middleware = { version = "0.3.3", features = ["json"] }
|
||||||
|
serde = { version = "1.0.210", features = ["derive"] }
|
||||||
|
serde_json = "1.0.128"
|
||||||
|
thiserror = "1.0.63"
|
||||||
|
tokio = { version = "1.40.0", features = ["full"] }
|
||||||
|
tracing-subscriber = "0.3.18"
|
||||||
|
|
||||||
|
[profile.release]
|
||||||
|
codegen-units = 1
|
||||||
|
lto = "fat"
|
||||||
|
opt-level = 3
|
58
flake.lock
Normal file
58
flake.lock
Normal file
|
@ -0,0 +1,58 @@
|
||||||
|
{
|
||||||
|
"nodes": {
|
||||||
|
"flake-parts": {
|
||||||
|
"inputs": {
|
||||||
|
"nixpkgs-lib": "nixpkgs-lib"
|
||||||
|
},
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1726153070,
|
||||||
|
"narHash": "sha256-HO4zgY0ekfwO5bX0QH/3kJ/h4KvUDFZg8YpkNwIbg1U=",
|
||||||
|
"owner": "hercules-ci",
|
||||||
|
"repo": "flake-parts",
|
||||||
|
"rev": "bcef6817a8b2aa20a5a6dbb19b43e63c5bf8619a",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "hercules-ci",
|
||||||
|
"repo": "flake-parts",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"nixpkgs": {
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1726062873,
|
||||||
|
"narHash": "sha256-IiA3jfbR7K/B5+9byVi9BZGWTD4VSbWe8VLpp9B/iYk=",
|
||||||
|
"owner": "NixOS",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"rev": "4f807e8940284ad7925ebd0a0993d2a1791acb2f",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "NixOS",
|
||||||
|
"ref": "nixos-unstable",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"nixpkgs-lib": {
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1725233747,
|
||||||
|
"narHash": "sha256-Ss8QWLXdr2JCBPcYChJhz4xJm+h/xjl4G0c0XlP6a74=",
|
||||||
|
"type": "tarball",
|
||||||
|
"url": "https://github.com/NixOS/nixpkgs/archive/356624c12086a18f2ea2825fed34523d60ccc4e3.tar.gz"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"type": "tarball",
|
||||||
|
"url": "https://github.com/NixOS/nixpkgs/archive/356624c12086a18f2ea2825fed34523d60ccc4e3.tar.gz"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"root": {
|
||||||
|
"inputs": {
|
||||||
|
"flake-parts": "flake-parts",
|
||||||
|
"nixpkgs": "nixpkgs"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"root": "root",
|
||||||
|
"version": 7
|
||||||
|
}
|
46
flake.nix
Normal file
46
flake.nix
Normal file
|
@ -0,0 +1,46 @@
|
||||||
|
{
|
||||||
|
inputs = {
|
||||||
|
flake-parts.url = "github:hercules-ci/flake-parts";
|
||||||
|
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
||||||
|
};
|
||||||
|
|
||||||
|
outputs =
|
||||||
|
inputs@{ flake-parts, ... }:
|
||||||
|
flake-parts.lib.mkFlake { inherit inputs; } {
|
||||||
|
systems = [
|
||||||
|
"x86_64-linux"
|
||||||
|
"aarch64-linux"
|
||||||
|
"aarch64-darwin"
|
||||||
|
"x86_64-darwin"
|
||||||
|
];
|
||||||
|
perSystem =
|
||||||
|
{ self', pkgs, ... }:
|
||||||
|
{
|
||||||
|
devShells.default = pkgs.mkShell {
|
||||||
|
inputsFrom = [ self'.packages.prometheus-satisfactory-exporter ];
|
||||||
|
packages = with pkgs; [
|
||||||
|
self'.formatter
|
||||||
|
clippy
|
||||||
|
rust-analyzer
|
||||||
|
rustfmt
|
||||||
|
];
|
||||||
|
RUST_SRC_PATH = "${pkgs.rust.packages.stable.rustPlatform.rustLibSrc}";
|
||||||
|
};
|
||||||
|
|
||||||
|
formatter = pkgs.nixfmt-rfc-style;
|
||||||
|
|
||||||
|
packages = {
|
||||||
|
default = self'.packages.prometheus-satisfactory-exporter;
|
||||||
|
prometheus-satisfactory-exporter = pkgs.rustPlatform.buildRustPackage {
|
||||||
|
name = "prometheus-satisfactory-exporter";
|
||||||
|
src = ./.;
|
||||||
|
cargoLock.lockFile = ./Cargo.lock;
|
||||||
|
buildInputs = with pkgs; [
|
||||||
|
pkg-config
|
||||||
|
openssl
|
||||||
|
];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
38
src/api.rs
Normal file
38
src/api.rs
Normal file
|
@ -0,0 +1,38 @@
|
||||||
|
#![allow(dead_code)]
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
#[derive(Default, Serialize)]
|
||||||
|
pub struct Request {
|
||||||
|
pub function: String,
|
||||||
|
pub data: HashMap<String, String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct Response<T> {
|
||||||
|
pub data: T,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct QueryServerState {
|
||||||
|
/// Current game state of the server.
|
||||||
|
pub server_game_state: ServerGameState,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct ServerGameState {
|
||||||
|
pub active_session_name: String,
|
||||||
|
pub num_connected_players: i64,
|
||||||
|
pub player_limit: i64,
|
||||||
|
pub tech_tier: i64,
|
||||||
|
pub active_schematic: String,
|
||||||
|
pub game_phase: Option<String>,
|
||||||
|
pub is_game_running: bool,
|
||||||
|
pub total_game_duration: i64,
|
||||||
|
pub is_game_paused: bool,
|
||||||
|
pub average_tick_rate: f64,
|
||||||
|
pub auto_load_session_name: String,
|
||||||
|
}
|
190
src/main.rs
Normal file
190
src/main.rs
Normal file
|
@ -0,0 +1,190 @@
|
||||||
|
use std::{env, ops::Div, sync::Arc, time::Duration};
|
||||||
|
|
||||||
|
use anyhow::Context as _;
|
||||||
|
use axum::{
|
||||||
|
extract::State,
|
||||||
|
http::StatusCode,
|
||||||
|
response::{IntoResponse, Response},
|
||||||
|
routing::get,
|
||||||
|
Router,
|
||||||
|
};
|
||||||
|
use clap::Parser;
|
||||||
|
use http_cache_reqwest::{
|
||||||
|
Cache, CacheMode, HttpCache, HttpCacheOptions, MokaCacheBuilder, MokaManager,
|
||||||
|
};
|
||||||
|
use metrics::create_registry;
|
||||||
|
use prometheus_client::{encoding::text::encode, registry::Registry};
|
||||||
|
use reqwest::header;
|
||||||
|
use reqwest_middleware::{ClientBuilder, ClientWithMiddleware};
|
||||||
|
|
||||||
|
mod api;
|
||||||
|
mod metrics;
|
||||||
|
|
||||||
|
use crate::metrics::Metrics;
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Parser)]
|
||||||
|
#[command(version, about, long_about = None)]
|
||||||
|
struct Cli {
|
||||||
|
/// Host to bind to.
|
||||||
|
#[arg(long, default_value = "127.0.0.1")]
|
||||||
|
host: String,
|
||||||
|
|
||||||
|
/// Port to bind to.
|
||||||
|
#[arg(short, long, default_value = "9797")]
|
||||||
|
port: u16,
|
||||||
|
|
||||||
|
/// Satisfactory server address and port.
|
||||||
|
#[arg(long, default_value = "127.0.0.1:7777")]
|
||||||
|
satisfactory_address: Option<String>,
|
||||||
|
|
||||||
|
/// Don't use a Bearer Token to authenticate requests to the Satisfactory server.
|
||||||
|
#[arg(long)]
|
||||||
|
unauthenticated: bool,
|
||||||
|
|
||||||
|
/// Don't verify the Satisfactory server TLS certificate.
|
||||||
|
#[arg(short = 'k', long)]
|
||||||
|
insecure: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct AppError(anyhow::Error);
|
||||||
|
impl IntoResponse for AppError {
|
||||||
|
fn into_response(self) -> Response {
|
||||||
|
(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
format!("Something went wrong: {}", self.0),
|
||||||
|
)
|
||||||
|
.into_response()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// This enables using `?` on functions that return `Result<_, anyhow::Error>` to turn them into
|
||||||
|
// `Result<_, AppError>`. That way you don't need to do that manually.
|
||||||
|
impl<E> From<E> for AppError
|
||||||
|
where
|
||||||
|
E: Into<anyhow::Error>,
|
||||||
|
{
|
||||||
|
fn from(err: E) -> Self {
|
||||||
|
Self(err.into())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default, Debug)]
|
||||||
|
struct AppState {
|
||||||
|
address: String,
|
||||||
|
client: ClientWithMiddleware,
|
||||||
|
registry: Registry,
|
||||||
|
metrics: Arc<Metrics>,
|
||||||
|
}
|
||||||
|
|
||||||
|
static APP_USER_AGENT: &str = concat!(env!("CARGO_PKG_NAME"), "/", env!("CARGO_PKG_VERSION"));
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() -> anyhow::Result<()> {
|
||||||
|
let cli = Cli::parse();
|
||||||
|
|
||||||
|
tracing_subscriber::fmt::init();
|
||||||
|
|
||||||
|
let mut headers = header::HeaderMap::new();
|
||||||
|
|
||||||
|
if !cli.unauthenticated {
|
||||||
|
let token_name = "SATISFACTORY_TOKEN";
|
||||||
|
let mut value = header::HeaderValue::from_str(
|
||||||
|
format!(
|
||||||
|
"Bearer {}",
|
||||||
|
env::var(token_name).context(format!("{token_name} is not set."))?
|
||||||
|
)
|
||||||
|
.as_str(),
|
||||||
|
)?;
|
||||||
|
value.set_sensitive(true);
|
||||||
|
headers.insert(header::AUTHORIZATION, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
let client = ClientBuilder::new(
|
||||||
|
reqwest::Client::builder()
|
||||||
|
.default_headers(headers)
|
||||||
|
.user_agent(APP_USER_AGENT)
|
||||||
|
.https_only(true)
|
||||||
|
.danger_accept_invalid_certs(cli.insecure)
|
||||||
|
// on startup, the Satisfactory server might take a long time to respond
|
||||||
|
.timeout(Duration::new(1, 0).div(2))
|
||||||
|
.build()?,
|
||||||
|
)
|
||||||
|
.with(Cache(HttpCache {
|
||||||
|
mode: CacheMode::Default,
|
||||||
|
manager: MokaManager::new(
|
||||||
|
MokaCacheBuilder::default()
|
||||||
|
.time_to_live(Duration::new(1, 0))
|
||||||
|
.build(),
|
||||||
|
),
|
||||||
|
options: HttpCacheOptions::default(),
|
||||||
|
}))
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let (registry, metrics) = create_registry();
|
||||||
|
|
||||||
|
let state = Arc::new(AppState {
|
||||||
|
#[allow(clippy::unwrap_used)]
|
||||||
|
address: cli.satisfactory_address.unwrap(),
|
||||||
|
client,
|
||||||
|
registry,
|
||||||
|
metrics,
|
||||||
|
});
|
||||||
|
|
||||||
|
let app = Router::new()
|
||||||
|
.route("/", get(root))
|
||||||
|
.route("/metrics", get(metrics_handler))
|
||||||
|
.with_state(state);
|
||||||
|
|
||||||
|
let listener = tokio::net::TcpListener::bind(format!("{}:{}", cli.host, cli.port)).await?;
|
||||||
|
axum::serve(listener, app).await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn root() -> &'static str {
|
||||||
|
"healthy, metrics available at /metrics"
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn metrics_handler(State(state): State<Arc<AppState>>) -> Result<String, AppError> {
|
||||||
|
let res = state
|
||||||
|
.client
|
||||||
|
.post(format!("https://{}/api/v1", state.address))
|
||||||
|
.json(&api::Request {
|
||||||
|
function: "QueryServerState".to_string(),
|
||||||
|
..Default::default()
|
||||||
|
})
|
||||||
|
.send()
|
||||||
|
.await?
|
||||||
|
.json::<api::Response<api::QueryServerState>>()
|
||||||
|
.await?
|
||||||
|
.data
|
||||||
|
.server_game_state;
|
||||||
|
|
||||||
|
state
|
||||||
|
.metrics
|
||||||
|
.num_connected_players
|
||||||
|
.set(res.num_connected_players);
|
||||||
|
|
||||||
|
state.metrics.tech_tier.set(res.tech_tier);
|
||||||
|
|
||||||
|
state
|
||||||
|
.metrics
|
||||||
|
.is_game_running
|
||||||
|
.set(if res.is_game_running { 1 } else { 0 });
|
||||||
|
|
||||||
|
state
|
||||||
|
.metrics
|
||||||
|
.total_game_duration
|
||||||
|
.set(res.total_game_duration);
|
||||||
|
|
||||||
|
state
|
||||||
|
.metrics
|
||||||
|
.is_game_paused
|
||||||
|
.set(if res.is_game_paused { 1 } else { 0 });
|
||||||
|
|
||||||
|
state.metrics.average_tick_rate.set(res.average_tick_rate);
|
||||||
|
|
||||||
|
let mut buffer = String::new();
|
||||||
|
encode(&mut buffer, &state.registry).unwrap();
|
||||||
|
|
||||||
|
Ok(buffer)
|
||||||
|
}
|
61
src/metrics.rs
Normal file
61
src/metrics.rs
Normal file
|
@ -0,0 +1,61 @@
|
||||||
|
use prometheus_client::{
|
||||||
|
metrics::gauge::Gauge,
|
||||||
|
registry::{Registry, Unit},
|
||||||
|
};
|
||||||
|
use std::sync::{atomic::AtomicU64, Arc};
|
||||||
|
|
||||||
|
#[derive(Default, Debug, doc_consts::DocConsts)]
|
||||||
|
pub struct Metrics {
|
||||||
|
/// Number of the players currently connected to the Dedicated Server.
|
||||||
|
pub num_connected_players: Gauge,
|
||||||
|
/// Maximum Tech Tier of all Schematics currently unlocked.
|
||||||
|
pub tech_tier: Gauge,
|
||||||
|
/// `1` if the save is currently loaded, `0` if the server is waiting for the session to be created.
|
||||||
|
pub is_game_running: Gauge,
|
||||||
|
/// Total time the current save has been loaded, in seconds.
|
||||||
|
pub total_game_duration: Gauge,
|
||||||
|
/// `1` if the game is paused. If the game is paused, total game duration does not increase.
|
||||||
|
pub is_game_paused: Gauge,
|
||||||
|
/// Average tick rate of the server, in ticks per second.
|
||||||
|
pub average_tick_rate: Gauge<f64, AtomicU64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn create_registry() -> (Registry, Arc<Metrics>) {
|
||||||
|
let mut registry = Registry::with_prefix("satisfactory");
|
||||||
|
let metrics = Arc::new(Metrics::default());
|
||||||
|
|
||||||
|
let subreg = registry.sub_registry_with_prefix("server_state");
|
||||||
|
subreg.register(
|
||||||
|
"num_connected_players",
|
||||||
|
Metrics::get_docs().num_connected_players,
|
||||||
|
metrics.num_connected_players.clone(),
|
||||||
|
);
|
||||||
|
subreg.register(
|
||||||
|
"tech_tier",
|
||||||
|
Metrics::get_docs().tech_tier,
|
||||||
|
metrics.tech_tier.clone(),
|
||||||
|
);
|
||||||
|
subreg.register(
|
||||||
|
"is_game_running",
|
||||||
|
Metrics::get_docs().is_game_running,
|
||||||
|
metrics.is_game_running.clone(),
|
||||||
|
);
|
||||||
|
subreg.register_with_unit(
|
||||||
|
"total_game_duration",
|
||||||
|
Metrics::get_docs().total_game_duration,
|
||||||
|
Unit::Seconds,
|
||||||
|
metrics.total_game_duration.clone(),
|
||||||
|
);
|
||||||
|
subreg.register(
|
||||||
|
"is_game_paused",
|
||||||
|
Metrics::get_docs().is_game_paused,
|
||||||
|
metrics.is_game_paused.clone(),
|
||||||
|
);
|
||||||
|
subreg.register(
|
||||||
|
"average_tick_rate",
|
||||||
|
Metrics::get_docs().average_tick_rate,
|
||||||
|
metrics.average_tick_rate.clone(),
|
||||||
|
);
|
||||||
|
|
||||||
|
(registry, metrics)
|
||||||
|
}
|
Loading…
Reference in a new issue