From bc056b84871817d8bec68579f278ae800990136b Mon Sep 17 00:00:00 2001 From: max-braintrust Date: Thu, 8 Oct 2026 15:21:59 -0700 Subject: [PATCH] fix: support inline code facets in observability templates --- README.md | 4 +++ src/observability/push.rs | 48 +++++++++++++++++++++++++++++ src/observability/template.rs | 57 +++++++++++++++++++++++++++++------ 3 files changed, 100 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 12fb3372..897268ea 100644 --- a/README.md +++ b/README.md @@ -197,6 +197,10 @@ Remove-Item -Recurse -Force (Join-Path $userProfile ".braintrust\auth") -ErrorAc | `bt sync` | Synchronize project logs between Braintrust and local NDJSON files | | `bt update` | Update bt in-place | +## `bt observability template` + +Facets can use extraction prompts or inline code. Observability template pull and push preserve inline code, runtime settings, code hashes, and tags. Bundled code cannot be packaged; use an inline code facet instead. + ## `bt custom-views` Define custom views with `customTraceView` or `customDatasetView` from `braintrust/custom-views` (SDK 3.33.0 or later). diff --git a/src/observability/push.rs b/src/observability/push.rs index 740e44a8..8b076430 100644 --- a/src/observability/push.rs +++ b/src/observability/push.rs @@ -718,6 +718,54 @@ mod tests { } } + #[test] + fn active_observability_inline_code_facet_round_trips() { + use crate::observability::template::from_remote; + + let mut facet = existing_function("test-code-facet", "facet", "code"); + let data = json!({ + "type": "code", + "data": { + "type": "inline", + "runtime_context": {"runtime": "node", "version": "22"}, + "code": "export default (span) => span.metadata.test_value;", + "code_hash": "test-code-hash" + } + }); + facet.function_data = Some(data.clone()); + facet.tags = Some(vec!["test-code-facet".to_string()]); + let mut map = existing_function("test-code-facet-topic-map", "classifier", "topic_map"); + map.function_data = Some( + json!({"type": "topic_map", "source_facet_function": {"type": "function", "id": facet.id}}), + ); + let mut topics = automation("test-topics-id", "Test Topics", "topic"); + topics.config = + add_topics_functions(&topics.config, &[(facet.id.clone(), map.id.clone())]).unwrap(); + let exported = from_remote(&[facet, map], &[topics]).unwrap(); + let source: ActiveObservabilityTemplate = + serde_json::from_str(&serde_json::to_string(&exported).unwrap()).unwrap(); + validate(&source).unwrap(); + let planned = plan( + &source, + Snapshot { + functions: vec![], + automations: vec![], + }, + None, + false, + ) + .unwrap(); + let mutation = &planned.facets[0]; + let request = mutation.template.request( + "test-destination-project-id", + &mutation.template.function_data, + ); + assert_eq!(request["function_type"], "facet"); + assert_eq!(request["function_data"], data); + assert_eq!(request["tags"], json!(["test-code-facet"])); + assert!(planned.topics.contains_key("new:Test Topics")); + } + #[test] fn active_observability_plans_topics_selection_rules() { let only = automation("auto-topics", "Topics", "topic"); diff --git a/src/observability/template.rs b/src/observability/template.rs index c15e6ca7..26430172 100644 --- a/src/observability/template.rs +++ b/src/observability/template.rs @@ -129,12 +129,7 @@ fn facet_from_remote( .function_data .clone() .ok_or_else(|| anyhow!("facet '{}' is missing function_data", facet.name))?; - if function_data.get("type").and_then(Value::as_str) != Some("facet") { - bail!( - "function '{}' has facet type but non-facet function_data", - facet.name - ); - } + validate_facet_data(&function_data, &facet.name)?; let preprocessor = saved_preprocessor(&mut function_data, by_id)?; Ok(FacetTemplate { @@ -267,9 +262,7 @@ pub(crate) fn validate(template: &ActiveObservabilityTemplate) -> Result<()> { for facet in &template.facets { require_text(&facet.name, "facet name")?; require_text(&facet.slug, "facet slug")?; - if facet.function_data.get("type").and_then(Value::as_str) != Some("facet") { - bail!("facet '{}' function_data.type must be 'facet'", facet.name); - } + validate_facet_data(&facet.function_data, &facet.name)?; reserve_slug(&mut slugs, &facet.slug, "facet")?; reserve_slug( &mut slugs, @@ -330,6 +323,18 @@ pub(crate) fn validate(template: &ActiveObservabilityTemplate) -> Result<()> { Ok(()) } +fn validate_facet_data(data: &Value, name: &str) -> Result<()> { + match data.get("type").and_then(Value::as_str) { + Some("facet") => Ok(()), + Some("code") => match data.get("data").and_then(|data| data.get("type")).and_then(Value::as_str) { + Some("inline") => Ok(()), + Some("bundle") => bail!("facet '{name}' uses bundled code, which observability templates cannot package; use an inline code facet"), + _ => bail!("code facet '{name}' must use function_data.data.type 'inline'"), + }, + _ => bail!("facet '{name}' function_data must be type 'facet' or inline 'code'"), + } +} + fn reserve_slug( slugs: &mut HashMap, slug: &str, @@ -692,6 +697,40 @@ mod tests { } } + #[test] + fn active_observability_rejects_nonportable_or_unrelated_facet_data() { + for (data, expected) in [ + ( + json!({"type": "code", "data": {"type": "bundle", "bundle_id": "test-bundle-id"}}), + "cannot package", + ), + ( + json!({"type": "code", "data": {}}), + "must use function_data.data.type 'inline'", + ), + ( + json!({"type": "topic_map"}), + "must be type 'facet' or inline 'code'", + ), + ] { + let facet = function("fn-test-facet", "test-facet", "facet", data.clone()); + assert!(from_remote(&[facet], &[]) + .unwrap_err() + .to_string() + .contains(expected)); + let template: ActiveObservabilityTemplate = serde_json::from_value(json!({ + "kind": KIND, + "schema_version": SCHEMA_VERSION, + "facets": [{"name": "Test facet", "slug": "test-facet", "function_data": data}] + })) + .unwrap(); + assert!(validate(&template) + .unwrap_err() + .to_string() + .contains(expected)); + } + } + #[test] fn active_observability_pull_is_portable_and_maps_topics() { let functions = vec![