From b2de3f2dd7d847e763cde6651095aa928af1e8bd Mon Sep 17 00:00:00 2001 From: Doug Roeper Date: Fri, 20 Feb 2026 09:14:26 -0500 Subject: [PATCH] Add shared data model types for groups and multi-part payloads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce foundational types for observation groups and named payloads: - GroupId and PayloadId identifier types - PayloadBuilder for constructing named payloads - Updated Observation struct: labels → group_ids, added parent_group_id, added ObservationType::Group variant - InvalidPayloadId error variant --- .../observation-tools-shared/src/group_id.rs | 42 +++++++++++++++ crates/observation-tools-shared/src/lib.rs | 9 +++- .../src/observation.rs | 17 +++---- .../observation-tools-shared/src/payload.rs | 51 +++++++++++++++++++ .../src/payload_id.rs | 27 ++++++++++ 5 files changed, 135 insertions(+), 11 deletions(-) create mode 100644 crates/observation-tools-shared/src/group_id.rs create mode 100644 crates/observation-tools-shared/src/payload_id.rs diff --git a/crates/observation-tools-shared/src/group_id.rs b/crates/observation-tools-shared/src/group_id.rs new file mode 100644 index 0000000..1770ad4 --- /dev/null +++ b/crates/observation-tools-shared/src/group_id.rs @@ -0,0 +1,42 @@ +use serde::Deserialize; +use serde::Serialize; +use utoipa::ToSchema; + +/// Unique identifier for a group +/// +/// Group IDs are user-provided strings. By default, a UUID v7 string is generated, +/// but any string value is accepted. +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, ToSchema)] +#[serde(transparent)] +#[schema(value_type = String, example = "018e9a3a2c1b7e3f8d2a4b5c6d7e8f9b")] +pub struct GroupId(String); + +impl GroupId { + /// Generate a new UUIDv7 group ID + pub fn new() -> Self { + Self(uuid::Uuid::now_v7().as_simple().to_string()) + } + + /// Get the string value of this group ID + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl Default for GroupId { + fn default() -> Self { + Self::new() + } +} + +impl From for GroupId { + fn from(s: String) -> Self { + Self(s) + } +} + +impl From<&str> for GroupId { + fn from(s: &str) -> Self { + Self(s.to_string()) + } +} diff --git a/crates/observation-tools-shared/src/lib.rs b/crates/observation-tools-shared/src/lib.rs index 2340f02..5cdb16b 100644 --- a/crates/observation-tools-shared/src/lib.rs +++ b/crates/observation-tools-shared/src/lib.rs @@ -2,11 +2,14 @@ pub mod error; pub mod models; +mod group_id; mod observation; mod payload; +mod payload_id; pub use error::Error; pub use error::Result; +pub use group_id::GroupId; pub use models::Execution; pub use models::ExecutionId; pub use observation::LogLevel; @@ -16,7 +19,9 @@ pub use observation::ObservationType; pub use observation::SourceInfo; pub use payload::Markdown; pub use payload::Payload; +pub use payload::PayloadBuilder; pub use payload::MIME_TYPE_RUST_DEBUG; +pub use payload_id::PayloadId; /// Payload size threshold for blob storage (64KB) /// Payloads larger than this will be uploaded as separate blobs @@ -26,10 +31,10 @@ pub const BLOB_THRESHOLD_BYTES: usize = 65536; pub const BATCH_SIZE: usize = 100; /// Estimated maximum observation metadata overhead (in bytes) -/// This includes JSON structure, field names, IDs, timestamps, labels, etc. +/// This includes JSON structure, field names, IDs, timestamps, group_ids, etc. /// Assumes reasonable limits: /// - name: ~256 bytes -/// - labels: ~10 labels * ~100 bytes = 1KB +/// - group_ids: ~10 groups * ~40 bytes = 400 bytes /// - metadata: ~10 entries * ~200 bytes = 2KB /// - source info: ~256 bytes /// - IDs, timestamps, JSON structure: ~512 bytes diff --git a/crates/observation-tools-shared/src/observation.rs b/crates/observation-tools-shared/src/observation.rs index eb9fa9a..53101ce 100644 --- a/crates/observation-tools-shared/src/observation.rs +++ b/crates/observation-tools-shared/src/observation.rs @@ -1,3 +1,4 @@ +use crate::group_id::GroupId; use crate::ExecutionId; use chrono::DateTime; use chrono::Utc; @@ -78,10 +79,13 @@ pub struct Observation { #[serde(default)] pub metadata: HashMap, - /// Hierarchical labels for grouping observations - /// Uses path convention (e.g., "api/request/headers") + /// IDs of groups this observation belongs to #[serde(default)] - pub labels: Vec, + pub group_ids: Vec, + + /// Parent group ID (used when observation_type == Group) + #[serde(skip_serializing_if = "Option::is_none")] + pub parent_group_id: Option, /// Parent span ID (for tracing integration) #[serde(skip_serializing_if = "Option::is_none")] @@ -89,12 +93,6 @@ pub struct Observation { /// When this observation was created pub created_at: DateTime, - - /// MIME type of the payload (e.g., "text/plain", "application/json") - pub mime_type: String, - - /// Size of the payload in bytes - pub payload_size: usize, } /// Type of observation @@ -103,6 +101,7 @@ pub enum ObservationType { LogEntry, Payload, Span, + Group, } /// Log level for observations diff --git a/crates/observation-tools-shared/src/payload.rs b/crates/observation-tools-shared/src/payload.rs index e8b9d68..8306eb0 100644 --- a/crates/observation-tools-shared/src/payload.rs +++ b/crates/observation-tools-shared/src/payload.rs @@ -112,3 +112,54 @@ impl From for Payload { Payload::with_mime_type(md.content, "text/markdown") } } + +/// Builder for creating named payloads to attach to observations. +/// +/// Each `PayloadBuilder` pairs a name with a `Payload`, allowing observations +/// to carry multiple named payloads (e.g., "headers", "body", "response"). +pub struct PayloadBuilder { + pub name: String, + pub payload: Payload, +} + +impl PayloadBuilder { + /// Create a new named payload + pub fn new(name: impl Into, payload: impl Into) -> Self { + Self { + name: name.into(), + payload: payload.into(), + } + } + + /// Create a named payload from a serde-serializable value (JSON) + pub fn json(name: impl Into, value: &T) -> Self { + Self { + name: name.into(), + payload: Payload::json(serde_json::to_string(value).unwrap_or_default()), + } + } + + /// Create a named payload from a Debug-formatted value + pub fn debug(name: impl Into, value: &T) -> Self { + Self { + name: name.into(), + payload: Payload::debug(format!("{:#?}", value)), + } + } + + /// Create a named plain text payload + pub fn text(name: impl Into, data: impl Into) -> Self { + Self { + name: name.into(), + payload: Payload::text(data), + } + } + + /// Create a named markdown payload + pub fn markdown(name: impl Into, content: impl Into) -> Self { + Self { + name: name.into(), + payload: Payload::with_mime_type(content, "text/markdown"), + } + } +} diff --git a/crates/observation-tools-shared/src/payload_id.rs b/crates/observation-tools-shared/src/payload_id.rs new file mode 100644 index 0000000..9ceeea5 --- /dev/null +++ b/crates/observation-tools-shared/src/payload_id.rs @@ -0,0 +1,27 @@ +use serde::Deserialize; +use serde::Serialize; +use utoipa::ToSchema; + +/// Unique identifier for a payload (UUIDv7) +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, ToSchema)] +#[serde(transparent)] +#[schema(value_type = String, example = "018e9a3a2c1b7e3f8d2a4b5c6d7e8f9c")] +pub struct PayloadId(String); + +impl PayloadId { + /// Generate a new UUIDv7 payload ID + pub fn new() -> Self { + Self(uuid::Uuid::now_v7().as_simple().to_string()) + } + + /// Get the string value of this payload ID + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl Default for PayloadId { + fn default() -> Self { + Self::new() + } +}