Skip to content

Commit f4bdd9f

Browse files
authored
Add named payload API, group builder, and group! macro (#61)
Client API additions for multi-part observations and groups: - ObservationPayloadHandle for adding payloads to existing observations - named_payload(), named_debug(), named_raw_payload() on ObservationBuilder - GroupBuilder, GroupHandle, SendGroup for observation hierarchies - group! macro for creating groups with source info - ExecutionHandle::placeholder() for error paths
1 parent 6fbb925 commit f4bdd9f

File tree

6 files changed

+359
-2
lines changed

6 files changed

+359
-2
lines changed

crates/observation-tools-client/src/execution.rs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,15 @@ impl ExecutionHandle {
8484
pub fn base_url(&self) -> &str {
8585
&self.base_url
8686
}
87+
88+
/// Create a placeholder handle (for stub observations when no execution context exists)
89+
pub(crate) fn placeholder() -> Self {
90+
Self {
91+
execution_id: ExecutionId::nil(),
92+
uploader_tx: async_channel::unbounded().0,
93+
base_url: String::new(),
94+
}
95+
}
8796
}
8897

8998
#[napi]
Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
//! Group builder and handle types for hierarchical observation grouping
2+
3+
use crate::client::UploaderMessage;
4+
use crate::context;
5+
use crate::execution::ExecutionHandle;
6+
use crate::observation::ObservationBuilder;
7+
use crate::observation_handle::SendObservation;
8+
use crate::Error;
9+
use observation_tools_shared::GroupId;
10+
use observation_tools_shared::ObservationId;
11+
use observation_tools_shared::ObservationType;
12+
use observation_tools_shared::Payload;
13+
use std::collections::HashMap;
14+
15+
/// Builder for creating groups
16+
///
17+
/// Groups are first-class hierarchical containers for observations.
18+
/// They are themselves observations with `ObservationType::Group`.
19+
pub struct GroupBuilder {
20+
name: String,
21+
custom_id: Option<GroupId>,
22+
parent_group_id: Option<GroupId>,
23+
metadata: HashMap<String, String>,
24+
}
25+
26+
impl GroupBuilder {
27+
/// Create a new group builder with the given name
28+
pub fn new(name: impl Into<String>) -> Self {
29+
Self {
30+
name: name.into(),
31+
custom_id: None,
32+
parent_group_id: None,
33+
metadata: HashMap::new(),
34+
}
35+
}
36+
37+
/// Set a custom group ID
38+
pub fn id(mut self, id: impl Into<String>) -> Self {
39+
self.custom_id = Some(GroupId::from(id.into()));
40+
self
41+
}
42+
43+
/// Set the parent group ID (for creating child groups)
44+
pub(crate) fn parent(mut self, parent_id: GroupId) -> Self {
45+
self.parent_group_id = Some(parent_id);
46+
self
47+
}
48+
49+
/// Add metadata to the group
50+
pub fn metadata(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
51+
self.metadata.insert(key.into(), value.into());
52+
self
53+
}
54+
55+
/// Build and send the group using the current execution context
56+
pub fn build(self) -> SendGroup {
57+
match context::get_current_execution() {
58+
Some(execution) => self.build_with_execution(&execution),
59+
None => {
60+
log::trace!(
61+
"No execution context available for group '{}'",
62+
self.name
63+
);
64+
SendGroup::stub(Error::NoExecutionContext)
65+
}
66+
}
67+
}
68+
69+
/// Build and send the group with an explicit execution handle
70+
pub fn build_with_execution(self, execution: &ExecutionHandle) -> SendGroup {
71+
let group_id = self.custom_id.unwrap_or_else(GroupId::new);
72+
let observation_id = ObservationId::new();
73+
74+
let group_handle = GroupHandle {
75+
group_id,
76+
execution_id: execution.id(),
77+
uploader_tx: execution.uploader_tx.clone(),
78+
base_url: execution.base_url().to_string(),
79+
};
80+
81+
// Serialize metadata as the payload
82+
let payload = if self.metadata.is_empty() {
83+
Payload::json("{}".to_string())
84+
} else {
85+
Payload::json(serde_json::to_string(&self.metadata).unwrap_or_else(|_| "{}".to_string()))
86+
};
87+
88+
let mut builder = ObservationBuilder::new(self.name)
89+
.with_id(observation_id)
90+
.observation_type(ObservationType::Group)
91+
.execution(execution);
92+
93+
if let Some(parent_id) = self.parent_group_id {
94+
builder = builder.parent_group(parent_id);
95+
}
96+
for (k, v) in self.metadata {
97+
builder = builder.metadata(k, v);
98+
}
99+
100+
let send = builder.send_observation(payload);
101+
SendGroup::from_send_observation(group_handle, send)
102+
}
103+
}
104+
105+
/// Handle to a created group
106+
#[derive(Clone, Debug)]
107+
#[allow(dead_code)]
108+
pub struct GroupHandle {
109+
pub(crate) group_id: GroupId,
110+
pub(crate) execution_id: observation_tools_shared::models::ExecutionId,
111+
pub(crate) uploader_tx: async_channel::Sender<UploaderMessage>,
112+
pub(crate) base_url: String,
113+
}
114+
115+
impl GroupHandle {
116+
/// Get the group ID
117+
pub fn id(&self) -> GroupId {
118+
self.group_id.clone()
119+
}
120+
121+
/// Create a child group builder with this group as parent
122+
pub fn child(&self, name: impl Into<String>) -> GroupBuilder {
123+
GroupBuilder::new(name).parent(self.group_id.clone())
124+
}
125+
126+
/// Construct a GroupHandle from a known ID without creating/sending a group.
127+
///
128+
/// This is useful for the tracing layer which already knows span IDs
129+
/// and doesn't need to create group observations for every span.
130+
pub fn from_id(group_id: GroupId, execution: &ExecutionHandle) -> Self {
131+
Self {
132+
group_id,
133+
execution_id: execution.id(),
134+
uploader_tx: execution.uploader_tx.clone(),
135+
base_url: execution.base_url().to_string(),
136+
}
137+
}
138+
}
139+
140+
/// Result of sending a group, allowing waiting for upload
141+
pub struct SendGroup {
142+
group_handle: GroupHandle,
143+
send: SendObservation,
144+
}
145+
146+
impl SendGroup {
147+
fn stub(error: Error) -> Self {
148+
Self {
149+
group_handle: GroupHandle {
150+
group_id: GroupId::new(),
151+
execution_id: observation_tools_shared::models::ExecutionId::nil(),
152+
uploader_tx: async_channel::unbounded().0,
153+
base_url: String::new(),
154+
},
155+
send: SendObservation::stub(error),
156+
}
157+
}
158+
159+
pub(crate) fn from_send_observation(group_handle: GroupHandle, send: SendObservation) -> Self {
160+
Self { group_handle, send }
161+
}
162+
163+
/// Wait for the group to be uploaded
164+
pub async fn wait_for_upload(mut self) -> crate::error::Result<GroupHandle> {
165+
self.send.wait_for_upload().await?;
166+
Ok(self.group_handle)
167+
}
168+
169+
/// Get a reference to the group handle
170+
pub fn handle(&self) -> &GroupHandle {
171+
&self.group_handle
172+
}
173+
174+
/// Consume and return the group handle
175+
pub fn into_handle(self) -> GroupHandle {
176+
self.group_handle
177+
}
178+
}

crates/observation-tools-client/src/lib.rs

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ mod client;
1010
pub(crate) mod context;
1111
mod error;
1212
mod execution;
13+
mod group;
1314
mod logger;
1415
mod observation;
1516
mod observation_handle;
@@ -26,13 +27,19 @@ pub use error::Error;
2627
pub use error::Result;
2728
pub use execution::BeginExecution;
2829
pub use execution::ExecutionHandle;
30+
pub use group::GroupBuilder;
31+
pub use group::GroupHandle;
32+
pub use group::SendGroup;
2933
pub use logger::ObservationLogger;
3034
pub use observation::ObservationBuilder;
3135
pub use observation_handle::ObservationHandle;
36+
pub use observation_handle::ObservationPayloadHandle;
3237
pub use observation_handle::SendObservation;
33-
// Re-export procedural macro
38+
// Re-export procedural macros
39+
pub use observation_tools_macros::group;
3440
pub use observation_tools_macros::observe;
3541
// Re-export from shared for convenience
42+
pub use observation_tools_shared::GroupId;
3643
pub use observation_tools_shared::Payload;
3744
pub use observation_tools_shared::PayloadBuilder;
3845

@@ -64,3 +71,4 @@ pub fn current_execution() -> Option<ExecutionHandle> {
6471
pub fn clear_global_execution() {
6572
context::clear_global_execution()
6673
}
74+

crates/observation-tools-client/src/observation.rs

Lines changed: 71 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,9 @@ use crate::client::ObservationUploadResult;
44
use crate::client::UploaderMessage;
55
use crate::context;
66
use crate::execution::ExecutionHandle;
7+
use crate::group::GroupHandle;
78
use crate::observation_handle::ObservationHandle;
9+
use crate::observation_handle::ObservationPayloadHandle;
810
use crate::observation_handle::SendObservation;
911
use crate::Error;
1012
use napi_derive::napi;
@@ -43,6 +45,8 @@ pub struct ObservationBuilder {
4345
custom_id: Option<ObservationId>,
4446
/// Explicit execution handle (overrides context)
4547
execution: Option<ExecutionHandle>,
48+
/// Parent group ID (for group observations)
49+
parent_group_id: Option<GroupId>,
4650
}
4751

4852
impl ObservationBuilder {
@@ -58,6 +62,7 @@ impl ObservationBuilder {
5862
log_level: LogLevel::Info,
5963
custom_id: None,
6064
execution: None,
65+
parent_group_id: None,
6166
}
6267
}
6368

@@ -79,6 +84,12 @@ impl ObservationBuilder {
7984
self
8085
}
8186

87+
/// Add a group to the observation
88+
pub fn group(mut self, group: &GroupHandle) -> Self {
89+
self.group_ids.push(group.id());
90+
self
91+
}
92+
8293
/// Add metadata to the observation
8394
pub fn metadata(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
8495
self.metadata.insert(key.into(), value.into());
@@ -113,6 +124,12 @@ impl ObservationBuilder {
113124
self
114125
}
115126

127+
/// Set the parent group ID (for group observations)
128+
pub(crate) fn parent_group(mut self, id: GroupId) -> Self {
129+
self.parent_group_id = Some(id);
130+
self
131+
}
132+
116133
/// Serialize the value as JSON and send the observation
117134
///
118135
/// Returns a `SendObservation` which allows you to wait for the upload
@@ -143,6 +160,59 @@ impl ObservationBuilder {
143160
self.send_observation(payload)
144161
}
145162

163+
/// Send the observation with a named serde-serialized payload, returning a handle
164+
/// that allows adding more named payloads later.
165+
pub fn named_payload<T: ?Sized + Serialize + 'static>(
166+
self,
167+
name: impl Into<String>,
168+
value: &T,
169+
) -> ObservationPayloadHandle {
170+
if TypeId::of::<T>() == TypeId::of::<Payload>() {
171+
panic!("Use named_raw_payload() method to set Payload directly");
172+
}
173+
let payload = Payload::json(serde_json::to_string(value).unwrap_or_default());
174+
self.send_named_observation(name, payload)
175+
}
176+
177+
/// Send the observation with a named Debug-formatted payload
178+
pub fn named_debug<T: Debug + ?Sized>(
179+
self,
180+
name: impl Into<String>,
181+
value: &T,
182+
) -> ObservationPayloadHandle {
183+
let payload = Payload::debug(format!("{:#?}", value));
184+
self.send_named_observation(name, payload)
185+
}
186+
187+
/// Send the observation with a named raw payload
188+
pub fn named_raw_payload(self, name: impl Into<String>, payload: Payload) -> ObservationPayloadHandle {
189+
self.send_named_observation(name, payload)
190+
}
191+
192+
fn send_named_observation(
193+
mut self,
194+
name: impl Into<String>,
195+
payload: Payload,
196+
) -> ObservationPayloadHandle {
197+
let execution = match self.execution.take().or_else(context::get_current_execution) {
198+
Some(exec) => exec,
199+
None => {
200+
let send = SendObservation::stub(Error::NoExecutionContext);
201+
return ObservationPayloadHandle::new(
202+
send.into_handle(),
203+
ExecutionHandle::placeholder(),
204+
);
205+
}
206+
};
207+
208+
// Send the observation metadata + named payload (single path, no duplication)
209+
let send = self.send_with_execution_and_name(payload, name, &execution);
210+
let handle = send.into_handle();
211+
212+
ObservationPayloadHandle::new(handle, execution.clone())
213+
}
214+
215+
146216
/// Internal method to build and send the observation
147217
pub(crate) fn send_observation(mut self, payload: Payload) -> SendObservation {
148218
let Some(execution) = self.execution.take().or_else(context::get_current_execution) else {
@@ -197,7 +267,7 @@ impl ObservationBuilder {
197267
observation_type: self.observation_type,
198268
log_level: self.log_level,
199269
group_ids: self.group_ids,
200-
parent_group_id: None,
270+
parent_group_id: self.parent_group_id,
201271
metadata: self.metadata,
202272
source: self.source,
203273
parent_span_id,

0 commit comments

Comments
 (0)