feat: init

This commit is contained in:
winston 2024-09-15 18:48:41 +02:00
commit a8f2a48a10
Signed by: winston
GPG key ID: 3786770EDBC2B481
8 changed files with 2464 additions and 0 deletions

2
.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
/target
/result

2037
Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

32
Cargo.toml Normal file
View 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
View 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
View 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
View 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
View 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
View 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)
}