chore: update gateway pressure observability

This commit is contained in:
elky
2026-06-30 17:01:39 +08:00
parent 974def5fef
commit f179ee72f9
69 changed files with 14843 additions and 1609 deletions
@@ -386,6 +386,7 @@ fn load_config(
warmup_url: Some(gateway_health_url.to_string()),
method: Method::POST,
headers,
header_sets: Vec::new(),
body: Some(
serde_json::json!({
"model": "gpt-5",
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,756 @@
use std::env;
use std::fs;
use std::path::PathBuf;
use aether_data::repository::auth::CreateStandaloneApiKeyRecord;
use aether_data::repository::wallet::WalletLookupKey;
use aether_data::{
DataBackends, DataLayerConfig, DatabaseDriver, SqlDatabaseConfig, SqlPoolConfig,
};
use aether_data_contracts::repository::global_models::{
CreateAdminGlobalModelRecord, UpdateAdminGlobalModelRecord, UpsertAdminProviderModelRecord,
};
use aether_data_contracts::repository::provider_catalog::{
StoredProviderCatalogEndpoint, StoredProviderCatalogKey, StoredProviderCatalogProvider,
};
use serde_json::json;
use sha2::Digest;
const DEFAULT_POSTGRES_URL: &str = "postgresql://postgres:[email protected]:5432/aether";
const DEFAULT_OUTPUT_ENV_PATH: &str = "/tmp/aether_local_env.sh";
const DEFAULT_OUTPUT_KEY_PATH: &str = "/tmp/aether_fullchain_api_key";
const DEFAULT_OUTPUT_KEY_LIST_PATH: &str = "/tmp/aether_fullchain_api_keys";
const DEFAULT_PROVIDER_ID: &str = "provider-local-pressure-openai";
const DEFAULT_ENDPOINT_ID: &str = "endpoint-local-pressure-openai-chat";
const DEFAULT_PROVIDER_KEY_ID: &str = "provider-key-local-pressure-openai";
const DEFAULT_GLOBAL_MODEL_ID: &str = "gm-local-pressure-gpt-5-mini";
const DEFAULT_MODEL_ID: &str = "model-local-pressure-gpt-5-mini";
const DEFAULT_API_KEY_ID: &str = "api-key-local-pressure";
const DEFAULT_OPERATOR_ID: &str = "pressure-local";
const DEFAULT_MODEL: &str = "gpt-5-mini";
const DEFAULT_MOCK_UPSTREAM_BASE_URL: &str = "http://127.0.0.1:18181/v1";
const DEFAULT_GATEWAY_BASE_URL: &str = "http://127.0.0.1:8084";
const DEFAULT_API_KEY: &str = "sk-aether-local-pressure";
#[derive(Debug, Clone)]
struct Config {
database_url: String,
output_env_path: PathBuf,
output_key_path: PathBuf,
output_key_list_path: PathBuf,
provider_id: String,
endpoint_id: String,
provider_key_id: String,
global_model_id: String,
model_id: String,
api_key_id: String,
operator_id: String,
model: String,
mock_upstream_base_url: String,
gateway_base_url: String,
api_key: String,
api_key_count: usize,
provider_api_key: String,
postgres_min_connections: u32,
postgres_max_connections: u32,
}
impl Config {
fn from_env_and_args() -> Result<Self, String> {
let mut config = Self {
database_url: env_value("DATABASE_URL")
.or_else(|| env_value("AETHER_DATABASE_URL"))
.or_else(|| env_value("AETHER_GATEWAY_DATA_POSTGRES_URL"))
.unwrap_or_else(|| DEFAULT_POSTGRES_URL.to_string()),
output_env_path: PathBuf::from(
env_value("OUTPUT_ENV_PATH").unwrap_or_else(|| DEFAULT_OUTPUT_ENV_PATH.to_string()),
),
output_key_path: PathBuf::from(
env_value("OUTPUT_KEY_PATH").unwrap_or_else(|| DEFAULT_OUTPUT_KEY_PATH.to_string()),
),
output_key_list_path: PathBuf::from(
env_value("OUTPUT_KEY_LIST_PATH")
.or_else(|| env_value("PRESSURE_API_KEY_LIST_FILE"))
.unwrap_or_else(|| DEFAULT_OUTPUT_KEY_LIST_PATH.to_string()),
),
provider_id: env_value("PRESSURE_PROVIDER_ID")
.unwrap_or_else(|| DEFAULT_PROVIDER_ID.to_string()),
endpoint_id: env_value("PRESSURE_ENDPOINT_ID")
.unwrap_or_else(|| DEFAULT_ENDPOINT_ID.to_string()),
provider_key_id: env_value("PRESSURE_PROVIDER_KEY_ID")
.unwrap_or_else(|| DEFAULT_PROVIDER_KEY_ID.to_string()),
global_model_id: env_value("PRESSURE_GLOBAL_MODEL_ID")
.unwrap_or_else(|| DEFAULT_GLOBAL_MODEL_ID.to_string()),
model_id: env_value("PRESSURE_MODEL_ID")
.unwrap_or_else(|| DEFAULT_MODEL_ID.to_string()),
api_key_id: env_value("PRESSURE_API_KEY_ID")
.unwrap_or_else(|| DEFAULT_API_KEY_ID.to_string()),
operator_id: env_value("PRESSURE_OPERATOR_ID")
.unwrap_or_else(|| DEFAULT_OPERATOR_ID.to_string()),
model: env_value("PRESSURE_MODEL").unwrap_or_else(|| DEFAULT_MODEL.to_string()),
mock_upstream_base_url: env_value("PRESSURE_MOCK_UPSTREAM_BASE_URL")
.unwrap_or_else(|| DEFAULT_MOCK_UPSTREAM_BASE_URL.to_string()),
gateway_base_url: env_value("GATEWAY_BASE_URL")
.unwrap_or_else(|| DEFAULT_GATEWAY_BASE_URL.to_string()),
api_key: env_value("AETHER_API_KEY").unwrap_or_else(|| DEFAULT_API_KEY.to_string()),
api_key_count: env_value("PRESSURE_API_KEY_COUNT")
.and_then(|value| value.parse::<usize>().ok())
.unwrap_or(1),
provider_api_key: env_value("PRESSURE_PROVIDER_API_KEY")
.unwrap_or_else(|| "dummy-local-pressure-provider-key".to_string()),
postgres_min_connections: 1,
postgres_max_connections: 8,
};
let args = env::args().skip(1).collect::<Vec<_>>();
let mut index = 0;
while index < args.len() {
let arg = &args[index];
match arg.as_str() {
"--database-url" => config.database_url = arg_value(&args, &mut index, arg)?,
"--output-env" => {
config.output_env_path = PathBuf::from(arg_value(&args, &mut index, arg)?)
}
"--output-key" => {
config.output_key_path = PathBuf::from(arg_value(&args, &mut index, arg)?)
}
"--output-key-list" => {
config.output_key_list_path = PathBuf::from(arg_value(&args, &mut index, arg)?)
}
"--provider-id" => config.provider_id = arg_value(&args, &mut index, arg)?,
"--endpoint-id" => config.endpoint_id = arg_value(&args, &mut index, arg)?,
"--provider-key-id" => config.provider_key_id = arg_value(&args, &mut index, arg)?,
"--global-model-id" => config.global_model_id = arg_value(&args, &mut index, arg)?,
"--model-id" => config.model_id = arg_value(&args, &mut index, arg)?,
"--api-key-id" => config.api_key_id = arg_value(&args, &mut index, arg)?,
"--operator-id" => config.operator_id = arg_value(&args, &mut index, arg)?,
"--model" => config.model = arg_value(&args, &mut index, arg)?,
"--mock-upstream-base-url" => {
config.mock_upstream_base_url = arg_value(&args, &mut index, arg)?
}
"--gateway-base-url" => {
config.gateway_base_url = arg_value(&args, &mut index, arg)?
}
"--api-key" => config.api_key = arg_value(&args, &mut index, arg)?,
"--api-key-count" => {
config.api_key_count = parse_usize(&arg_value(&args, &mut index, arg)?, arg)?
}
"--provider-api-key" => {
config.provider_api_key = arg_value(&args, &mut index, arg)?
}
"--postgres-min-connections" => {
config.postgres_min_connections =
parse_u32(&arg_value(&args, &mut index, arg)?, arg)?
}
"--postgres-max-connections" => {
config.postgres_max_connections =
parse_u32(&arg_value(&args, &mut index, arg)?, arg)?
}
"--help" | "-h" => {
print_help();
std::process::exit(0);
}
_ => return Err(format!("unknown argument: {arg}")),
}
index += 1;
}
config.validate()?;
Ok(config)
}
fn validate(&self) -> Result<(), String> {
for (name, value) in [
("database_url", &self.database_url),
("provider_id", &self.provider_id),
("endpoint_id", &self.endpoint_id),
("provider_key_id", &self.provider_key_id),
("global_model_id", &self.global_model_id),
("model_id", &self.model_id),
("api_key_id", &self.api_key_id),
("operator_id", &self.operator_id),
("model", &self.model),
("mock_upstream_base_url", &self.mock_upstream_base_url),
("gateway_base_url", &self.gateway_base_url),
("api_key", &self.api_key),
] {
if value.trim().is_empty() {
return Err(format!("{name} cannot be empty"));
}
}
if self.postgres_min_connections > self.postgres_max_connections {
return Err("postgres min connections cannot exceed max connections".to_string());
}
if self.api_key_count == 0 {
return Err("api_key_count must be positive".to_string());
}
Ok(())
}
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let config = Config::from_env_and_args().map_err(|err| format!("invalid config: {err}"))?;
let backends = DataBackends::from_config(DataLayerConfig::from_database(SqlDatabaseConfig {
driver: DatabaseDriver::Postgres,
url: config.database_url.clone(),
pool: SqlPoolConfig {
min_connections: config.postgres_min_connections,
max_connections: config.postgres_max_connections,
acquire_timeout_ms: 5_000,
idle_timeout_ms: 30_000,
max_lifetime_ms: 300_000,
statement_cache_capacity: 128,
require_ssl: false,
},
}))?;
seed_provider_catalog(&backends, &config).await?;
seed_models(&backends, &config).await?;
let operator_user_id = seed_operator_user(&backends, &config).await?;
seed_api_keys(&backends, &config, &operator_user_id).await?;
verify_candidate_selection(&backends, &config).await?;
write_outputs(&config)?;
println!("gateway pressure seed complete");
println!("provider_id={}", config.provider_id);
println!("endpoint_id={}", config.endpoint_id);
println!("provider_key_id={}", config.provider_key_id);
println!("model={}", config.model);
println!("api_key_id={}", config.api_key_id);
println!("api_key_count={}", config.api_key_count);
println!("env written to {}", config.output_env_path.display());
println!("api key written to {}", config.output_key_path.display());
println!(
"api key list written to {}",
config.output_key_list_path.display()
);
Ok(())
}
async fn seed_provider_catalog(
backends: &DataBackends,
config: &Config,
) -> Result<(), Box<dyn std::error::Error>> {
let reader = backends
.read()
.provider_catalog()
.ok_or("provider catalog reader unavailable")?;
let writer = backends
.write()
.provider_catalog()
.ok_or("provider catalog writer unavailable")?;
let provider = StoredProviderCatalogProvider::new(
config.provider_id.clone(),
"Local pressure OpenAI mock".to_string(),
Some("http://127.0.0.1:18181".to_string()),
"openai".to_string(),
)?
.with_transport_fields(
true,
false,
false,
None,
Some(0),
None,
Some(120.0),
Some(30.0),
None,
)
.with_routing_fields(0)
.with_description(Some(
"Local OpenAI-compatible mock provider for gateway pressure tests".to_string(),
));
if reader
.list_providers_by_ids(std::slice::from_ref(&config.provider_id))
.await?
.is_empty()
{
writer.create_provider(&provider, None).await?;
} else {
writer.update_provider(&provider).await?;
}
let endpoint = StoredProviderCatalogEndpoint::new(
config.endpoint_id.clone(),
config.provider_id.clone(),
"openai:chat".to_string(),
Some("openai".to_string()),
Some("chat_completions".to_string()),
true,
)?
.with_transport_fields(
config.mock_upstream_base_url.clone(),
None,
None,
Some(0),
None,
None,
None,
None,
)?
.with_health_score(1.0);
if reader
.list_endpoints_by_ids(std::slice::from_ref(&config.endpoint_id))
.await?
.is_empty()
{
writer.create_endpoint(&endpoint).await?;
} else {
writer.update_endpoint(&endpoint).await?;
}
let provider_key = StoredProviderCatalogKey::new(
config.provider_key_id.clone(),
config.provider_id.clone(),
"Local pressure mock key".to_string(),
"api_key".to_string(),
Some(json!({"streaming": true})),
true,
)?
.with_transport_fields(
Some(json!(["openai:chat"])),
Some(config.provider_api_key.clone()),
None,
None,
None,
Some(json!([config.model.clone()])),
None,
None,
None,
)?
.with_rate_limit_fields(None, None, None, None, None, None, None, None, None)
.with_health_fields(
Some(json!({"openai:chat": {"status": "healthy"}})),
Some(json!({"openai:chat": {"state": "closed"}})),
);
if reader
.list_keys_by_ids(std::slice::from_ref(&config.provider_key_id))
.await?
.is_empty()
{
writer.create_key(&provider_key).await?;
} else {
writer.update_key(&provider_key).await?;
}
Ok(())
}
async fn seed_models(
backends: &DataBackends,
config: &Config,
) -> Result<(), Box<dyn std::error::Error>> {
let reader = backends
.read()
.global_models()
.ok_or("global model reader unavailable")?;
let writer = backends
.write()
.global_models()
.ok_or("global model writer unavailable")?;
let capabilities = Some(json!({
"streaming": true,
"chat": true
}));
let global_config = Some(json!({
"model_mappings": [config.model],
"pressure_seed": true
}));
if reader
.get_admin_global_model_by_id(&config.global_model_id)
.await?
.is_some()
{
writer
.update_admin_global_model(&UpdateAdminGlobalModelRecord::new(
config.global_model_id.clone(),
config.model.clone(),
true,
Some(0.0),
None,
capabilities.clone(),
global_config.clone(),
)?)
.await?;
} else {
writer
.create_admin_global_model(&CreateAdminGlobalModelRecord::new(
config.global_model_id.clone(),
config.model.clone(),
config.model.clone(),
true,
Some(0.0),
None,
capabilities.clone(),
global_config.clone(),
)?)
.await?;
}
let provider_model_mappings = Some(json!([
{
"name": config.model,
"priority": 0,
"api_formats": ["openai:chat"],
"endpoint_ids": [config.endpoint_id]
}
]));
let provider_model = UpsertAdminProviderModelRecord::new(
config.model_id.clone(),
config.provider_id.clone(),
config.global_model_id.clone(),
config.model.clone(),
provider_model_mappings,
Some(0.0),
None,
Some(false),
Some(false),
Some(true),
Some(false),
Some(false),
true,
true,
Some(json!({"pressure_seed": true})),
)?;
if reader
.list_admin_provider_models_by_global_model_id(&config.global_model_id)
.await?
.iter()
.any(|model| model.id == config.model_id)
{
writer.update_admin_provider_model(&provider_model).await?;
} else {
writer.create_admin_provider_model(&provider_model).await?;
}
Ok(())
}
async fn seed_api_keys(
backends: &DataBackends,
config: &Config,
operator_user_id: &str,
) -> Result<(), Box<dyn std::error::Error>> {
for index in 0..config.api_key_count {
seed_api_key(backends, config, operator_user_id, index).await?;
}
Ok(())
}
async fn seed_api_key(
backends: &DataBackends,
config: &Config,
operator_user_id: &str,
key_index: usize,
) -> Result<(), Box<dyn std::error::Error>> {
let auth_reader = backends
.read()
.auth_api_keys()
.ok_or("auth api key reader unavailable")?;
let auth_writer = backends
.write()
.auth_api_keys()
.ok_or("auth api key writer unavailable")?;
let wallet_reader = backends
.read()
.wallets()
.ok_or("wallet reader unavailable")?;
let api_key_id = pressure_api_key_id(config, key_index);
let api_key_value = pressure_api_key_value(config, key_index);
let existing = auth_reader
.find_export_standalone_api_key_by_id(&api_key_id)
.await?;
if existing.is_none() {
auth_writer
.create_standalone_api_key(CreateStandaloneApiKeyRecord {
user_id: operator_user_id.to_string(),
api_key_id: api_key_id.clone(),
key_hash: sha256_hex(&api_key_value),
key_encrypted: Some(api_key_value),
name: Some(format!("Local pressure API key {}", key_index + 1)),
allowed_providers: Some(vec![config.provider_id.clone()]),
allowed_api_formats: Some(vec!["openai:chat".to_string()]),
allowed_models: Some(vec![config.model.clone()]),
ip_rules: None,
rate_limit: Some(0),
concurrent_limit: None,
force_capabilities: None,
is_active: true,
expires_at_unix_secs: None,
auto_delete_on_expiry: false,
total_requests: 0,
total_tokens: 0,
total_cost_usd: 0.0,
})
.await?;
} else {
auth_writer
.update_standalone_api_key_basic(
aether_data::repository::auth::UpdateStandaloneApiKeyBasicRecord {
api_key_id: api_key_id.clone(),
name: Some(format!("Local pressure API key {}", key_index + 1)),
rate_limit_present: true,
rate_limit: Some(0),
concurrent_limit_present: true,
concurrent_limit: None,
allowed_providers: Some(Some(vec![config.provider_id.clone()])),
allowed_api_formats: Some(Some(vec!["openai:chat".to_string()])),
allowed_models: Some(Some(vec![config.model.clone()])),
ip_rules: Some(None),
expires_at_present: true,
expires_at_unix_secs: None,
auto_delete_on_expiry_present: true,
auto_delete_on_expiry: false,
},
)
.await?;
auth_writer
.set_standalone_api_key_active(&api_key_id, true)
.await?;
}
if wallet_reader
.find(WalletLookupKey::ApiKeyId(&api_key_id))
.await?
.is_none()
{
wallet_reader
.initialize_auth_api_key_wallet(&api_key_id, 0.0, true)
.await?;
} else {
wallet_reader
.update_auth_api_key_wallet_limit_mode(&api_key_id, "unlimited")
.await?;
}
Ok(())
}
async fn seed_operator_user(
backends: &DataBackends,
config: &Config,
) -> Result<String, Box<dyn std::error::Error>> {
let user_reader = backends.read().users().ok_or("user reader unavailable")?;
let wallet_reader = backends
.read()
.wallets()
.ok_or("wallet reader unavailable")?;
let username = format!("{}-user", config.operator_id);
let user = match user_reader.find_user_auth_by_username(&username).await? {
Some(user) => user_reader
.update_local_auth_user_admin_fields(
&user.id,
Some("admin".to_string()),
true,
Some(vec![config.provider_id.clone()]),
true,
Some(vec!["openai:chat".to_string()]),
true,
Some(vec![config.model.clone()]),
true,
None,
Some(true),
)
.await?
.unwrap_or(user),
None => user_reader
.create_local_auth_user_with_settings(
Some(format!("{}@local.pressure", config.operator_id)),
true,
username,
"local-pressure-password-disabled".to_string(),
"admin".to_string(),
Some(vec![config.provider_id.clone()]),
Some(vec!["openai:chat".to_string()]),
Some(vec![config.model.clone()]),
None,
)
.await?
.ok_or("failed to create pressure operator user")?,
};
if wallet_reader
.find(WalletLookupKey::UserId(&user.id))
.await?
.is_none()
{
wallet_reader
.initialize_auth_user_wallet(&user.id, 0.0, true)
.await?;
} else {
wallet_reader
.update_auth_user_wallet_limit_mode(&user.id, "unlimited")
.await?;
}
Ok(user.id)
}
async fn verify_candidate_selection(
backends: &DataBackends,
config: &Config,
) -> Result<(), Box<dyn std::error::Error>> {
let reader = backends
.read()
.minimal_candidate_selection()
.ok_or("candidate selection reader unavailable")?;
let rows = reader
.list_for_exact_api_format_and_requested_model("openai:chat", &config.model)
.await?;
let has_pressure_row = rows.iter().any(|row| {
row.provider_id == config.provider_id
&& row.endpoint_id == config.endpoint_id
&& row.key_id == config.provider_key_id
&& row.global_model_id == config.global_model_id
&& row.model_id == config.model_id
&& row.provider_is_active
&& row.endpoint_is_active
&& row.key_is_active
&& row.model_is_active
&& row.model_is_available
});
if !has_pressure_row {
return Err(format!(
"seeded candidate not visible for model {} and openai:chat",
config.model
)
.into());
}
Ok(())
}
fn write_outputs(config: &Config) -> Result<(), Box<dyn std::error::Error>> {
if let Some(parent) = config.output_env_path.parent() {
fs::create_dir_all(parent)?;
}
if let Some(parent) = config.output_key_path.parent() {
fs::create_dir_all(parent)?;
}
if let Some(parent) = config.output_key_list_path.parent() {
fs::create_dir_all(parent)?;
}
fs::write(&config.output_key_path, format!("{}\n", config.api_key))?;
let key_list = (0..config.api_key_count)
.map(|index| pressure_api_key_value(config, index))
.collect::<Vec<_>>()
.join("\n");
fs::write(&config.output_key_list_path, format!("{key_list}\n"))?;
let env_content = format!(
concat!(
"export AETHER_API_KEY_FILE={key_path}\n",
"export AETHER_API_KEY_LIST_FILE={key_list_path}\n",
"export AETHER_API_KEY=$(cat {key_path})\n",
"export GATEWAY_BASE_URL={gateway_base_url}\n",
"export TARGET_URL={gateway_base_url}/v1/chat/completions\n",
"export METRICS_URL={gateway_base_url}/_gateway/metrics\n",
"export PRESSURE_MODEL={model}\n",
"export PRESSURE_MOCK_UPSTREAM_BASE_URL={mock_upstream_base_url}\n",
"export PRESSURE_MOCK_UPSTREAM_METRICS_URL=http://127.0.0.1:18181/metrics\n"
),
key_path = shell_escape(&config.output_key_path.display().to_string()),
key_list_path = shell_escape(&config.output_key_list_path.display().to_string()),
gateway_base_url = shell_escape(config.gateway_base_url.trim_end_matches('/')),
model = shell_escape(&config.model),
mock_upstream_base_url = shell_escape(&config.mock_upstream_base_url),
);
fs::write(&config.output_env_path, env_content)?;
Ok(())
}
fn sha256_hex(value: &str) -> String {
let mut hasher = sha2::Sha256::new();
hasher.update(value.as_bytes());
format!("{:x}", hasher.finalize())
}
fn pressure_api_key_id(config: &Config, index: usize) -> String {
if index == 0 {
config.api_key_id.clone()
} else {
format!("{}-{}", config.api_key_id, index + 1)
}
}
fn pressure_api_key_value(config: &Config, index: usize) -> String {
if index == 0 {
config.api_key.clone()
} else {
format!("{}-{}", config.api_key, index + 1)
}
}
fn shell_escape(value: &str) -> String {
if value
.chars()
.all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '/' | '.' | ':' | '_' | '-'))
{
return value.to_string();
}
format!("'{}'", value.replace('\'', "'\\''"))
}
fn env_value(name: &str) -> Option<String> {
env::var(name)
.ok()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
}
fn arg_value(args: &[String], index: &mut usize, name: &str) -> Result<String, String> {
*index += 1;
args.get(*index)
.filter(|value| !value.starts_with("--"))
.cloned()
.ok_or_else(|| format!("{name} requires a value"))
}
fn parse_u32(value: &str, name: &str) -> Result<u32, String> {
value
.parse::<u32>()
.map_err(|_| format!("{name} must be an unsigned integer"))
}
fn parse_usize(value: &str, name: &str) -> Result<usize, String> {
value
.parse::<usize>()
.map_err(|_| format!("{name} must be an unsigned integer"))
}
fn print_help() {
println!(
"Usage: cargo run -p aether-testkit --bin gateway_pressure_seed -- [options]\n\
\n\
Options:\n\
--database-url URL\n\
--output-env PATH\n\
--output-key PATH\n\
--output-key-list PATH\n\
--gateway-base-url URL\n\
--mock-upstream-base-url URL\n\
--model NAME\n\
--api-key VALUE\n\
--api-key-count N\n\
--provider-api-key VALUE\n\
--provider-id ID\n\
--endpoint-id ID\n\
--provider-key-id ID\n\
--global-model-id ID\n\
--model-id ID\n\
--api-key-id ID\n"
);
}
+125 -8
View File
@@ -11,6 +11,8 @@ use tokio::sync::Mutex;
use crate::runtime::{BenchmarkRuntimeSampler, BenchmarkRuntimeSnapshot};
const MAX_ERROR_SAMPLES: usize = 32;
const MAX_STATUS_SAMPLES: usize = 32;
const MAX_STATUS_SAMPLE_BODY_CHARS: usize = 512;
const FIRST_BODY_BACKGROUND_DRAIN_CHUNKS_ENV: &str =
"AETHER_TESTKIT_FIRST_BODY_BACKGROUND_DRAIN_CHUNKS";
const FIRST_BODY_BACKGROUND_DRAIN_MS_ENV: &str = "AETHER_TESTKIT_FIRST_BODY_BACKGROUND_DRAIN_MS";
@@ -34,6 +36,7 @@ pub struct HttpLoadProbeConfig {
pub warmup_url: Option<String>,
pub method: Method,
pub headers: BTreeMap<String, String>,
pub header_sets: Vec<BTreeMap<String, String>>,
pub body: Option<Vec<u8>>,
pub total_requests: usize,
pub concurrency: usize,
@@ -56,6 +59,7 @@ impl Default for HttpLoadProbeConfig {
warmup_url: None,
method: Method::GET,
headers: BTreeMap::new(),
header_sets: Vec::new(),
body: None,
total_requests: 100,
concurrency: 10,
@@ -93,6 +97,11 @@ impl HttpLoadProbeConfig {
if self.client_shards == 0 {
return Err("load probe client_shards must be positive".to_string());
}
for (index, headers) in self.header_sets.iter().enumerate() {
if headers.is_empty() {
return Err(format!("load probe header_sets[{index}] cannot be empty"));
}
}
if self.http1_only && self.http2_prior_knowledge {
return Err(
"load probe cannot enable both http1_only and http2_prior_knowledge".to_string(),
@@ -114,6 +123,15 @@ pub struct HttpLoadProbeErrorSample {
pub source: Option<String>,
}
#[derive(Debug, Clone, serde::Serialize, PartialEq, Eq)]
pub struct HttpLoadProbeStatusSample {
pub request_index: usize,
pub url: String,
pub status: u16,
pub elapsed_ms: u64,
pub body: String,
}
#[derive(Debug, Clone, serde::Serialize, PartialEq, Eq)]
pub struct HttpLoadProbeResult {
pub url: String,
@@ -159,6 +177,8 @@ pub struct HttpLoadProbeResult {
pub error_counts: BTreeMap<String, usize>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub error_samples: Vec<HttpLoadProbeErrorSample>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub non_success_status_samples: Vec<HttpLoadProbeStatusSample>,
}
#[derive(Debug, Clone, serde::Serialize, PartialEq, Eq)]
@@ -207,6 +227,8 @@ pub struct MultiUrlHttpLoadProbeResult {
pub error_counts: BTreeMap<String, usize>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub error_samples: Vec<HttpLoadProbeErrorSample>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub non_success_status_samples: Vec<HttpLoadProbeStatusSample>,
}
pub async fn run_http_load_probe(
@@ -253,6 +275,7 @@ pub async fn run_http_load_probe(
status_counts: result.status_counts,
error_counts: result.error_counts,
error_samples: result.error_samples,
non_success_status_samples: result.non_success_status_samples,
})
}
@@ -274,7 +297,7 @@ async fn run_http_load_probe_against_urls(
let effective_client_shards = effective_probe_client_shards(config);
let clients = Arc::new(build_probe_clients(config, effective_client_shards)?);
let total_requests = config.total_requests;
let request_headers = build_headers(&config.headers)?;
let request_headers = build_request_header_sets(config)?;
let request_body = config.body.clone().map(Arc::new);
let response_mode = config.response_mode;
let first_body_hold = config.first_body_hold;
@@ -290,6 +313,7 @@ async fn run_http_load_probe_against_urls(
let status_counts = Arc::new(Mutex::new(BTreeMap::<u16, usize>::new()));
let error_counts = Arc::new(Mutex::new(BTreeMap::<String, usize>::new()));
let error_samples = Arc::new(Mutex::new(Vec::<HttpLoadProbeErrorSample>::new()));
let non_success_status_samples = Arc::new(Mutex::new(Vec::<HttpLoadProbeStatusSample>::new()));
let target_request_counts = Arc::new(Mutex::new(BTreeMap::<String, usize>::new()));
let failed_requests = Arc::new(AtomicUsize::new(0));
let completed_requests = Arc::new(AtomicUsize::new(0));
@@ -304,12 +328,13 @@ async fn run_http_load_probe_against_urls(
let status_counts = Arc::clone(&status_counts);
let error_counts = Arc::clone(&error_counts);
let error_samples = Arc::clone(&error_samples);
let non_success_status_samples = Arc::clone(&non_success_status_samples);
let target_request_counts = Arc::clone(&target_request_counts);
let failed_requests = Arc::clone(&failed_requests);
let completed_requests = Arc::clone(&completed_requests);
let method = config.method.clone();
let urls = urls.to_vec();
let request_headers = request_headers.clone();
let request_headers = Arc::clone(&request_headers);
let request_body = request_body.clone();
let start_delay = worker_start_delay(start_ramp, worker_index, config.concurrency);
@@ -326,7 +351,8 @@ async fn run_http_load_probe_against_urls(
let started_at = Instant::now();
let url = urls[current % urls.len()].clone();
let mut request = client.request(method.clone(), &url);
for (name, value) in request_headers.iter() {
let headers = &request_headers[current % request_headers.len()];
for (name, value) in headers.iter() {
request = request.header(name, value);
}
if let Some(body) = request_body.as_ref() {
@@ -357,6 +383,17 @@ async fn run_http_load_probe_against_urls(
.await;
}
Ok(observation) => {
if !(200..300).contains(&status) {
record_non_success_status_sample(
&non_success_status_samples,
current,
&url,
status,
started_at.elapsed().as_millis() as u64,
observation.body_sample.as_deref().unwrap_or_default(),
)
.await;
}
let mut counts = status_counts.lock().await;
*counts.entry(status).or_insert(0) += 1;
drop(counts);
@@ -404,6 +441,7 @@ async fn run_http_load_probe_against_urls(
let status_counts = status_counts.lock().await.clone();
let error_counts = error_counts.lock().await.clone();
let error_samples = error_samples.lock().await.clone();
let non_success_status_samples = non_success_status_samples.lock().await.clone();
let target_request_counts = target_request_counts.lock().await.clone();
let mut latencies = latencies_ms.lock().await.clone();
let mut header_latencies = header_latencies_ms.lock().await.clone();
@@ -460,6 +498,7 @@ async fn run_http_load_probe_against_urls(
status_counts,
error_counts,
error_samples,
non_success_status_samples,
})
}
@@ -613,6 +652,26 @@ async fn record_load_error(
}
}
async fn record_non_success_status_sample(
non_success_status_samples: &Arc<Mutex<Vec<HttpLoadProbeStatusSample>>>,
request_index: usize,
url: &str,
status: u16,
elapsed_ms: u64,
body: &str,
) {
let mut samples = non_success_status_samples.lock().await;
if samples.len() < MAX_STATUS_SAMPLES {
samples.push(HttpLoadProbeStatusSample {
request_index,
url: url.to_string(),
status,
elapsed_ms,
body: compact_error_text(body, MAX_STATUS_SAMPLE_BODY_CHARS),
});
}
}
fn classify_reqwest_error(phase: &str, err: &reqwest::Error) -> ClassifiedLoadError {
let kind = if err.is_timeout() && err.is_connect() {
"connect_timeout"
@@ -689,17 +748,19 @@ async fn observe_response_body(
)
})?;
let first_body_latency_ms = started_at.elapsed().as_millis() as u64;
drop(first);
let body_sample = compact_bytes_sample(&first, MAX_STATUS_SAMPLE_BODY_CHARS);
if !first_body_hold.is_zero() {
tokio::time::sleep(first_body_hold).await;
}
drain_first_body_response_tail(response).await?;
Ok(BodyObservation {
first_body_latency_ms: Some(first_body_latency_ms),
body_sample: Some(body_sample),
})
}
HttpLoadProbeResponseMode::FullBody => {
let mut first_body_latency_ms = None;
let mut body_sample = Vec::new();
while let Some(chunk) = response
.chunk()
.await
@@ -708,7 +769,7 @@ async fn observe_response_body(
if first_body_latency_ms.is_none() {
first_body_latency_ms = Some(started_at.elapsed().as_millis() as u64);
}
drop(chunk);
append_body_sample(&mut body_sample, &chunk, MAX_STATUS_SAMPLE_BODY_CHARS);
}
if first_body_latency_ms.is_none() {
return Err(ClassifiedLoadError::static_body(
@@ -718,11 +779,24 @@ async fn observe_response_body(
}
Ok(BodyObservation {
first_body_latency_ms,
body_sample: Some(String::from_utf8_lossy(&body_sample).into_owned()),
})
}
}
}
fn compact_bytes_sample(bytes: &[u8], max_chars: usize) -> String {
compact_error_text(String::from_utf8_lossy(bytes), max_chars)
}
fn append_body_sample(target: &mut Vec<u8>, chunk: &[u8], max_chars: usize) {
if target.len() >= max_chars {
return;
}
let remaining = max_chars.saturating_sub(target.len());
target.extend_from_slice(&chunk[..chunk.len().min(remaining)]);
}
async fn drain_first_body_response_tail(
mut response: reqwest::Response,
) -> Result<(), ClassifiedLoadError> {
@@ -773,9 +847,26 @@ fn env_u64(key: &str, default_value: u64) -> u64 {
.unwrap_or(default_value)
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
#[derive(Debug, Clone, Default, PartialEq, Eq)]
struct BodyObservation {
first_body_latency_ms: Option<u64>,
body_sample: Option<String>,
}
fn build_request_header_sets(config: &HttpLoadProbeConfig) -> Result<Arc<Vec<HeaderMap>>, String> {
let raw_sets = if config.header_sets.is_empty() {
vec![config.headers.clone()]
} else {
config.header_sets.clone()
};
let mut sets = Vec::with_capacity(raw_sets.len().max(1));
for headers in raw_sets {
sets.push(build_headers(&headers)?);
}
if sets.is_empty() {
sets.push(HeaderMap::new());
}
Ok(Arc::new(sets))
}
fn build_headers(headers: &BTreeMap<String, String>) -> Result<HeaderMap, String> {
@@ -815,8 +906,8 @@ fn percentile(latencies: &[u64], percentile: u8) -> u64 {
#[cfg(test)]
mod tests {
use super::{
build_headers, summarize_latencies, worker_start_delay, HttpLoadProbeConfig,
HttpLoadProbeResponseMode,
build_headers, build_request_header_sets, summarize_latencies, worker_start_delay,
HttpLoadProbeConfig, HttpLoadProbeResponseMode,
};
use reqwest::Method;
use std::collections::BTreeMap;
@@ -886,6 +977,7 @@ mod tests {
assert_eq!(config.method, Method::GET);
assert!(config.warmup_url.is_none());
assert!(config.headers.is_empty());
assert!(config.header_sets.is_empty());
assert!(config.body.is_none());
assert_eq!(config.total_requests, 100);
assert_eq!(config.concurrency, 10);
@@ -916,6 +1008,31 @@ mod tests {
assert!(build_headers(&invalid).is_err());
}
#[test]
fn validates_header_sets() {
let mut config = HttpLoadProbeConfig {
url: "http://127.0.0.1/".to_string(),
header_sets: vec![BTreeMap::new()],
..HttpLoadProbeConfig::default()
};
assert!(config.validate().is_err());
config.header_sets = vec![BTreeMap::from([(
"authorization".to_string(),
"Bearer test".to_string(),
)])];
assert!(config.validate().is_ok());
let sets = build_request_header_sets(&config).expect("header set should build");
assert_eq!(sets.len(), 1);
assert_eq!(
sets[0]
.get("authorization")
.and_then(|value| value.to_str().ok()),
Some("Bearer test")
);
}
#[test]
fn spreads_worker_start_delay_across_ramp() {
assert_eq!(
+88 -7
View File
@@ -85,22 +85,69 @@ fn parse_prometheus_line(line: &str) -> Option<PrometheusSample> {
fn parse_labels(raw: &str) -> BTreeMap<String, String> {
let mut labels = BTreeMap::new();
for pair in raw.split(',').filter(|pair| !pair.is_empty()) {
for pair in split_label_pairs(raw) {
if let Some((key, value)) = pair.split_once('=') {
labels.insert(
key.trim().to_string(),
value
.trim()
.trim_matches('"')
.replace("\\\"", "\"")
.replace("\\n", "\n")
.replace("\\\\", "\\"),
unescape_label_value(value.trim().trim_matches('"')),
);
}
}
labels
}
fn split_label_pairs(raw: &str) -> Vec<&str> {
let mut pairs = Vec::new();
let mut start = 0;
let mut in_string = false;
let mut escaped = false;
for (index, ch) in raw.char_indices() {
if escaped {
escaped = false;
continue;
}
match ch {
'\\' if in_string => escaped = true,
'"' => in_string = !in_string,
',' if !in_string => {
let pair = raw[start..index].trim();
if !pair.is_empty() {
pairs.push(pair);
}
start = index + ch.len_utf8();
}
_ => {}
}
}
let pair = raw[start..].trim();
if !pair.is_empty() {
pairs.push(pair);
}
pairs
}
fn unescape_label_value(value: &str) -> String {
let mut output = String::with_capacity(value.len());
let mut chars = value.chars();
while let Some(ch) = chars.next() {
if ch != '\\' {
output.push(ch);
continue;
}
match chars.next() {
Some('n') => output.push('\n'),
Some('\\') => output.push('\\'),
Some('"') => output.push('"'),
Some(next) => {
output.push('\\');
output.push(next);
}
None => output.push('\\'),
}
}
output
}
#[cfg(test)]
mod tests {
use super::{find_metric_value_u64, parse_prometheus_samples};
@@ -133,4 +180,38 @@ aether_gateway_concurrency_rejected_total{gate="gateway_requests"} 12
Some(12)
);
}
#[test]
fn parses_quoted_label_values_containing_commas() {
let samples = parse_prometheus_samples(
r#"
metric_with_sql{rank="1",query_prefix="SELECT id, name, created_at FROM request_candidates",state="active"} 2
"#,
);
assert_eq!(samples.len(), 1);
assert_eq!(samples[0].labels.get("rank").map(String::as_str), Some("1"));
assert_eq!(
samples[0].labels.get("query_prefix").map(String::as_str),
Some("SELECT id, name, created_at FROM request_candidates")
);
assert_eq!(
samples[0].labels.get("state").map(String::as_str),
Some("active")
);
}
#[test]
fn unescapes_quoted_label_values() {
let samples = parse_prometheus_samples(
r#"
metric_with_escape{message="bad\"line\nx\\y"} 1
"#,
);
assert_eq!(
samples[0].labels.get("message").map(String::as_str),
Some("bad\"line\nx\\y")
);
}
}