diff --git a/crates/observation-tools-client/openapi.json b/crates/observation-tools-client/openapi.json index 68fa813..2063253 100644 --- a/crates/observation-tools-client/openapi.json +++ b/crates/observation-tools-client/openapi.json @@ -83,12 +83,15 @@ }, { "properties": { - "payload": { - "$ref": "#/components/schemas/PayloadOrPointerResponse" + "payloads": { + "items": { + "$ref": "#/components/schemas/GetPayload" + }, + "type": "array" } }, "required": [ - "payload" + "payloads" ], "type": "object" } @@ -105,6 +108,39 @@ ], "type": "object" }, + "GetPayload": { + "properties": { + "data": { + "$ref": "#/components/schemas/PayloadOrPointerResponse" + }, + "id": { + "$ref": "#/components/schemas/PayloadId" + }, + "mime_type": { + "type": "string" + }, + "name": { + "type": "string" + }, + "size": { + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "id", + "name", + "mime_type", + "size", + "data" + ], + "type": "object" + }, + "GroupId": { + "description": "Unique identifier for a group\n\nGroup IDs are user-provided strings. By default, a UUID v7 string is generated,\nbut any string value is accepted.", + "example": "018e9a3a2c1b7e3f8d2a4b5c6d7e8f9b", + "type": "string" + }, "ListExecutionsResponse": { "description": "Response for listing executions", "properties": { @@ -170,17 +206,17 @@ "$ref": "#/components/schemas/ExecutionId", "description": "ID of the execution this observation belongs to" }, - "id": { - "$ref": "#/components/schemas/ObservationId", - "description": "Unique identifier for this observation" - }, - "labels": { - "description": "Hierarchical labels for grouping observations\nUses path convention (e.g., \"api/request/headers\")", + "group_ids": { + "description": "IDs of groups this observation belongs to", "items": { - "type": "string" + "$ref": "#/components/schemas/GroupId" }, "type": "array" }, + "id": { + "$ref": "#/components/schemas/ObservationId", + "description": "Unique identifier for this observation" + }, "log_level": { "$ref": "#/components/schemas/LogLevel", "description": "Log level for this observation" @@ -195,10 +231,6 @@ }, "type": "object" }, - "mime_type": { - "description": "MIME type of the payload (e.g., \"text/plain\", \"application/json\")", - "type": "string" - }, "name": { "description": "User-defined name for this observation", "type": "string" @@ -207,16 +239,16 @@ "$ref": "#/components/schemas/ObservationType", "description": "Type of observation" }, + "parent_group_id": { + "$ref": "#/components/schemas/GroupId", + "description": "Parent group ID (used when observation_type == Group)", + "nullable": true + }, "parent_span_id": { "description": "Parent span ID (for tracing integration)", "nullable": true, "type": "string" }, - "payload_size": { - "description": "Size of the payload in bytes", - "minimum": 0, - "type": "integer" - }, "source": { "$ref": "#/components/schemas/SourceInfo", "description": "Source location where this observation was created", @@ -229,9 +261,7 @@ "name", "observation_type", "log_level", - "created_at", - "mime_type", - "payload_size" + "created_at" ], "type": "object" }, @@ -245,10 +275,16 @@ "enum": [ "LogEntry", "Payload", - "Span" + "Span", + "Group" ], "type": "string" }, + "PayloadId": { + "description": "Unique identifier for a payload (UUIDv7)", + "example": "018e9a3a2c1b7e3f8d2a4b5c6d7e8f9c", + "type": "string" + }, "PayloadOrPointerResponse": { "oneOf": [ { @@ -556,7 +592,7 @@ }, "/api/exe/{execution_id}/obs/{observation_id}/content": { "get": { - "operationId": "get_observation_blob", + "operationId": "get_observation_blob_legacy", "parameters": [ { "description": "Execution ID", @@ -600,7 +636,68 @@ "description": "Observation blob not found" } }, - "summary": "Get observation blob content", + "summary": "Get observation blob content (legacy route for backward compat)\nThis is kept to support old URLs like /api/exe/{exec_id}/obs/{obs_id}/content", + "tags": [ + "observations" + ] + } + }, + "/api/exe/{execution_id}/obs/{observation_id}/payload/{payload_id}/content": { + "get": { + "operationId": "get_observation_blob", + "parameters": [ + { + "description": "Execution ID", + "in": "path", + "name": "execution_id", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Observation ID", + "in": "path", + "name": "observation_id", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Payload ID", + "in": "path", + "name": "payload_id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/octet-stream": { + "schema": { + "items": { + "format": "int32", + "minimum": 0, + "type": "integer" + }, + "type": "array" + } + } + }, + "description": "Payload content" + }, + "400": { + "description": "Bad request" + }, + "404": { + "description": "Payload not found" + } + }, + "summary": "Get observation payload content", "tags": [ "observations" ] diff --git a/crates/observation-tools-server/src/api/mod.rs b/crates/observation-tools-server/src/api/mod.rs index 9f96bff..e1831dc 100644 --- a/crates/observation-tools-server/src/api/mod.rs +++ b/crates/observation-tools-server/src/api/mod.rs @@ -144,6 +144,7 @@ pub fn build_api() -> (Router, Router, OpenApi) { .routes(routes!(observations::list_observations)) .routes(routes!(observations::get_observation)) .routes(routes!(observations::get_observation_blob)) + .routes(routes!(observations::get_observation_blob_legacy)) .split_for_parts(); let mut openapi = OpenApi::default(); diff --git a/crates/observation-tools-server/src/api/observations/create.rs b/crates/observation-tools-server/src/api/observations/create.rs index ed80342..d8cb9bf 100644 --- a/crates/observation-tools-server/src/api/observations/create.rs +++ b/crates/observation-tools-server/src/api/observations/create.rs @@ -4,24 +4,39 @@ use crate::api::types::CreateObservationsResponse; use crate::api::AppError; use crate::storage::BlobStorage; use crate::storage::MetadataStorage; -use crate::storage::ObservationWithPayloadPointer; -use crate::storage::PayloadOrPointer; +use crate::storage::ObservationWithPayloads; +use crate::storage::PayloadData; +use crate::storage::StoredPayload; use axum::extract::Multipart; use axum::extract::Path; use axum::extract::State; use axum::Json; use observation_tools_shared::models::ExecutionId; use observation_tools_shared::Observation; +use observation_tools_shared::PayloadId; use observation_tools_shared::BLOB_THRESHOLD_BYTES; +use serde::Deserialize; use std::collections::HashMap; use std::sync::Arc; +/// Entry in the payload manifest sent by new clients +#[derive(Debug, Deserialize)] +struct PayloadManifestEntry { + observation_id: String, + payload_id: String, + #[allow(dead_code)] + name: String, + mime_type: String, + #[allow(dead_code)] + size: usize, +} + /// Create observations (batch) via multipart form /// /// The multipart form should contain: -/// - "observations": JSON array of observation metadata (with empty -/// payload.data) -/// - "{observation_id}": Binary payload data for each observation +/// - "observations": JSON array of observation metadata +/// - "{obs_id}:{payload_id}:{name}": Binary payload data for each payload +/// - Legacy: "{obs_id}:{name}" or "{obs_id}" formats are also supported #[tracing::instrument(skip(metadata, blobs, multipart))] pub async fn create_observations( State(metadata): State>, @@ -32,6 +47,7 @@ pub async fn create_observations( let _execution_id = ExecutionId::parse(&execution_id)?; let mut observations: Option> = None; + let mut payload_manifest: Option> = None; let mut payloads: HashMap = HashMap::new(); // Parse all multipart fields @@ -51,8 +67,17 @@ pub async fn create_observations( let parsed: Vec = serde_json::from_slice(&data) .map_err(|e| AppError::BadRequest(format!("Failed to parse observations JSON: {}", e)))?; observations = Some(parsed); + } else if name == "payload_manifest" { + // Parse payload manifest (new clients send this for authoritative MIME types) + let data = field + .bytes() + .await + .map_err(|e| AppError::BadRequest(format!("Failed to read payload manifest: {}", e)))?; + let parsed: Vec = serde_json::from_slice(&data) + .map_err(|e| AppError::BadRequest(format!("Failed to parse payload manifest JSON: {}", e)))?; + payload_manifest = Some(parsed); } else { - // This is a payload field, keyed by observation ID + // This is a payload field let data = field.bytes().await.map_err(|e| { AppError::BadRequest(format!("Failed to read payload data for {}: {}", name, e)) })?; @@ -60,7 +85,7 @@ pub async fn create_observations( } } - let mut observations = observations.ok_or_else(|| { + let observations = observations.ok_or_else(|| { AppError::BadRequest("Missing 'observations' field in multipart form".to_string()) })?; @@ -71,32 +96,85 @@ pub async fn create_observations( "Creating observations batch" ); - // Merge payload data into observations and handle blob storage - let mut observations_with_payloads = vec![]; - for obs in &mut observations { + // Build a lookup from (observation_id, payload_id) -> manifest entry for MIME types + let manifest_lookup: HashMap<(String, String), &PayloadManifestEntry> = payload_manifest + .as_ref() + .map(|entries| { + entries + .iter() + .map(|e| ((e.observation_id.clone(), e.payload_id.clone()), e)) + .collect() + }) + .unwrap_or_default(); + + // Build ObservationWithPayloads for each observation by collecting all matching payloads + let mut observations_with_payloads = Vec::with_capacity(observations.len()); + + for obs in &observations { let obs_id_str = obs.id.to_string(); - let Some(payload_data) = payloads.remove(&obs_id_str) else { + let mut obs_payloads: Vec = Vec::new(); + + // Collect all payload keys that belong to this observation + let matching_keys: Vec = payloads + .keys() + .filter(|k| { + let id_part = k.split(':').next().unwrap_or(k); + id_part == obs_id_str + }) + .cloned() + .collect(); + + for key in matching_keys { + let data = payloads.remove(&key).expect("key was just found"); + let (payload_id, name) = parse_payload_key(&key, &obs_id_str)?; + + // Determine MIME type: check manifest first, fall back to heuristic for old clients + let mime_type = if let Some(entry) = manifest_lookup.get(&(obs_id_str.clone(), payload_id.as_str().to_string())) { + entry.mime_type.clone() + } else if serde_json::from_slice::(&data).is_ok() { + "application/json".to_string() + } else if std::str::from_utf8(&data).is_ok() { + "text/plain".to_string() + } else { + "application/octet-stream".to_string() + }; + + let size = data.len(); + let payload_data = if size >= BLOB_THRESHOLD_BYTES { + blobs + .store_blob(obs.id, payload_id.clone(), data.clone()) + .await?; + PayloadData::Blob + } else { + PayloadData::Inline(data.to_vec()) + }; + + obs_payloads.push(StoredPayload { + id: payload_id, + name, + mime_type, + size, + data: payload_data, + }); + } + + if obs_payloads.is_empty() { return Err(AppError::BadRequest(format!( "Missing payload data for observation ID {}", obs.id ))); - }; - let payload = if payload_data.len() >= BLOB_THRESHOLD_BYTES { - blobs.store_blob(obs.id, payload_data).await?; - None - } else { - Some(payload_data.to_vec()) - }; - observations_with_payloads.push(ObservationWithPayloadPointer { + } + + observations_with_payloads.push(ObservationWithPayloads { observation: obs.clone(), - payload_or_pointer: PayloadOrPointer { payload }, + payloads: obs_payloads, }); } - // Warn about any orphan payloads - for orphan_id in payloads.keys() { + // Warn about any orphaned payloads + for key in payloads.keys() { tracing::warn!( - observation_id = %orphan_id, + payload_key = %key, "Received payload for unknown observation ID" ); } @@ -113,3 +191,51 @@ pub async fn create_observations( Ok(Json(CreateObservationsResponse {})) } + +/// Parse a payload key into (PayloadId, name). +/// Supports formats: +/// - "{obs_id}:{payload_id}:{name}" (new format) +/// - "{obs_id}:{name}" (legacy named format, generates new PayloadId) +/// - "{obs_id}" (legacy bare format, generates new PayloadId, name = "default") +fn parse_payload_key( + key: &str, + obs_id_str: &str, +) -> Result<(PayloadId, String), AppError> { + let rest = &key[obs_id_str.len()..]; + if rest.is_empty() { + // Legacy bare format: "{obs_id}" + return Ok((PayloadId::new(), "default".to_string())); + } + + if !rest.starts_with(':') { + return Err(AppError::BadRequest(format!( + "Invalid payload key format: {}", + key + ))); + } + + let after_obs_id = &rest[1..]; // skip the ':' + let parts: Vec<&str> = after_obs_id.splitn(2, ':').collect(); + + match parts.len() { + 1 => { + // Legacy "{obs_id}:{name}" format + Ok((PayloadId::new(), parts[0].to_string())) + } + 2 => { + // "{obs_id}:{payload_id}:{name}" - try parsing first part as PayloadId + if uuid::Uuid::parse_str(parts[0]).is_ok() { + let payload_id = PayloadId::from(parts[0]); + Ok((payload_id, parts[1].to_string())) + } else { + // Not a valid PayloadId, treat as legacy format where the whole thing is a name + // This shouldn't normally happen + Ok((PayloadId::new(), after_obs_id.to_string())) + } + } + _ => Err(AppError::BadRequest(format!( + "Invalid payload key format: {}", + key + ))), + } +} diff --git a/crates/observation-tools-server/src/api/observations/get.rs b/crates/observation-tools-server/src/api/observations/get.rs index 5ea6d74..d73ffe0 100644 --- a/crates/observation-tools-server/src/api/observations/get.rs +++ b/crates/observation-tools-server/src/api/observations/get.rs @@ -2,14 +2,16 @@ use crate::api::AppError; use crate::storage::MetadataStorage; -use crate::storage::ObservationWithPayloadPointer; -use crate::storage::PayloadOrPointer; +use crate::storage::ObservationWithPayloads; +use crate::storage::PayloadData; +use crate::storage::StoredPayload; use axum::extract::Path; use axum::extract::State; use axum::Json; use observation_tools_shared::models::ExecutionId; use observation_tools_shared::Observation; use observation_tools_shared::ObservationId; +use observation_tools_shared::PayloadId; use serde::Deserialize; use serde::Serialize; use std::sync::Arc; @@ -24,14 +26,42 @@ pub struct GetObservationResponse { pub struct GetObservation { #[serde(flatten)] pub observation: Observation, - pub payload: PayloadOrPointerResponse, + pub payloads: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +pub struct GetPayload { + pub id: PayloadId, + pub name: String, + pub mime_type: String, + pub size: usize, + pub data: PayloadOrPointerResponse, } impl GetObservation { - pub fn new(obs: ObservationWithPayloadPointer) -> GetObservation { + pub fn new(obs: ObservationWithPayloads) -> GetObservation { + let exec_id = obs.observation.execution_id; + let obs_id = obs.observation.id; + let payloads = obs + .payloads + .into_iter() + .map(|p| { + let id = p.id.clone(); + let name = p.name.clone(); + let mime_type = p.mime_type.clone(); + let size = p.size; + GetPayload { + id, + name, + mime_type, + size, + data: PayloadOrPointerResponse::from_stored_payload(p, exec_id, obs_id), + } + }) + .collect(); GetObservation { - payload: PayloadOrPointerResponse::new(&obs.observation, obs.payload_or_pointer), observation: obs.observation, + payloads, } } } @@ -46,30 +76,40 @@ pub enum PayloadOrPointerResponse { } impl PayloadOrPointerResponse { - pub fn new(obs: &Observation, payload_or_pointer: PayloadOrPointer) -> Self { - let Some(data) = payload_or_pointer.payload else { - return PayloadOrPointerResponse::Pointer { - url: format!("/api/exe/{}/obs/{}/content", obs.execution_id, obs.id), - }; + pub fn from_stored_payload( + payload: StoredPayload, + exec_id: ExecutionId, + obs_id: ObservationId, + ) -> Self { + let data = match payload.data { + PayloadData::Inline(data) => data, + PayloadData::Blob => { + return PayloadOrPointerResponse::Pointer { + url: format!( + "/api/exe/{}/obs/{}/payload/{}/content", + exec_id, obs_id, payload.id.as_str() + ), + }; + } }; - if obs.mime_type.starts_with("application/json") { + if payload.mime_type.starts_with("application/json") { if let Ok(json_value) = serde_json::from_slice::(&data) { return PayloadOrPointerResponse::Json(json_value); } } - if obs.mime_type.starts_with("text/x-rust-debug") { + if payload.mime_type.starts_with("text/x-rust-debug") { if let Ok(text) = String::from_utf8(data.clone()) { let parsed = crate::debug_parser::parse_debug_to_json(&text); return PayloadOrPointerResponse::Json(parsed); } } - if obs.mime_type.starts_with("text/plain") { + if payload.mime_type.starts_with("text/plain") { if let Ok(text) = String::from_utf8(data.clone()) { return PayloadOrPointerResponse::Text(text); } } - if obs.mime_type.starts_with("text/markdown") { + if payload.mime_type.starts_with("text/markdown") { if let Ok(text) = String::from_utf8(data.clone()) { return PayloadOrPointerResponse::Markdown { raw: text }; } @@ -101,10 +141,7 @@ pub async fn get_observation( ) -> Result, AppError> { let _execution_id = ExecutionId::parse(&execution_id)?; let observation_id = ObservationId::parse(&observation_id)?; - let observations = metadata.get_observations(&[observation_id]).await?; - let observation = observations.into_iter().next().ok_or_else(|| { - crate::storage::StorageError::NotFound(format!("Observation {} not found", observation_id)) - })?; + let observation = metadata.get_observation(observation_id).await?; Ok(Json(GetObservationResponse { observation: GetObservation::new(observation), })) diff --git a/crates/observation-tools-server/src/api/observations/get_blob.rs b/crates/observation-tools-server/src/api/observations/get_blob.rs index 3f317ae..2db6ddd 100644 --- a/crates/observation-tools-server/src/api/observations/get_blob.rs +++ b/crates/observation-tools-server/src/api/observations/get_blob.rs @@ -3,6 +3,7 @@ use crate::api::AppError; use crate::storage::BlobStorage; use crate::storage::MetadataStorage; +use crate::storage::PayloadData; use crate::storage::StorageError; use axum::extract::Path; use axum::extract::State; @@ -11,9 +12,70 @@ use axum::http::HeaderValue; use axum::http::StatusCode; use axum::response::IntoResponse; use observation_tools_shared::ObservationId; +use observation_tools_shared::PayloadId; use std::sync::Arc; -/// Get observation blob content +/// Get observation payload content +#[utoipa::path( + get, + path = "/api/exe/{execution_id}/obs/{observation_id}/payload/{payload_id}/content", + params( + ("execution_id" = String, Path, description = "Execution ID"), + ("observation_id" = String, Path, description = "Observation ID"), + ("payload_id" = String, Path, description = "Payload ID") + ), + responses( + (status = 200, description = "Payload content", body = Vec, content_type = "application/octet-stream"), + (status = 404, description = "Payload not found"), + (status = 400, description = "Bad request") + ), + tag = "observations" +)] +#[tracing::instrument(skip(metadata, blobs))] +pub async fn get_observation_blob( + State(metadata): State>, + State(blobs): State>, + Path((_execution_id, observation_id, payload_id)): Path<(String, String, String)>, +) -> Result { + let observation_id = ObservationId::parse(&observation_id)?; + let payload_id = PayloadId::from(payload_id); + let observation = metadata.get_observation(observation_id).await?; + + // Find the payload in the manifest + let payload = observation + .payloads + .iter() + .find(|p| p.id == payload_id) + .ok_or_else(|| { + StorageError::NotFound(format!( + "Payload {} not found for observation {}", + payload_id.as_str(), observation_id + )) + })?; + + let content_type = HeaderValue::from_str(&payload.mime_type) + .unwrap_or_else(|_| HeaderValue::from_static("application/octet-stream")); + + // If the payload data is inline (from prefix scan), return it directly + if let PayloadData::Inline(ref data) = payload.data { + return Ok(( + StatusCode::OK, + [(header::CONTENT_TYPE, content_type)], + data.clone(), + )); + } + + // Otherwise fetch from blob storage + let blob = blobs.get_blob(observation_id, payload_id.clone()).await?; + Ok(( + StatusCode::OK, + [(header::CONTENT_TYPE, content_type)], + blob.to_vec(), + )) +} + +/// Get observation blob content (legacy route for backward compat) +/// This is kept to support old URLs like /api/exe/{exec_id}/obs/{obs_id}/content #[utoipa::path( get, path = "/api/exe/{execution_id}/obs/{observation_id}/content", @@ -29,27 +91,34 @@ use std::sync::Arc; tag = "observations" )] #[tracing::instrument(skip(metadata, blobs))] -pub async fn get_observation_blob( +pub async fn get_observation_blob_legacy( State(metadata): State>, State(blobs): State>, Path((_execution_id, observation_id)): Path<(String, String)>, ) -> Result { let observation_id = ObservationId::parse(&observation_id)?; - let observations = metadata.get_observations(&[observation_id]).await?; - let observation = observations - .into_iter() - .next() - .ok_or_else(|| StorageError::NotFound(format!("Observation {} not found", observation_id)))?; - let content_type = HeaderValue::from_str(&observation.observation.mime_type) + let observation = metadata.get_observation(observation_id).await?; + + // Use the first payload + let payload = observation.payloads.first().ok_or_else(|| { + StorageError::NotFound(format!( + "No payloads found for observation {}", + observation_id + )) + })?; + + let content_type = HeaderValue::from_str(&payload.mime_type) .unwrap_or_else(|_| HeaderValue::from_static("application/octet-stream")); - if let Some(payload) = observation.payload_or_pointer.payload { + + if let PayloadData::Inline(ref data) = payload.data { return Ok(( StatusCode::OK, [(header::CONTENT_TYPE, content_type)], - payload, + data.clone(), )); } - let blob = blobs.get_blob(observation_id).await?; + + let blob = blobs.get_blob(observation_id, payload.id.clone()).await?; Ok(( StatusCode::OK, [(header::CONTENT_TYPE, content_type)], diff --git a/crates/observation-tools-server/src/api/observations/list.rs b/crates/observation-tools-server/src/api/observations/list.rs index 432c8a4..3494376 100644 --- a/crates/observation-tools-server/src/api/observations/list.rs +++ b/crates/observation-tools-server/src/api/observations/list.rs @@ -1,7 +1,6 @@ //! List observations handler use crate::api::observations::get::GetObservation; -use crate::api::observations::get::PayloadOrPointerResponse; use crate::api::types::ListObservationsQuery; use crate::api::AppError; use crate::storage::MetadataStorage; @@ -59,10 +58,7 @@ pub async fn list_observations( Ok(Json(ListObservationsResponse { observations: observations .into_iter() - .map(|o| GetObservation { - payload: PayloadOrPointerResponse::new(&o.observation, o.payload_or_pointer), - observation: o.observation, - }) + .map(GetObservation::new) .collect(), has_next_page, })) diff --git a/crates/observation-tools-server/src/api/observations/mod.rs b/crates/observation-tools-server/src/api/observations/mod.rs index 3e2a03f..e9a8736 100644 --- a/crates/observation-tools-server/src/api/observations/mod.rs +++ b/crates/observation-tools-server/src/api/observations/mod.rs @@ -9,7 +9,10 @@ pub use create::create_observations; pub use get::__path_get_observation; pub use get::get_observation; pub use get::GetObservation; +pub use get::GetPayload; pub use get_blob::__path_get_observation_blob; +pub use get_blob::__path_get_observation_blob_legacy; pub use get_blob::get_observation_blob; +pub use get_blob::get_observation_blob_legacy; pub use list::__path_list_observations; pub use list::list_observations; diff --git a/crates/observation-tools-server/src/ui/execution_detail.rs b/crates/observation-tools-server/src/ui/execution_detail.rs index 06045db..938cc39 100644 --- a/crates/observation-tools-server/src/ui/execution_detail.rs +++ b/crates/observation-tools-server/src/ui/execution_detail.rs @@ -116,11 +116,11 @@ async fn execution_detail_view( // If observation ID is provided, load the observation for the side panel let selected_observation = if let Some(obs_id) = &query.obs { let observation_id = ObservationId::parse(obs_id)?; - let obs_list = metadata.get_observations(&[observation_id]).await?; - obs_list - .into_iter() - .next() - .map(|obs| GetObservation::new(obs)) + match metadata.get_observation(observation_id).await { + Ok(obs) => Some(GetObservation::new(obs)), + Err(StorageError::NotFound(_)) => None, + Err(e) => return Err(e.into()), + } } else { None }; diff --git a/crates/observation-tools-server/src/ui/observation_detail.rs b/crates/observation-tools-server/src/ui/observation_detail.rs index f9ec595..2559c97 100644 --- a/crates/observation-tools-server/src/ui/observation_detail.rs +++ b/crates/observation-tools-server/src/ui/observation_detail.rs @@ -25,11 +25,8 @@ pub async fn observation_detail( "Rendering observation detail page" ); let parsed_observation_id = observation_tools_shared::ObservationId::parse(&observation_id)?; - let observation = match metadata.get_observations(&[parsed_observation_id]).await { - Ok(observations) => observations - .into_iter() - .next() - .map(|obs| GetObservation::new(obs)), + let observation = match metadata.get_observation(parsed_observation_id).await { + Ok(obs) => Some(GetObservation::new(obs)), Err(crate::storage::StorageError::NotFound(_)) => { // The user may go to the observation page before it's uploaded. Since the page // auto-refreshes, we do not throw an error so it will show up once it's diff --git a/crates/observation-tools-server/templates/_observation_content.html b/crates/observation-tools-server/templates/_observation_content.html index e638a21..b74effb 100644 --- a/crates/observation-tools-server/templates/_observation_content.html +++ b/crates/observation-tools-server/templates/_observation_content.html @@ -29,10 +29,10 @@

{{ observation.name }}

{{ observation.parent_span_id }}

{% endif %} - {% if observation.labels %} -

- labels: - {{ observation.labels|join(", ") }} + {% if observation.group_ids %} +

+ groups: + {{ observation.group_ids|join(", ") }}

{% endif %} {% if observation.metadata %} @@ -49,52 +49,56 @@

met
-

payload

-

- type: - {{ observation.mime_type }} -

-

- size: - {{ observation.payload_size }} bytes -

-

- Open raw content in new tab -

+

payloads

- {% if observation.payload_size > display_threshold %} -
-

Payload is too large to display inline.

-
- {% elif observation.payload.Markdown is defined %} -
- {{ observation.payload.Markdown.raw|render_markdown|safe }} -
- {% elif observation.payload.Json is defined %} -
- {{ render_json(observation.payload.Json) }} -
- {% elif observation.payload.Text is defined %} -
{{ observation.payload.Text|unescape }}
- {% elif observation.payload.Pointer is defined %} -
-

Payload stored externally.

- Load external content + {% for payload in observation.payloads %} +
+

{{ payload.name }}

+

+ type: + {{ payload.mime_type }} + size: + {{ payload.size }} bytes +

+

+ Open raw content in new tab +

+ + {% if payload.size > display_threshold %} +
+

Payload is too large to display inline.

+
+ {% elif payload.data.Markdown is defined %} +
+ {{ payload.data.Markdown.raw|render_markdown|safe }} +
+ {% elif payload.data.Json is defined %} +
+ {{ render_json(payload.data.Json) }} +
+ {% elif payload.data.Text is defined %} +
{{ payload.data.Text|unescape }}
+ {% elif payload.data.Pointer is defined %} +
+

Payload stored externally.

+ Load external content +
+ {% elif payload.data.InlineBinary is defined %} +
+[Binary content - {{ payload.size }} bytes]
+ {% else %} +
[Unknown payload type]
+ {% endif %}
- {% elif observation.payload.InlineBinary is defined %} -
-[Binary content - {{ observation.payload_size }} bytes]
- {% else %} -
[Unknown payload type]
- {% endif %} + {% endfor %}
diff --git a/crates/observation-tools-server/templates/execution_detail.html b/crates/observation-tools-server/templates/execution_detail.html index acb92d3..8837779 100644 --- a/crates/observation-tools-server/templates/execution_detail.html +++ b/crates/observation-tools-server/templates/execution_detail.html @@ -95,27 +95,27 @@

observations

>{% if obs.source %}{{ obs.source.file }}:{{ obs.source.line }}{% else %}-{% endif %} {% if obs.observation_type == 'LogEntry' %} - {% if obs.payload.Text is defined %} - {{ obs.payload.Text[:200] }}{% if obs.payload.Text|length > 200 %}...{% endif %} - {% elif obs.payload.Json is defined %} + >{% set p = obs.payloads[0].data if obs.payloads else none %}{% if obs.observation_type == 'LogEntry' %} + {% if p.Text is defined %} + {{ p.Text[:200] }}{% if p.Text|length > 200 %}...{% endif %} + {% elif p.Json is defined %} [JSON] - {% elif obs.payload.Markdown is defined %} - {{ obs.payload.Markdown.raw[:200] }}{% if obs.payload.Markdown.raw|length > 200 %}...{% endif %} - {% elif obs.payload.Pointer is defined %} + {% elif p.Markdown is defined %} + {{ p.Markdown.raw[:200] }}{% if p.Markdown.raw|length > 200 %}...{% endif %} + {% elif p.Pointer is defined %} [external content] {% else %} [binary] {% endif %} {% else %} {{ obs.name }}: - {% if obs.payload.Text is defined %} - {{ obs.payload.Text[:100] }}{% if obs.payload.Text|length > 100 %}...{% endif %} - {% elif obs.payload.Json is defined %} + {% if p.Text is defined %} + {{ p.Text[:100] }}{% if p.Text|length > 100 %}...{% endif %} + {% elif p.Json is defined %} [JSON] - {% elif obs.payload.Markdown is defined %} - {{ obs.payload.Markdown.raw[:100] }}{% if obs.payload.Markdown.raw|length > 100 %}...{% endif %} - {% elif obs.payload.Pointer is defined %} + {% elif p.Markdown is defined %} + {{ p.Markdown.raw[:100] }}{% if p.Markdown.raw|length > 100 %}...{% endif %} + {% elif p.Pointer is defined %} [external content] {% else %} [binary] @@ -137,13 +137,13 @@

observations

>{{ obs.name }} - — {{ obs.mime_type }} + {% if obs.payloads %}— {{ obs.payloads[0].mime_type }}{% endif %} {% if obs.source %}— {{ obs.source.file }}:{{ obs.source.line }}{% endif %} — {{ obs.created_at }} - {% if obs.labels %} + {% if obs.group_ids %}
labels: {{ obs.labels|join(", ") }}groups: {{ obs.group_ids|join(", ") }}
{% endif %}