Compare commits
6 Commits
v0.1.0
...
8036a37c13
| Author | SHA1 | Date | |
|---|---|---|---|
| 8036a37c13 | |||
| 5e8c4ea199 | |||
| 63c505dce6 | |||
| acee1bcbdc | |||
| 8fa02c2e2d | |||
| 78a94f24d2 |
57
.github/workflows/ci.yml
vendored
Normal file
57
.github/workflows/ci.yml
vendored
Normal 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
|
||||||
@@ -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
21
LICENSE
Normal 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
68
README.md
Normal 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.
|
||||||
130
src/main.rs
130
src/main.rs
@@ -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(())
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user