-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathobservation.rs
More file actions
349 lines (313 loc) · 10.6 KB
/
observation.rs
File metadata and controls
349 lines (313 loc) · 10.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
//! Observation builder API
use crate::client::ObservationUploadResult;
use crate::client::UploaderMessage;
use crate::context;
use crate::execution::ExecutionHandle;
use crate::observation_handle::ObservationHandle;
use crate::observation_handle::SendObservation;
use crate::Error;
use napi_derive::napi;
use observation_tools_shared::GroupId;
use observation_tools_shared::LogLevel;
use observation_tools_shared::Markdown;
use observation_tools_shared::Observation;
use observation_tools_shared::ObservationId;
use observation_tools_shared::ObservationType;
use observation_tools_shared::Payload;
use observation_tools_shared::PayloadId;
use observation_tools_shared::SourceInfo;
use serde::Serialize;
use std::any::TypeId;
use std::collections::HashMap;
use std::fmt::Debug;
/// Builder for creating observations
///
/// Use the `observe!` macro or `ObservationBuilder::new()` to create a builder,
/// then chain methods to configure and send the observation.
///
/// Payload methods (`.serde()`, `.debug()`, `.payload()`) send the observation
/// immediately and return `SendObservation` for optional waiting.
#[derive(Clone)]
#[napi]
pub struct ObservationBuilder {
name: String,
group_ids: Vec<GroupId>,
metadata: HashMap<String, String>,
source: Option<SourceInfo>,
parent_span_id: Option<String>,
observation_type: ObservationType,
log_level: LogLevel,
/// Custom observation ID (for testing)
custom_id: Option<ObservationId>,
/// Explicit execution handle (overrides context)
execution: Option<ExecutionHandle>,
}
impl ObservationBuilder {
/// Create a new observation builder with the given name
pub fn new<T: AsRef<str>>(name: T) -> Self {
Self {
name: name.as_ref().to_string(),
group_ids: Vec::new(),
metadata: HashMap::new(),
source: None,
parent_span_id: None,
observation_type: ObservationType::Payload,
log_level: LogLevel::Info,
custom_id: None,
execution: None,
}
}
/// Set an explicit execution handle for this observation
///
/// When set, this execution is used instead of the current execution context.
/// Useful when sending observations outside of an execution context scope.
pub fn execution(mut self, execution: &ExecutionHandle) -> Self {
self.execution = Some(execution.clone());
self
}
/// Set a custom observation ID (for testing)
///
/// This allows tests to create an observation with a known ID, enabling
/// navigation to the observation URL before the observation is uploaded.
pub fn with_id(mut self, id: ObservationId) -> Self {
self.custom_id = Some(id);
self
}
/// Add metadata to the observation
pub fn metadata(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
self.metadata.insert(key.into(), value.into());
self
}
/// Set the source info for the observation
pub fn source(mut self, file: impl Into<String>, line: u32) -> Self {
self.source = Some(SourceInfo {
file: file.into(),
line,
column: None,
});
self
}
/// Set the parent span ID
pub fn parent_span_id(mut self, span_id: impl Into<String>) -> Self {
self.parent_span_id = Some(span_id.into());
self
}
/// Set the observation type
pub fn observation_type(mut self, observation_type: ObservationType) -> Self {
self.observation_type = observation_type;
self
}
/// Set the log level
pub fn log_level(mut self, log_level: LogLevel) -> Self {
self.log_level = log_level;
self
}
/// Serialize the value as JSON and send the observation
///
/// Returns a `SendObservation` which allows you to wait for the upload
/// or get the observation handle.
pub fn serde<T: ?Sized + Serialize + 'static>(self, value: &T) -> SendObservation {
if TypeId::of::<T>() == TypeId::of::<Payload>() {
panic!("Use payload() method to set Payload directly");
}
let payload = Payload::json(serde_json::to_string(value).unwrap_or_default());
self.send_observation(payload)
}
/// Send the observation with a custom payload
///
/// Returns a `SendObservation` which allows you to wait for the upload
/// or get the observation handle.
pub fn payload<T: Into<Payload>>(self, value: T) -> SendObservation {
self.send_observation(value.into())
}
/// Format the value using Debug and send the observation
///
/// Uses `{:#?}` (pretty-printed Debug) for consistent, parseable output.
/// The payload will have MIME type `text/x-rust-debug` which enables
/// special parsing and rendering on the server.
pub fn debug<T: Debug + ?Sized>(self, value: &T) -> SendObservation {
let payload = Payload::debug(format!("{:#?}", value));
self.send_observation(payload)
}
/// Internal method to build and send the observation
pub(crate) fn send_observation(mut self, payload: Payload) -> SendObservation {
let Some(execution) = self.execution.take().or_else(context::get_current_execution) else {
log::error!(
"No execution context available for observation '{}'",
self.name
);
return SendObservation::stub(Error::NoExecutionContext);
};
self.send_with_execution(payload, &execution)
}
/// Internal method to build and send the observation with a resolved execution.
/// Sends the default payload with name "default".
fn send_with_execution(
self,
payload: Payload,
execution: &ExecutionHandle,
) -> SendObservation {
self.send_with_execution_and_name(payload, "default", execution)
}
/// Internal method to build and send the observation with a named payload.
fn send_with_execution_and_name(
self,
payload: Payload,
payload_name: impl Into<String>,
execution: &ExecutionHandle,
) -> SendObservation {
let observation_id = self.custom_id.unwrap_or_else(ObservationId::new);
let handle = ObservationHandle {
base_url: execution.base_url().to_string(),
execution_id: execution.id(),
observation_id,
};
// Auto-set parent_span_id from current tracing span if not explicitly set
#[cfg(feature = "tracing")]
let parent_span_id = self
.parent_span_id
.or_else(context::get_current_tracing_span_id);
#[cfg(not(feature = "tracing"))]
let parent_span_id = self.parent_span_id;
let observation = Observation {
id: observation_id,
execution_id: execution.id(),
name: self.name,
observation_type: self.observation_type,
log_level: self.log_level,
group_ids: self.group_ids,
parent_group_id: None,
metadata: self.metadata,
source: self.source,
parent_span_id,
created_at: chrono::Utc::now(),
};
let (uploaded_tx, uploaded_rx) = tokio::sync::watch::channel::<ObservationUploadResult>(None);
// Log before sending so any error comes afterward
log::info!(
"Sending: {}/exe/{}/obs/{}",
execution.base_url(),
execution.id(),
observation_id
);
// Send observation metadata
if let Err(e) = execution
.uploader_tx
.try_send(UploaderMessage::Observation {
observation,
handle: handle.clone(),
uploaded_tx,
})
{
log::error!("Failed to send observation: {}", e);
return SendObservation::stub(Error::ChannelClosed);
}
// Send the payload as a separate message
let _ = execution.uploader_tx.try_send(UploaderMessage::Payload {
observation_id,
execution_id: execution.id(),
payload_id: PayloadId::new(),
name: payload_name.into(),
payload,
});
SendObservation::new(handle, uploaded_rx)
}
}
/// Intermediate NAPI type that holds a builder and payload, allowing
/// `.send(exe)` pattern
#[napi]
pub struct ObservationBuilderWithPayloadNapi {
builder: ObservationBuilder,
payload: Payload,
}
#[napi]
impl ObservationBuilderWithPayloadNapi {
/// Send the observation using the provided execution handle
#[napi]
pub fn send(&self, execution: &ExecutionHandle) -> SendObservation {
self
.builder
.clone()
.execution(execution)
.send_observation(self.payload.clone())
}
}
#[napi]
impl ObservationBuilder {
/// Create a new observation builder with the given name
#[napi(constructor)]
pub fn new_napi(name: String) -> Self {
Self::new(name)
}
/// Set a custom observation ID (for testing)
///
/// This allows tests to create an observation with a known ID, enabling
/// navigation to the observation URL before the observation is uploaded.
#[napi(js_name = "withId")]
pub fn with_id_napi(&mut self, id: String) -> napi::Result<&Self> {
let observation_id = ObservationId::parse(&id)
.map_err(|e| napi::Error::from_reason(format!("Invalid observation ID: {}", e)))?;
self.custom_id = Some(observation_id);
Ok(self)
}
/// Add a group to the observation by group ID string
#[napi(js_name = "group")]
pub fn group_napi(&mut self, group_id: String) -> &Self {
self.group_ids.push(GroupId::from(group_id));
self
}
/// Add metadata to the observation
#[napi(js_name = "metadata")]
pub fn metadata_napi(&mut self, key: String, value: String) -> &Self {
self.metadata.insert(key, value);
self
}
/// Set the source info for the observation
#[napi(js_name = "source")]
pub fn source_napi(&mut self, file: String, line: u32) -> &Self {
self.source = Some(SourceInfo {
file,
line,
column: None,
});
self
}
/// Set the payload as JSON data, returning a builder that can be sent with an
/// execution
#[napi(js_name = "jsonPayload")]
pub fn json_payload_napi(
&self,
json_string: String,
) -> napi::Result<ObservationBuilderWithPayloadNapi> {
let value = serde_json::from_str::<serde_json::Value>(&json_string)
.map_err(|e| napi::Error::from_reason(format!("Invalid JSON payload: {}", e)))?;
let payload = Payload::json(serde_json::to_string(&value).unwrap_or_default());
Ok(ObservationBuilderWithPayloadNapi {
builder: self.clone(),
payload,
})
}
/// Set the payload with custom data and MIME type, returning a builder that
/// can be sent
#[napi(js_name = "rawPayload")]
pub fn raw_payload_napi(
&self,
data: String,
mime_type: String,
) -> ObservationBuilderWithPayloadNapi {
let payload = Payload::with_mime_type(data, mime_type);
ObservationBuilderWithPayloadNapi {
builder: self.clone(),
payload,
}
}
/// Set the payload as markdown content, returning a builder that can be sent
#[napi(js_name = "markdownPayload")]
pub fn markdown_payload_napi(&self, content: String) -> ObservationBuilderWithPayloadNapi {
let payload: Payload = Markdown::from(content).into();
ObservationBuilderWithPayloadNapi {
builder: self.clone(),
payload,
}
}
}