mod batch;
mod otlp;
mod postgres;
use std::collections::HashMap;
use std::sync::Arc;
use std::time::{Duration, Instant, 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())
}
}
#[derive(Debug, PartialEq, Eq)]
pub enum FlushOutcome {
Flushed { records: u64 },
Failed { records: u64, error: String },
TimedOut { abandoned: u64 },
}
impl FlushOutcome {
pub fn as_str(&self) -> &'static str {
match self {
Self::Flushed { .. } => "flushed",
Self::Failed { .. } => "failed",
Self::TimedOut { .. } => "timeout",
}
}
pub fn is_complete(&self) -> bool {
matches!(self, Self::Flushed { .. })
}
}
#[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(())
}
async fn flush(&self) -> FlushOutcome {
FlushOutcome::Flushed { records: 0 }
}
fn abandon(&self, reason: DropReason) -> u64 {
let _ = reason;
0
}
}
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;
}
}
pub async fn flush(&self, budget: Duration) -> FlushReport {
let deadline = Instant::now() + budget;
let mut sinks = Vec::with_capacity(self.sinks.len());
for sink in &self.sinks {
let remaining = deadline.saturating_duration_since(Instant::now());
let outcome = match tokio::time::timeout(remaining, sink.flush()).await {
Ok(outcome) => outcome,
Err(_) => FlushOutcome::TimedOut {
abandoned: sink.abandon(DropReason::Shutdown),
},
};
crate::telemetry::metrics::record_usage_flush(sink.name(), outcome.as_str());
sinks.push((sink.name(), outcome));
}
FlushReport { sinks }
}
}
#[derive(Debug)]
pub struct FlushReport {
pub sinks: Vec<(&'static str, FlushOutcome)>,
}
impl FlushReport {
pub fn is_complete(&self) -> bool {
self.sinks.iter().all(|(_, outcome)| outcome.is_complete())
}
pub fn log(&self) {
for (sink, outcome) in &self.sinks {
match outcome {
FlushOutcome::Flushed { records } => {
tracing::info!(sink, records, "usage sink flushed on shutdown")
}
FlushOutcome::Failed { records, error } => tracing::error!(
sink,
records,
error = %error,
reason = DropReason::SinkError.as_str(),
"usage sink rejected its buffered records on shutdown"
),
FlushOutcome::TimedOut { abandoned } => tracing::error!(
sink,
abandoned,
reason = DropReason::Shutdown.as_str(),
"usage sink flush exceeded its bound; buffered records were abandoned"
),
}
}
}
}
#[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,
}
}
struct StalledSink;
#[async_trait]
impl UsageSink for StalledSink {
fn name(&self) -> &'static str {
"stalled"
}
async fn record(&self, _record: &UsageRecord) {}
async fn record_batch(&self, _batch: &[ObservedRecord]) -> Result<(), SinkFailure> {
std::future::pending().await
}
}
#[tokio::test]
async fn a_write_through_sink_has_nothing_to_flush() {
let fanout = UsageFanout::new(vec![Box::new(StdoutSink)]);
let report = fanout.flush(Duration::from_secs(5)).await;
assert!(report.is_complete());
assert_eq!(
report.sinks,
vec![("stdout", FlushOutcome::Flushed { records: 0 })]
);
}
#[tokio::test]
async fn a_stalled_sink_flush_ends_at_the_bound_with_its_buffer_accounted() {
let batched = BatchedSink::spawn(
Arc::new(StalledSink),
BatchSettings {
capacity: 16,
max_batch: 1,
flush_interval: Duration::from_millis(5),
},
);
let fanout = UsageFanout::new(vec![Box::new(batched)]);
for _ in 0..4 {
fanout.record(&sample_record()).await;
}
tokio::time::sleep(Duration::from_millis(20)).await;
let report = fanout.flush(Duration::from_millis(50)).await;
assert!(
!report.is_complete(),
"a stalled sink cannot report success"
);
let (sink, outcome) = &report.sinks[0];
assert_eq!(*sink, "stalled");
assert!(
matches!(outcome, FlushOutcome::TimedOut { abandoned } if *abandoned > 0),
"{outcome:?}"
);
}
#[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:?}");
}
}