Compare commits

6 Commits
v0.1.0 ... main

5 changed files with 247 additions and 31 deletions

57
.github/workflows/ci.yml vendored Normal file
View File

@@ -0,0 +1,57 @@
name: Release
on:
push:
tags:
- 'v*.*.*'
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Rust
uses: dtolnay/rust-toolchain@stable
with:
toolchain: stable
- name: Build release
run: cargo build --release
- name: Upload release asset
uses: actions/upload-artifact@v4
with:
name: satisfactory-exporter
path: target/release/satisfactory-exporter
release:
needs: build
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Create GitHub Release
id: create_release
uses: actions/create-release@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
tag_name: ${{ github.ref }}
release_name: Release ${{ github.ref }}
draft: false
prerelease: false
- name: Upload release asset
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ steps.create_release.outputs.upload_url }}
asset_path: target/release/satisfactory-exporter
asset_name: satisfactory-exporter
asset_content_type: application/octet-stream

View File

@@ -9,4 +9,4 @@ axum = "0.6"
serde = { version = "1.0", features = ["derive"] } serde = { version = "1.0", features = ["derive"] }
prometheus = "0.13" prometheus = "0.13"
reqwest = { version = "0.11", features = ["json"] } reqwest = { version = "0.11", features = ["json"] }
clap = { version = "4.0", features = ["derive"] } clap = { version = "4.0", features = ["derive", "env"] }

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2024 Drayton Munster
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

68
README.md Normal file
View File

@@ -0,0 +1,68 @@
# Satisfactory Exporter
This project is a prometheus exporter for the game [Satisfactory](https://www.satisfactorygame.com/).
It periodically queries a dedicated server via the HTTPS API for metrics and exposes them via an HTTP endpoint.
## Usage
### CLI
```
Usage: satisfactory-exporter [OPTIONS] --endpoint <ENDPOINT>
Options:
--update-interval <UPDATE_INTERVAL>
Interval in seconds between each query to the server [env: SE_UPDATE_INTERVAL=] [default: 5]
--endpoint <ENDPOINT>
Hostname and port of the server to query [env: SE_ENDPOINT=]
--token-file <TOKEN_FILE>
File containing the bearer token to use for authentication. Mutually exclusive with --token [env: SE_TOKEN_FILE=]
--token <TOKEN>
Bearer token to use for authentication. Mutually exclusive with --token-file [env: SE_TOKEN=]
--allow-insecure
Allow insecure connections (e.g., to a server with a self-signed certificate) [env: SE_ALLOW_INSECURE=]
--listen <LISTEN>
Address:Port to which the server will listen [env: SE_LISTEN=] [default: 127.0.0.1:3030]
-h, --help
Print help
-V, --version
Print version
```
### Generating an API token
You can create an API token by executing the following command in the Satisfactory server console:
```
server.GenerateAPIToken
```
It will create a long string that looks like the following:
```text
ewoJInBsIjogIkFQSVRva2VuIgp9.<Long string of characters>
```
This token can then be used with the `--token` option, or saved to a file and used with the `--token-file` option.
### Connecting via Insecure HTTPS
If the server uses a self-signed certificate (the default), you can use the `--allow-insecure` option to allow the
exporter to connect to it.
### Example
```sh
./satisfactory-explorer --endpoint game.example.com:7777 --token-file /path/to/token.txt
```
Or, for a server with a self-signed certificate:
```sh
./satisfactory-explorer --endpoint game.example.com:7777 --token-file /path/to/token.txt --allow-insecure
```
## License
This project is licensed under the MIT License. See the `LICENSE` file for details.

View File

@@ -1,9 +1,4 @@
use axum::{ use axum::{extract::State, response::IntoResponse, routing::get, Router};
extract::State,
response::IntoResponse,
routing::get,
Router,
};
use clap::Parser; use clap::Parser;
use prometheus::{Encoder, Gauge, Registry, TextEncoder}; use prometheus::{Encoder, Gauge, Registry, TextEncoder};
use reqwest::Client; use reqwest::Client;
@@ -14,41 +9,83 @@ use std::sync::Arc;
use std::time::Duration; use std::time::Duration;
use tokio::time::interval; use tokio::time::interval;
/// Command line arguments structure
#[derive(Parser, Debug)] #[derive(Parser, Debug)]
#[command(author, version, about, long_about = None)] #[command(author, version, about, long_about = None)]
struct Args { struct Args {
#[arg(short, long, default_value = "5", help="Interval in seconds between each query to the server")] /// Interval in seconds between each query to the server
#[arg(
long,
default_value = "5",
help = "Interval in seconds between each query to the server",
env = "SE_UPDATE_INTERVAL"
)]
update_interval: u64, update_interval: u64,
#[arg(short, long, help="Hostname and port of the server to query")] /// Hostname and port of the server to query
#[arg(
long,
help = "Hostname and port of the server to query",
env = "SE_ENDPOINT"
)]
endpoint: String, endpoint: String,
#[arg(short, long, help="File containing the bearer token to use for authentication")] /// File containing the bearer token to use for authentication
#[arg(
long,
help = "File containing the bearer token to use for authentication. Mutually exclusive with --token",
group = "token_source",
env = "SE_TOKEN_FILE"
)]
token_file: Option<String>, token_file: Option<String>,
#[arg(short, long, help="Allow insecure connections (e.g., to a server with a self-signed certificate)")] /// Bearer token to use for authentication
#[arg(
long,
help = "Bearer token to use for authentication. Mutually exclusive with --token-file",
group = "token_source",
env = "SE_TOKEN"
)]
token: Option<String>,
/// Allow insecure connections (e.g., to a server with a self-signed certificate)
#[arg(
long,
help = "Allow insecure connections (e.g., to a server with a self-signed certificate)",
env = "SE_ALLOW_INSECURE"
)]
allow_insecure: bool, allow_insecure: bool,
#[arg(short, long, help="Address:Port to which the server will listen", default_value = "127.0.0.1:3030")] /// Address:Port to which the server will listen
#[arg(
long,
help = "Address:Port to which the server will listen",
default_value = "127.0.0.1:3030",
env = "SE_LISTEN"
)]
listen: String, listen: String,
} }
/// Structure for the query body sent to the server
#[derive(Serialize)] #[derive(Serialize)]
struct QueryBody { struct QueryBody {
function: String, function: String,
} }
/// Structure for the server response
#[derive(Deserialize)] #[derive(Deserialize)]
struct ServerResponse { struct ServerResponse {
data: ServerData, data: ServerData,
} }
/// Structure for the server data within the server response
#[derive(Deserialize)] #[derive(Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
struct ServerData { struct ServerData {
server_game_state: ServerGameState, server_game_state: ServerGameState,
} }
/// Structure for the server game state within the server data
#[derive(Deserialize)] #[derive(Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
struct ServerGameState { struct ServerGameState {
@@ -58,6 +95,7 @@ struct ServerGameState {
average_tick_rate: f64, average_tick_rate: f64,
} }
/// Structure for the metrics to be collected
#[derive(Clone)] #[derive(Clone)]
struct Metrics { struct Metrics {
num_connected_players: Gauge, num_connected_players: Gauge,
@@ -67,25 +105,40 @@ struct Metrics {
} }
impl Metrics { impl Metrics {
/// Creates a new instance of `Metrics`
fn new() -> Self { fn new() -> Self {
Metrics { Metrics {
num_connected_players: Gauge::new("num_connected_players", "Number of connected players").unwrap(), num_connected_players: Gauge::new(
tech_tier: Gauge::new("tech_tier", "Current tech tier").unwrap(), "satisfactory_num_connected_players",
total_game_duration: Gauge::new("total_game_duration", "Total game duration").unwrap(), "Number of connected players",
average_tick_rate: Gauge::new("average_tick_rate", "Average tick rate").unwrap(), )
.unwrap(),
tech_tier: Gauge::new("satisfactory_tech_tier", "Current tech tier").unwrap(),
total_game_duration: Gauge::new(
"satisfactory_total_game_duration",
"Total game duration",
)
.unwrap(),
average_tick_rate: Gauge::new("satisfactory_average_tick_rate", "Average tick rate")
.unwrap(),
} }
} }
/// Updates the metrics with the provided game state
fn update(&self, game_state: &ServerGameState) { fn update(&self, game_state: &ServerGameState) {
self.num_connected_players.set(game_state.num_connected_players as f64); self.num_connected_players
.set(game_state.num_connected_players as f64);
self.tech_tier.set(game_state.tech_tier as f64); self.tech_tier.set(game_state.tech_tier as f64);
self.total_game_duration.set(game_state.total_game_duration as f64); self.total_game_duration
.set(game_state.total_game_duration as f64);
self.average_tick_rate.set(game_state.average_tick_rate); self.average_tick_rate.set(game_state.average_tick_rate);
} }
} }
/// Shared state type alias
type SharedState = Arc<(Metrics, Registry)>; type SharedState = Arc<(Metrics, Registry)>;
/// Handler for the `/metrics` endpoint
async fn metrics_handler(State(state): State<SharedState>) -> impl IntoResponse { async fn metrics_handler(State(state): State<SharedState>) -> impl IntoResponse {
let encoder = TextEncoder::new(); let encoder = TextEncoder::new();
let mut buffer = Vec::new(); let mut buffer = Vec::new();
@@ -93,20 +146,34 @@ async fn metrics_handler(State(state): State<SharedState>) -> impl IntoResponse
String::from_utf8(buffer).unwrap() String::from_utf8(buffer).unwrap()
} }
/// Main function
#[tokio::main] #[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> { async fn main() -> Result<(), Box<dyn std::error::Error>> {
// Parse command line arguments
let args = Args::parse(); let args = Args::parse();
// Create a new registry and metrics instance
let registry = Registry::new(); let registry = Registry::new();
let metrics = Arc::new(Metrics::new()); let metrics = Arc::new(Metrics::new());
registry.register(Box::new(metrics.num_connected_players.clone())).unwrap(); // Register metrics with the registry
registry.register(Box::new(metrics.tech_tier.clone())).unwrap(); registry
registry.register(Box::new(metrics.total_game_duration.clone())).unwrap(); .register(Box::new(metrics.num_connected_players.clone()))
registry.register(Box::new(metrics.average_tick_rate.clone())).unwrap(); .unwrap();
registry
.register(Box::new(metrics.tech_tier.clone()))
.unwrap();
registry
.register(Box::new(metrics.total_game_duration.clone()))
.unwrap();
registry
.register(Box::new(metrics.average_tick_rate.clone()))
.unwrap();
// Create shared state
let shared_state: SharedState = Arc::new(((*metrics).clone(), registry)); let shared_state: SharedState = Arc::new(((*metrics).clone(), registry));
// Clone metrics for use in the update loop
let metrics_clone = Arc::clone(&metrics); let metrics_clone = Arc::clone(&metrics);
let update_interval = Duration::from_secs(args.update_interval); let update_interval = Duration::from_secs(args.update_interval);
@@ -118,10 +185,13 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
let client = client_builder.build()?; let client = client_builder.build()?;
// Read the bearer token if provided // Read the bearer token if provided
let bearer_token = args.token_file.map(|file| fs::read_to_string(file).expect("Failed to read token file")); let bearer_token = args.token.or(args
.token_file
.map(|file| fs::read_to_string(file).expect("Failed to read token file")));
let query_endpoint = format!("https://{}/api/v1", args.endpoint); let query_endpoint = format!("https://{}/api/v1", args.endpoint);
// Spawn a task to periodically query the server and update metrics
tokio::spawn(async move { tokio::spawn(async move {
let mut interval = interval(update_interval); let mut interval = interval(update_interval);
let query_body = QueryBody { let query_body = QueryBody {
@@ -137,23 +207,23 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
} }
match request.send().await { match request.send().await {
Ok(response) => { Ok(response) => match response.json::<ServerResponse>().await {
match response.json::<ServerResponse>().await { Ok(server_response) => {
Ok(server_response) => { metrics_clone.update(&server_response.data.server_game_state);
metrics_clone.update(&server_response.data.server_game_state);
}
Err(e) => eprintln!("Failed to parse service metrics: {}", e),
} }
} Err(e) => eprintln!("Failed to parse service metrics: {}", e),
},
Err(e) => eprintln!("Failed to fetch metrics: {}", e), Err(e) => eprintln!("Failed to fetch metrics: {}", e),
} }
} }
}); });
// Build the application router
let app = Router::new() let app = Router::new()
.route("/metrics", get(metrics_handler)) .route("/metrics", get(metrics_handler))
.with_state(shared_state); .with_state(shared_state);
// Start the server
let addr = std::net::SocketAddr::from_str(&args.listen)?; let addr = std::net::SocketAddr::from_str(&args.listen)?;
println!("Listening on {}", addr); println!("Listening on {}", addr);
axum::Server::bind(&addr) axum::Server::bind(&addr)
@@ -161,4 +231,4 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
.await?; .await?;
Ok(()) Ok(())
} }