Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
48 changes: 48 additions & 0 deletions src/observability/push.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
57 changes: 48 additions & 9 deletions src/observability/template.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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<String, &'static str>,
slug: &str,
Expand Down Expand Up @@ -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![
Expand Down
Loading