Files
tkmind_go/crates/goose/src/tracing/langfuse_layer.rs
T

510 lines
16 KiB
Rust

use crate::tracing::observation_layer::{BatchManager, ObservationLayer, SpanTracker};
use chrono::Utc;
use reqwest::{Client, StatusCode};
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use std::env;
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::Mutex;
use url::Url;
use uuid::Uuid;
const DEFAULT_LANGFUSE_URL: &str = "http://localhost:3000";
#[derive(Debug, Serialize, Deserialize)]
struct LangfuseIngestionResponse {
successes: Vec<LangfuseIngestionSuccess>,
errors: Vec<LangfuseIngestionError>,
}
#[derive(Debug, Serialize, Deserialize)]
struct LangfuseIngestionSuccess {
id: String,
status: i32,
}
#[derive(Debug, Serialize, Deserialize)]
struct LangfuseIngestionError {
id: String,
status: i32,
message: Option<String>,
error: Option<Value>,
}
#[derive(Debug, Clone)]
pub struct LangfuseBatchManager {
pub batch: Vec<Value>,
pub client: Client,
pub base_url: String,
pub public_key: String,
pub secret_key: String,
}
impl LangfuseBatchManager {
pub fn new(public_key: String, secret_key: String, base_url: String) -> Self {
Self {
batch: Vec::new(),
client: Client::builder()
.timeout(Duration::from_secs(10))
.build()
.expect("Failed to create HTTP client"),
base_url,
public_key,
secret_key,
}
}
pub fn spawn_sender(manager: Arc<Mutex<Self>>) {
const BATCH_INTERVAL: Duration = Duration::from_secs(5);
tokio::spawn(async move {
loop {
tokio::time::sleep(BATCH_INTERVAL).await;
if let Err(e) = manager.lock().await.send() {
tracing::error!(
error.msg = %e,
error.type = %std::any::type_name_of_val(&e),
"Failed to send batch to Langfuse"
);
}
}
});
}
pub async fn send_async(&mut self) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
if self.batch.is_empty() {
return Ok(());
}
let payload = json!({ "batch": self.batch });
let base_url = Url::parse(&self.base_url).map_err(|e| format!("Invalid base URL: {e}"))?;
let url = base_url
.join("api/public/ingestion")
.map_err(|e| format!("Failed to construct endpoint URL: {e}"))?;
let response = self
.client
.post(url)
.basic_auth(&self.public_key, Some(&self.secret_key))
.json(&payload)
.send()
.await?;
match response.status() {
status if status.is_success() => {
let response_body: LangfuseIngestionResponse = response.json().await?;
for error in &response_body.errors {
tracing::error!(
id = %error.id,
status = error.status,
message = error.message.as_deref().unwrap_or("No message"),
error = ?error.error,
"Partial failure in batch ingestion"
);
}
if !response_body.successes.is_empty() {
self.batch.clear();
}
if response_body.successes.is_empty() && !response_body.errors.is_empty() {
Err("Langfuse ingestion failed for all items".into())
} else {
Ok(())
}
}
status @ (StatusCode::BAD_REQUEST
| StatusCode::UNAUTHORIZED
| StatusCode::FORBIDDEN
| StatusCode::NOT_FOUND
| StatusCode::METHOD_NOT_ALLOWED) => {
let err_text = response.text().await.unwrap_or_default();
Err(format!("Langfuse API error: {}: {}", status, err_text).into())
}
status => {
let err_text = response.text().await.unwrap_or_default();
Err(format!("Unexpected status code: {}: {}", status, err_text).into())
}
}
}
}
impl BatchManager for LangfuseBatchManager {
fn add_event(&mut self, event_type: &str, body: Value) {
self.batch.push(json!({
"id": Uuid::new_v4().to_string(),
"timestamp": Utc::now().to_rfc3339(),
"type": event_type,
"body": body
}));
}
fn send(&mut self) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
tokio::task::block_in_place(|| {
tokio::runtime::Handle::current().block_on(self.send_async())
})
}
fn is_empty(&self) -> bool {
self.batch.is_empty()
}
}
pub fn create_langfuse_observer() -> Option<ObservationLayer> {
let public_key = env::var("LANGFUSE_PUBLIC_KEY")
.or_else(|_| env::var("LANGFUSE_INIT_PROJECT_PUBLIC_KEY"))
.unwrap_or_default(); // Use empty string if not found
let secret_key = env::var("LANGFUSE_SECRET_KEY")
.or_else(|_| env::var("LANGFUSE_INIT_PROJECT_SECRET_KEY"))
.unwrap_or_default(); // Use empty string if not found
// Return None if either key is empty
if public_key.is_empty() || secret_key.is_empty() {
return None;
}
let base_url = env::var("LANGFUSE_URL")
.or_else(|_| env::var("LANGFUSE_BASE_URL"))
.or_else(|_| env::var("LANGFUSE_HOST"))
.unwrap_or_else(|_| DEFAULT_LANGFUSE_URL.to_string());
let batch_manager = Arc::new(Mutex::new(LangfuseBatchManager::new(
public_key, secret_key, base_url,
)));
if !cfg!(test) {
LangfuseBatchManager::spawn_sender(batch_manager.clone());
}
Some(ObservationLayer {
batch_manager,
span_tracker: Arc::new(Mutex::new(SpanTracker::new())),
})
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
use std::collections::HashMap;
use tokio::sync::Mutex;
use tracing::dispatcher;
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
struct TestFixture {
original_subscriber: Option<dispatcher::Dispatch>,
original_env_vars: HashMap<String, String>,
mock_server: Option<MockServer>,
}
impl TestFixture {
async fn new() -> Self {
Self {
original_subscriber: Some(dispatcher::get_default(dispatcher::Dispatch::clone)),
original_env_vars: Self::save_env_vars(),
mock_server: None,
}
}
fn save_env_vars() -> HashMap<String, String> {
[
"LANGFUSE_PUBLIC_KEY",
"LANGFUSE_INIT_PROJECT_PUBLIC_KEY",
"LANGFUSE_SECRET_KEY",
"LANGFUSE_INIT_PROJECT_SECRET_KEY",
"LANGFUSE_URL",
]
.iter()
.filter_map(|&var| env::var(var).ok().map(|val| (var.to_string(), val)))
.collect()
}
async fn with_mock_server(mut self) -> Self {
self.mock_server = Some(MockServer::start().await);
self
}
fn mock_server_uri(&self) -> String {
self.mock_server
.as_ref()
.expect("Mock server not initialized")
.uri()
}
async fn mock_response(&self, status: u16, body: Value) {
Mock::given(method("POST"))
.and(path("/api/public/ingestion"))
.respond_with(ResponseTemplate::new(status).set_body_json(body))
.mount(self.mock_server.as_ref().unwrap())
.await;
}
}
impl Drop for TestFixture {
fn drop(&mut self) {
// Restore original subscriber
if let Some(subscriber) = &self.original_subscriber {
let _ = dispatcher::set_global_default(subscriber.clone());
}
// Restore environment
for var in [
"LANGFUSE_PUBLIC_KEY",
"LANGFUSE_INIT_PROJECT_PUBLIC_KEY",
"LANGFUSE_SECRET_KEY",
"LANGFUSE_INIT_PROJECT_SECRET_KEY",
"LANGFUSE_URL",
] {
if let Some(value) = self.original_env_vars.get(var) {
env::set_var(var, value);
} else {
env::remove_var(var);
}
}
}
}
fn create_test_event() -> Value {
json!({
"name": "test_span",
"type": "SPAN"
})
}
#[tokio::test]
async fn test_batch_manager_creation() {
let _fixture = TestFixture::new().await;
let manager = LangfuseBatchManager::new(
"test-public".to_string(),
"test-secret".to_string(),
"http://test.local".to_string(),
);
assert_eq!(manager.public_key, "test-public");
assert_eq!(manager.secret_key, "test-secret");
assert_eq!(manager.base_url, "http://test.local");
assert!(manager.batch.is_empty());
}
#[tokio::test]
async fn test_add_event() {
let _fixture = TestFixture::new().await;
let mut manager = LangfuseBatchManager::new(
"test-public".to_string(),
"test-secret".to_string(),
"http://test.local".to_string(),
);
manager.add_event("test-event", create_test_event());
assert_eq!(manager.batch.len(), 1);
let event = &manager.batch[0];
assert_eq!(event["type"], "test-event");
assert_eq!(event["body"], create_test_event());
assert!(event["id"].as_str().is_some());
assert!(event["timestamp"].as_str().is_some());
}
#[tokio::test]
async fn test_batch_send_success() {
let fixture = TestFixture::new().await.with_mock_server().await;
fixture
.mock_response(
200,
json!({
"successes": [{"id": "1", "status": 200}],
"errors": []
}),
)
.await;
let mut manager = LangfuseBatchManager::new(
"test-public".to_string(),
"test-secret".to_string(),
fixture.mock_server_uri(),
);
manager.add_event("test-event", create_test_event());
let result = manager.send_async().await;
assert!(result.is_ok());
assert!(manager.batch.is_empty());
}
#[tokio::test]
async fn test_batch_send_partial_failure() {
let fixture = TestFixture::new().await.with_mock_server().await;
fixture
.mock_response(
200,
json!({
"successes": [{"id": "1", "status": 200}],
"errors": [{"id": "2", "status": 400, "message": "Invalid data"}]
}),
)
.await;
let mut manager = LangfuseBatchManager::new(
"test-public".to_string(),
"test-secret".to_string(),
fixture.mock_server_uri(),
);
manager.add_event("test-event", create_test_event());
let result = manager.send_async().await;
assert!(result.is_ok());
assert!(manager.batch.is_empty());
}
#[tokio::test]
async fn test_batch_send_complete_failure() {
let fixture = TestFixture::new().await.with_mock_server().await;
fixture
.mock_response(
200,
json!({
"successes": [],
"errors": [{"id": "1", "status": 400, "message": "Invalid data"}]
}),
)
.await;
let mut manager = LangfuseBatchManager::new(
"test-public".to_string(),
"test-secret".to_string(),
fixture.mock_server_uri(),
);
manager.add_event("test-event", create_test_event());
let result = manager.send_async().await;
assert!(result.is_err());
assert!(!manager.batch.is_empty());
}
#[tokio::test]
async fn test_create_langfuse_observer() {
let fixture = TestFixture::new().await.with_mock_server().await;
// Test 1: No environment variables set - remove all possible variables
for var in &[
"LANGFUSE_PUBLIC_KEY",
"LANGFUSE_INIT_PROJECT_PUBLIC_KEY",
"LANGFUSE_SECRET_KEY",
"LANGFUSE_INIT_PROJECT_SECRET_KEY",
"LANGFUSE_URL",
] {
env::remove_var(var);
}
let observer = create_langfuse_observer();
assert!(
observer.is_none(),
"Observer should be None without environment variables"
);
// Test 2: Only public key set (regular)
env::set_var("LANGFUSE_PUBLIC_KEY", "test-public-key");
let observer = create_langfuse_observer();
assert!(
observer.is_none(),
"Observer should be None with only public key"
);
env::remove_var("LANGFUSE_PUBLIC_KEY");
// Test 3: Only secret key set (regular)
env::set_var("LANGFUSE_SECRET_KEY", "test-secret-key");
let observer = create_langfuse_observer();
assert!(
observer.is_none(),
"Observer should be None with only secret key"
);
env::remove_var("LANGFUSE_SECRET_KEY");
// Test 4: Only public key set (init project)
env::set_var("LANGFUSE_INIT_PROJECT_PUBLIC_KEY", "test-public-key");
let observer = create_langfuse_observer();
assert!(
observer.is_none(),
"Observer should be None with only init project public key"
);
env::remove_var("LANGFUSE_INIT_PROJECT_PUBLIC_KEY");
// Test 5: Only secret key set (init project)
env::set_var("LANGFUSE_INIT_PROJECT_SECRET_KEY", "test-secret-key");
let observer = create_langfuse_observer();
assert!(
observer.is_none(),
"Observer should be None with only init project secret key"
);
env::remove_var("LANGFUSE_INIT_PROJECT_SECRET_KEY");
// Test 6: Both regular keys set (should succeed)
env::set_var("LANGFUSE_PUBLIC_KEY", "test-public-key");
env::set_var("LANGFUSE_SECRET_KEY", "test-secret-key");
env::set_var("LANGFUSE_URL", fixture.mock_server_uri());
let observer = create_langfuse_observer();
assert!(
observer.is_some(),
"Observer should be Some with both regular keys set"
);
// Clean up regular keys
env::remove_var("LANGFUSE_PUBLIC_KEY");
env::remove_var("LANGFUSE_SECRET_KEY");
// Test 7: Both init project keys set (should succeed)
env::set_var("LANGFUSE_INIT_PROJECT_PUBLIC_KEY", "test-public-key");
env::set_var("LANGFUSE_INIT_PROJECT_SECRET_KEY", "test-secret-key");
let observer = create_langfuse_observer();
assert!(
observer.is_some(),
"Observer should be Some with both init project keys set"
);
// Verify the observer has an empty batch manager
let batch_manager = observer.unwrap().batch_manager;
assert!(batch_manager.lock().await.is_empty());
}
#[tokio::test]
async fn test_batch_manager_spawn_sender() {
let fixture = TestFixture::new().await.with_mock_server().await;
fixture
.mock_response(
200,
json!({
"successes": [{"id": "1", "status": 200}],
"errors": []
}),
)
.await;
let manager = Arc::new(Mutex::new(LangfuseBatchManager::new(
"test-public".to_string(),
"test-secret".to_string(),
fixture.mock_server_uri(),
)));
manager
.lock()
.await
.add_event("test-event", create_test_event());
// Instead of spawning the sender which uses blocking operations,
// test the async send directly
let result = manager.lock().await.send_async().await;
assert!(result.is_ok());
assert!(manager.lock().await.batch.is_empty());
}
}