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