semantic bufo search find-bufo.com
bufo

improve logfire observability with structured spans and attributes

- add top-level 'bufo_search' span to group all search operations
- rename spans to use service.operation pattern (voyage.embed_text, turbopuffer.query)
- add comprehensive attributes to each span and log:
* query text visible in all operations
* top_k parameter tracking
* embedding dimensions
* vector search distance metrics (min/max)
* result quality metrics (count, top result, scores)
- remove noisy turbopuffer response debug log
- all child spans now properly nested under parent search span

this creates a clear hierarchy in logfire:
bufo_search (parent)
├─ voyage.embed_text
└─ turbopuffer.query

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

+76 -11
+76 -10
src/search.rs
··· 55 55 query: web::Json<SearchQuery>, 56 56 config: web::Data<Config>, 57 57 ) -> ActixResult<HttpResponse> { 58 + let query_text = &query.query; 59 + let top_k_val = query.top_k; 60 + 61 + let _search_span = logfire::span!( 62 + "bufo_search", 63 + query = query_text, 64 + top_k = top_k_val as i64 65 + ); 66 + 67 + logfire::info!( 68 + "search request received", 69 + query = query_text, 70 + top_k = top_k_val as i64 71 + ); 72 + 58 73 let embedding_client = EmbeddingClient::new(config.voyage_api_key.clone()); 59 74 let tpuf_client = TurbopufferClient::new( 60 75 config.turbopuffer_api_key.clone(), 61 76 config.turbopuffer_namespace.clone(), 62 77 ); 63 78 64 - // run vector search 79 + // generate embedding for user query 65 80 let query_embedding = { 66 - let _span = logfire::span!("generate_embedding", query = &query.query); 81 + let _span = logfire::span!( 82 + "voyage.embed_text", 83 + query = query_text, 84 + model = "voyage-3-lite" 85 + ); 67 86 embedding_client 68 - .embed_text(&query.query) 87 + .embed_text(query_text) 69 88 .await 70 89 .map_err(|e| { 71 - logfire::error!("failed to generate embedding", error = e.to_string()); 90 + let error_msg = e.to_string(); 91 + logfire::error!( 92 + "embedding generation failed", 93 + error = error_msg, 94 + query = query_text 95 + ); 72 96 actix_web::error::ErrorInternalServerError(format!( 73 97 "failed to generate embedding: {}", 74 98 e ··· 76 100 })? 77 101 }; 78 102 103 + logfire::info!( 104 + "embedding generated", 105 + query = query_text, 106 + embedding_dim = query_embedding.len() as i64 107 + ); 108 + 79 109 let vector_request = QueryRequest { 80 110 rank_by: vec![ 81 111 serde_json::json!("vector"), ··· 86 116 include_attributes: Some(vec!["url".to_string(), "name".to_string(), "filename".to_string()]), 87 117 }; 88 118 119 + let namespace = &config.turbopuffer_namespace; 89 120 let vector_results = { 90 - let _span = logfire::span!("vector_search", top_k = query.top_k); 121 + let _span = logfire::span!( 122 + "turbopuffer.query", 123 + query = query_text, 124 + top_k = top_k_val as i64, 125 + namespace = namespace 126 + ); 91 127 tpuf_client.query(vector_request).await.map_err(|e| { 92 - logfire::error!("vector search failed", error = e.to_string()); 128 + let error_msg = e.to_string(); 129 + logfire::error!( 130 + "vector search failed", 131 + error = error_msg, 132 + query = query_text, 133 + top_k = top_k_val as i64 134 + ); 93 135 actix_web::error::ErrorInternalServerError(format!( 94 136 "failed to query turbopuffer (vector): {}", 95 137 e ··· 97 139 })? 98 140 }; 99 141 142 + let min_dist = vector_results.iter().map(|r| r.dist).min_by(|a, b| a.partial_cmp(b).unwrap()).unwrap_or(0.0) as f64; 143 + let max_dist = vector_results.iter().map(|r| r.dist).max_by(|a, b| a.partial_cmp(b).unwrap()).unwrap_or(0.0) as f64; 144 + let results_found = vector_results.len() as i64; 145 + 146 + logfire::info!( 147 + "vector search completed", 148 + query = query_text, 149 + results_found = results_found, 150 + min_dist = min_dist, 151 + max_dist = max_dist 152 + ); 153 + 100 154 // convert vector search results to bufo results 101 155 // turbopuffer returns cosine distance (0 = identical, 2 = opposite) 102 156 // convert to similarity score: 1 - (distance / 2) to get 0-1 range ··· 129 183 }) 130 184 .collect(); 131 185 132 - logfire::info!("search completed", 133 - query = &query.query, 134 - results_count = results.len() as i64, 135 - top_score = results.first().map(|r| r.score as f64).unwrap_or(0.0) 186 + let results_count = results.len() as i64; 187 + let top_result_name = results.first().map(|r| r.name.clone()).unwrap_or_else(|| "none".to_string()); 188 + let top_score_val = results.first().map(|r| r.score as f64).unwrap_or(0.0); 189 + let avg_score_val = if !results.is_empty() { 190 + results.iter().map(|r| r.score as f64).sum::<f64>() / results.len() as f64 191 + } else { 192 + 0.0 193 + }; 194 + 195 + logfire::info!( 196 + "search completed successfully", 197 + query = query_text, 198 + results_count = results_count, 199 + top_result = &top_result_name, 200 + top_score = top_score_val, 201 + avg_score = avg_score_val 136 202 ); 137 203 138 204 Ok(HttpResponse::Ok().json(SearchResponse { results }))
-1
src/turbopuffer.rs
··· 59 59 } 60 60 61 61 let body = response.text().await.context("failed to read response body")?; 62 - log::info!("turbopuffer response: {}", body); 63 62 64 63 serde_json::from_str(&body) 65 64 .context(format!("failed to parse query response: {}", body))