mod batch;
mod otlp;
mod postgres;
use std::collections::HashMap;
use std::sync::Arc;
use std::time::SystemTime;
use async_trait::async_trait;
use serde::Serialize;
use crate::config::{UsageSinkConfig, UsageSinkKind};
use crate::credentials::CredentialSource;
pub use batch::{BatchSettings, BatchedSink};
pub use otlp::OtlpUsageSink;
pub use postgres::{PostgresSink, PostgresSinkSettings, tls_connector, validate_table_name};
#[derive(Debug, Clone, Copy, Serialize)]
#[serde(rename_all = "snake_case")]
#[allow(dead_code)] pub enum Status {
Ok,
UpstreamError,
ClientCancelled,
Partial,
Rejected,
}
impl Status {
pub fn as_str(self) -> &'static str {
match self {
Self::Ok => "ok",
Self::UpstreamError => "upstream_error",
Self::ClientCancelled => "client_cancelled",
Self::Partial => "partial",
Self::Rejected => "rejected",
}
}
pub fn is_error(self) -> bool {
matches!(self, Self::UpstreamError)
}
}
#[derive(Debug, Clone, Serialize)]
pub struct UsageRecord {
pub schema_version: u32,
pub request_id: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub trace_id: Option<String>,
pub namespace: String,
pub subject: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub signer_kid: Option<String>,
pub model: String,
pub target_provider: String,
pub target_model: String,
pub credential_source: &'static str,
pub credential_id: String,
pub status: Status,
pub input_tokens: u64,
pub cache_read_tokens: u64,
pub cache_write_tokens: u64,
pub output_tokens: u64,
pub cost_microdollars: u64,
pub catalog_version: u64,
pub latency_ms: u64,
pub attempts: u32,
}
impl UsageRecord {
pub const SCHEMA_VERSION: u32 = 2;
pub fn credential_source_str(source: CredentialSource) -> &'static str {
match source {
CredentialSource::Platform => "platform",
CredentialSource::Byok => "byok",
}
}
}
#[derive(Debug, Clone)]
pub struct ObservedRecord {
pub record: UsageRecord,
pub observed_at: SystemTime,
}
impl ObservedRecord {
pub fn now(record: UsageRecord) -> Self {
Self {
record,
observed_at: SystemTime::now(),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DropReason {
BufferFull,
SinkError,
Shutdown,
}
impl DropReason {
pub fn as_str(self) -> &'static str {
match self {
Self::BufferFull => "buffer_full",
Self::SinkError => "sink_error",
Self::Shutdown => "shutdown",
}
}
}
#[derive(Debug, thiserror::Error)]
#[error("{0}")]
pub struct SinkFailure(pub String);
impl SinkFailure {
pub fn new(message: impl Into<String>) -> Self {
Self(message.into())
}
}
#[async_trait]
pub trait UsageSink: Send + Sync {
fn name(&self) -> &'static str;
async fn record(&self, record: &UsageRecord);
async fn record_batch(&self, batch: &[ObservedRecord]) -> Result<(), SinkFailure> {
for observed in batch {
self.record(&observed.record).await;
}
Ok(())
}
}
pub struct StdoutSink;
#[async_trait]
impl UsageSink for StdoutSink {
fn name(&self) -> &'static str {
"stdout"
}
async fn record(&self, record: &UsageRecord) {
match serde_json::to_string(record) {
Ok(line) => println!("{line}"),
Err(e) => tracing::error!(error = %e, "failed to serialize usage record"),
}
}
}
pub struct UsageFanout {
sinks: Vec<Box<dyn UsageSink>>,
}
impl UsageFanout {
pub fn new(sinks: Vec<Box<dyn UsageSink>>) -> Self {
Self { sinks }
}
pub async fn record(&self, record: &UsageRecord) {
for sink in &self.sinks {
sink.record(record).await;
}
}
}
#[derive(Debug, thiserror::Error)]
pub enum UsageSinkError {
#[error("usage sink `{kind}`: {message}")]
Invalid { kind: &'static str, message: String },
#[error("postgres usage sink: {0}")]
Postgres(#[from] tokio_postgres::Error),
}
impl UsageSinkError {
fn invalid(kind: &'static str, message: impl Into<String>) -> Self {
Self::Invalid {
kind,
message: message.into(),
}
}
}
pub async fn build_sinks(
configs: &[UsageSinkConfig],
env: &HashMap<String, String>,
) -> Result<Vec<Box<dyn UsageSink>>, UsageSinkError> {
if configs.is_empty() {
return Ok(vec![Box::new(StdoutSink)]);
}
let mut sinks: Vec<Box<dyn UsageSink>> = Vec::with_capacity(configs.len());
for config in configs {
match config.kind {
UsageSinkKind::Stdout => sinks.push(Box::new(StdoutSink)),
UsageSinkKind::Otlp => sinks.push(Box::new(OtlpUsageSink::new()?)),
UsageSinkKind::Postgres => {
let dsn_env = config.dsn_env.as_deref().unwrap_or_default();
let dsn = env
.get(dsn_env)
.filter(|dsn| !dsn.trim().is_empty())
.ok_or_else(|| {
UsageSinkError::invalid(
"postgres",
format!("`{dsn_env}` is unset or empty in the environment"),
)
})?;
let sink = PostgresSink::connect(
dsn,
PostgresSinkSettings {
table: config.table(),
create_table: config.create_table,
},
)
.await?;
sinks.push(Box::new(BatchedSink::spawn(
Arc::new(sink),
config.batch_settings(),
)));
}
}
}
Ok(sinks)
}
#[cfg(test)]
mod tests {
use super::*;
pub(super) fn sample_record() -> UsageRecord {
UsageRecord {
schema_version: UsageRecord::SCHEMA_VERSION,
request_id: "req_0000000000000001".to_string(),
trace_id: Some("4bf92f3577b34da6a3ce929d0e0e4736".to_string()),
namespace: "acme".to_string(),
subject: "GW_INBOUND_ACME_KEY".to_string(),
signer_kid: Some("verifier-1".to_string()),
model: "gpt-4o".to_string(),
target_provider: "openai".to_string(),
target_model: "gpt-4o-2024-08-06".to_string(),
credential_source: "byok",
credential_id: "openai-primary".to_string(),
status: Status::Ok,
input_tokens: 120,
cache_read_tokens: 12,
cache_write_tokens: 0,
output_tokens: 34,
cost_microdollars: 640,
catalog_version: 0,
latency_ms: 812,
attempts: 1,
}
}
#[tokio::test]
async fn no_configured_sink_keeps_the_stdout_default() {
let sinks = build_sinks(&[], &HashMap::new()).await.expect("defaults");
assert_eq!(sinks.len(), 1);
assert_eq!(sinks[0].name(), "stdout");
}
#[tokio::test]
async fn a_postgres_sink_whose_dsn_env_is_unset_fails_at_boot() {
let config = UsageSinkConfig {
kind: UsageSinkKind::Postgres,
dsn_env: Some("AXOND_TEST_MISSING_DSN".to_string()),
..UsageSinkConfig::default()
};
let err = build_sinks(&[config], &HashMap::new())
.await
.err()
.expect("missing dsn must fail at boot");
assert!(matches!(err, UsageSinkError::Invalid { .. }), "{err:?}");
}
}