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 %}
+
+ {% 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 %}