diff --git a/README.md b/README.md index 5ed747aa..f67d871d 100644 --- a/README.md +++ b/README.md @@ -214,6 +214,19 @@ Remove-Item -Recurse -Force (Join-Path $userProfile ".braintrust\auth") -ErrorAc ## `bt observability template` +Copy custom facets, their Topics automation settings, and Loop automations between projects: + +```bash +bt observability template pull --output observability-template.json --project test-source-project +bt observability template push observability-template.json --project test-destination-project +``` + +Schema v2 adds `topics_automations`, with each referenced automation's name, description, and API-returned config. Scope, automation-level BTQL filters, sampling, model, status, and timing travel with the facet. Project-specific `facet_functions` and `topic_map_functions` wiring is rebuilt in the destination. Version 1 templates remain readable and use destination/default Topics settings. + +New automations use the bundled settings. Existing automations with matching settings and descriptions retain them. Differences require `--force`, which replaces settings in full while preserving destination facet/map wiring; destination-only settings are dropped. `--topics-automation ` uses that existing automation's settings instead of the template's. Existing resource replacement rules still apply to facet and preprocessor definitions. + +Settings scoped to a specific experiment cannot be exported because its ID is project-specific. Timing validation and normalization remain with the server; settings returned by the API may differ from the submitted values. Topic map definitions and generated state are not exported. Topics update when the pipeline runs again. + Push with `--force` replaces an imported facet's topic map binding in the selected automation. It reuses the map with the generated template slug, or creates that map if needed, and detaches superseded maps from that automation. Other automations keep their facet and map bindings. Detached map functions remain in the project and are listed in human and JSON push output. Existing generated state updates when the Topics pipeline runs again. ## `bt custom-views` diff --git a/src/eval.rs b/src/eval.rs index 91eae7c3..5b5770fc 100644 --- a/src/eval.rs +++ b/src/eval.rs @@ -2734,11 +2734,12 @@ fn build_js_plan( } fn build_vite_node_fallback_command(runner: &Path, files: &[String]) -> Result { + let runner = js_runner_path_for_kind(runner, RunnerKind::ViteNode); if let Some(path) = find_node_module_bin_for_files("vite-node", files) .or_else(|| js_runner::find_binary_in_path(&["vite-node"])) { let mut command = Command::new(path); - command.arg(runner).args(files); + command.arg(&runner).args(files); return Ok(command); } diff --git a/src/observability/mod.rs b/src/observability/mod.rs index b5a9a914..53cebeef 100644 --- a/src/observability/mod.rs +++ b/src/observability/mod.rs @@ -28,7 +28,7 @@ pub(crate) struct ObservabilityArgs { #[derive(Debug, Clone, Subcommand)] enum ObservabilityCommand { - /// Pull and push facets and Loop automations as a portable template + /// Pull and push facets, Topics settings, and Loop automations as a portable template Template(TemplateArgs), } @@ -47,9 +47,9 @@ struct TemplateArgs { #[derive(Debug, Clone, Subcommand)] enum TemplateCommand { - /// Pull facets and Loop automations into a portable template + /// Pull facets, Topics settings, and Loop automations into a portable template Pull(PullArgs), - /// Push facets and Loop automations from a portable template + /// Push facets, Topics settings, and Loop automations from a portable template Push(PushArgs), } diff --git a/src/observability/pull.rs b/src/observability/pull.rs index 9e7c7e0a..1d3366d9 100644 --- a/src/observability/pull.rs +++ b/src/observability/pull.rs @@ -15,8 +15,8 @@ use crate::{ use super::{ template::{ - deduplicate_preprocessors, from_remote, ActiveObservabilityTemplate, AutomationTemplate, - FacetTemplate, + deduplicate_preprocessors, from_remote, retain_topics_settings, validate, + validate_topics_scope, ActiveObservabilityTemplate, AutomationTemplate, FacetTemplate, }, PullArgs, }; @@ -41,6 +41,9 @@ pub(crate) async fn run(base: BaseArgs, args: PullArgs) -> Result<()> { (template.facets, template.automations) = filter_active_resources(template.facets, template.automations); } + retain_topics_settings(&mut template, &functions, &automations)?; + validate(&template)?; + validate_topics_scope(&template)?; // Selection happens first so a selected facet never loses its required definition. deduplicate_preprocessors(&mut template.facets); diff --git a/src/observability/push.rs b/src/observability/push.rs index a479bf6e..8e4a6778 100644 --- a/src/observability/push.rs +++ b/src/observability/push.rs @@ -17,8 +17,9 @@ use super::template::{ add_topics_functions, default_topics_config, embedding_model, is_loop_config, is_topics, loop_config_for_target, new_topic_map_request, reconciled_topic_map_request, remove_topics_functions, saved_preprocessor_slug, topic_map_ids, topic_map_matches, - topic_map_slug, with_preprocessor_id, ActiveObservabilityTemplate, AutomationTemplate, - FacetTemplate, PortableFunction, DEFAULT_TOPICS_DESCRIPTION, + topic_map_slug, topics_config_for_target, topics_settings, validate_topics_scope, + with_preprocessor_id, ActiveObservabilityTemplate, AutomationTemplate, FacetTemplate, + PortableFunction, DEFAULT_TOPICS_DESCRIPTION, }; #[derive(Debug)] @@ -68,6 +69,7 @@ struct TopicsMutation { target: TopicsTarget, embedding_model: String, config: Value, + description: Option, } #[derive(Debug, Serialize)] @@ -92,6 +94,9 @@ pub(crate) fn plan( topics_override: Option<&str>, force: bool, ) -> Result { + if topics_override.is_none() { + validate_topics_scope(template)?; + } let functions_by_slug = functions_by_slug(&snapshot.functions); let functions_by_id = snapshot .functions @@ -203,10 +208,15 @@ pub(crate) fn plan( TopicsTarget::Existing(automation) => add_topics_functions(&automation.config, &[])?, TopicsTarget::New(_) => default_topics_config(), }; + let description = match &target { + TopicsTarget::Existing(automation) => automation.description.clone(), + TopicsTarget::New(_) => Some(DEFAULT_TOPICS_DESCRIPTION.to_string()), + }; topics.entry(key.clone()).or_insert(TopicsMutation { target, embedding_model: model, config, + description, }); for map in &duplicate_topic_maps { detached_topic_maps @@ -226,6 +236,32 @@ pub(crate) fn plan( }); } + if topics_override.is_none() { + for mutation in topics.values_mut() { + let name = match &mutation.target { + TopicsTarget::Existing(automation) => &automation.name, + TopicsTarget::New(name) => name, + }; + let Some(settings) = template + .topics_automations + .iter() + .find(|settings| &settings.name == name) + else { + continue; + }; + if let TopicsTarget::Existing(existing) = &mutation.target { + if !force + && (topics_settings(&existing.config)? != settings.config + || existing.description != settings.description) + { + bail!("Topics automation '{name}' has different settings; use --force to replace them or --topics-automation to use the destination settings"); + } + } + mutation.config = topics_config_for_target(&settings.config, &mutation.config)?; + mutation.description = settings.description.clone(); + } + } + let loops = template .automations .iter() @@ -556,7 +592,7 @@ pub(crate) async fn execute( TopicsTarget::Existing(automation) => { let body = json!({ "name": automation.name, - "description": automation.description, + "description": mutation.description, "config": config, }); patch_project_automation(client, &automation.id, &body) @@ -569,7 +605,7 @@ pub(crate) async fn execute( let body = json!({ "project_id": project_id, "name": name, - "description": DEFAULT_TOPICS_DESCRIPTION, + "description": mutation.description, "config": config, }); let created = create_project_automation(client, &body) @@ -642,9 +678,7 @@ async fn upsert_function(client: &ApiClient, request: &Value, replace: bool) -> #[cfg(test)] mod tests { use super::*; - use crate::observability::template::{ - deduplicate_preprocessors, validate, KIND, SCHEMA_VERSION, - }; + use crate::observability::template::{deduplicate_preprocessors, validate, KIND}; fn facet(topics: Option<&str>) -> FacetTemplate { FacetTemplate { @@ -663,8 +697,9 @@ mod tests { fn template(facet: FacetTemplate) -> ActiveObservabilityTemplate { ActiveObservabilityTemplate { kind: KIND.to_string(), - schema_version: SCHEMA_VERSION, + schema_version: 1, facets: vec![facet], + topics_automations: Vec::new(), automations: Vec::new(), } } @@ -811,8 +846,9 @@ mod tests { deduplicate_preprocessors(&mut facets); let source = ActiveObservabilityTemplate { kind: KIND.to_string(), - schema_version: SCHEMA_VERSION, + schema_version: 1, facets, + topics_automations: Vec::new(), automations: Vec::new(), }; validate(&source).expect("valid shared preprocessor template"); @@ -973,8 +1009,9 @@ mod tests { let wrong_loop = plan( &ActiveObservabilityTemplate { kind: KIND.to_string(), - schema_version: SCHEMA_VERSION, + schema_version: 1, facets: Vec::new(), + topics_automations: Vec::new(), automations: vec![AutomationTemplate { name: "Test automation".to_string(), description: None, @@ -991,4 +1028,261 @@ mod tests { .expect_err("wrong automation type"); assert!(wrong_loop.to_string().contains("not a Loop")); } + + fn scoped_template() -> ActiveObservabilityTemplate { + let mut source = template(facet(Some("Test scoped Topics"))); + source.schema_version = 2; + source.topics_automations = vec![AutomationTemplate { + name: "Test scoped Topics".to_string(), + description: Some("Test span policy".to_string()), + config: json!({"event_type":"topic", "scope":{"type":"span"}, + "data_scope":{"type":"project_logs"}, "btql_filter":"metadata.test_enabled = true", + "sampling_rate":1, "model":"test-model", "status":"active", "rerun_seconds":86400, + "backfill_time_range":"604800s"}), + }]; + source + } + + #[test] + fn active_observability_v2_pull_push_round_trip_creates_bundled_settings() { + use crate::observability::template::from_remote; + let expected = scoped_template(); + let settings = &expected.topics_automations[0]; + let source_facet = existing_function("test-facet", "facet", "facet"); + let mut source_map = existing_function("test-facet-topic-map", "classifier", "topic_map"); + source_map.function_data = Some( + json!({"type":"topic_map", "source_facet_function":{"type":"function", "id":source_facet.id}}), + ); + let mut remote = automation("test-source-automation", &settings.name, "topic"); + remote.description = settings.description.clone(); + remote.config = add_topics_functions( + &topics_config_for_target(&settings.config, &json!({})).unwrap(), + &[(source_facet.id.clone(), source_map.id.clone())], + ) + .unwrap(); + let pulled = from_remote(&[source_facet, source_map], &[remote]).unwrap(); + let source = serde_json::from_str(&serde_json::to_string(&pulled).unwrap()).unwrap(); + validate(&source).unwrap(); + assert_eq!(source.topics_automations, expected.topics_automations); + let planned = plan( + &source, + Snapshot { + functions: vec![], + automations: vec![], + }, + None, + false, + ) + .unwrap(); + let mutation = &planned.topics["new:Test scoped Topics"]; + assert_eq!(topics_settings(&mutation.config).unwrap(), settings.config); + assert_eq!(mutation.description, settings.description); + assert_eq!(mutation.config["facet_functions"], json!([])); + assert_eq!(mutation.config["topic_map_functions"], json!([])); + } + + #[test] + fn active_observability_v2_matching_settings_do_not_require_force() { + let source = scoped_template(); + let settings = &source.topics_automations[0]; + let mut existing = automation("test-existing-automation", &settings.name, "topic"); + existing.config = topics_config_for_target(&settings.config, &json!({})).unwrap(); + existing.description = settings.description.clone(); + let planned = plan( + &source, + Snapshot { + functions: vec![], + automations: vec![existing.clone()], + }, + None, + false, + ) + .unwrap(); + assert_eq!( + planned.topics["id:test-existing-automation"].config, + existing.config + ); + assert_eq!( + planned.topics["id:test-existing-automation"].description, + existing.description + ); + } + + #[test] + fn active_observability_v2_differing_settings_require_force_and_keep_wiring() { + let mut source = scoped_template(); + source.topics_automations[0].description = None; + let mut existing = automation("test-existing-automation", "Test scoped Topics", "topic"); + existing.description = Some("Test destination description".to_string()); + existing.config = add_topics_functions( + &default_topics_config(), + &[( + "fn-test-other-facet".to_string(), + "fn-test-other-map".to_string(), + )], + ) + .unwrap(); + existing.config["test_destination_only"] = json!(true); + let snapshot = || Snapshot { + functions: vec![], + automations: vec![existing.clone()], + }; + assert!(plan(&source, snapshot(), None, false) + .unwrap_err() + .to_string() + .contains("--force")); + let planned = plan(&source, snapshot(), None, true).unwrap(); + let mutation = &planned.topics["id:test-existing-automation"]; + assert_eq!( + topics_settings(&mutation.config).unwrap(), + source.topics_automations[0].config + ); + assert_eq!( + mutation.config["facet_functions"], + existing.config["facet_functions"] + ); + assert_eq!( + mutation.config["topic_map_functions"], + existing.config["topic_map_functions"] + ); + assert!(mutation.description.is_none()); + } + + #[test] + fn active_observability_v2_override_uses_destination_settings() { + let mut source = scoped_template(); + source.topics_automations[0].config["data_scope"] = + json!({"type":"experiment", "experiment_id":"test-experiment-id"}); + source.topics_automations[0].config["backfill_time_range"] = json!("server-owned-value"); + let existing = automation( + "test-destination-automation", + "Test destination Topics", + "topic", + ); + let planned = plan( + &source, + Snapshot { + functions: vec![], + automations: vec![existing.clone()], + }, + Some("test-destination-automation"), + false, + ) + .unwrap(); + assert_eq!(planned.topics.len(), 1); + let mutation = &planned.topics["id:test-destination-automation"]; + assert_eq!(mutation.config, existing.config); + assert_eq!(mutation.description, existing.description); + } + + #[tokio::test] + async fn active_observability_v2_execute_sends_settings_description_and_wiring() { + use actix_web::{web, App, HttpRequest, HttpResponse, HttpServer}; + use std::{ + net::TcpListener, + sync::{Arc, Mutex}, + }; + + type RecordedRequests = Arc>>; + let requests: RecordedRequests = Arc::new(Mutex::new(Vec::new())); + let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap(); + let api_url = format!("http://{}", listener.local_addr().unwrap()); + let state = web::Data::new(requests.clone()); + let server = HttpServer::new(move || { + App::new().app_data(state.clone()).default_service(web::to( + |request: HttpRequest, + body: web::Json, + state: web::Data| async move { + let mut response = body.into_inner(); + state.lock().unwrap().push(( + request.method().to_string(), + request.path().to_string(), + response.clone(), + )); + match (request.method().as_str(), request.path()) { + ("PUT", "/v1/function") => { + response["id"] = if response["function_type"] == "facet" { + json!("fn-test-facet") + } else { + json!("fn-test-map") + }; + } + ("PATCH", "/v1/project_automation/test-existing-automation") => { + response["id"] = json!("test-existing-automation"); + response["project_id"] = json!("test-project-id"); + } + _ => return HttpResponse::NotFound().finish(), + } + HttpResponse::Ok().json(response) + }, + )) + }) + .workers(1) + .listen(listener) + .unwrap() + .run(); + let handle = server.handle(); + tokio::spawn(server); + let login = braintrust_sdk_rust::LoginState::new(); + login.set( + "test-key".to_string(), + "test-org-id".to_string(), + "test-org".to_string(), + api_url.clone(), + "https://example.invalid".to_string(), + ); + let client = ApiClient::new(&crate::auth::LoginContext { + login, + api_url, + app_url: "https://example.invalid".to_string(), + profile: None, + }) + .unwrap(); + + let mut source = scoped_template(); + source.topics_automations[0].description = None; + let mut facet = existing_function("test-facet", "facet", "facet"); + facet.id = "fn-test-facet".to_string(); + let mut map = existing_function("test-facet-topic-map", "classifier", "topic_map"); + map.id = "fn-test-map".to_string(); + map.function_data = Some( + json!({"type": "topic_map", "source_facet_function": {"type": "function", "id": facet.id}}), + ); + let mut automation = automation("test-existing-automation", "Test scoped Topics", "topic"); + automation.description = Some("Test old description".to_string()); + automation.config = + add_topics_functions(&automation.config, &[(facet.id.clone(), map.id.clone())]) + .unwrap(); + let planned = plan( + &source, + Snapshot { + functions: vec![facet, map], + automations: vec![automation], + }, + None, + true, + ) + .unwrap(); + let result = execute(&client, "test-project-id", planned).await; + handle.stop(true).await; + assert_eq!(result.unwrap().facets[0].id, "fn-test-facet"); + let requests = requests.lock().unwrap(); + assert_eq!(requests.len(), 3); + assert_eq!(requests[0].0, "PUT"); + assert_eq!(requests[1].0, "PUT"); + assert_eq!(requests[2].0, "PATCH"); + let body = &requests[2].2; + assert_eq!(body["name"], "Test scoped Topics"); + assert!(body["description"].is_null()); + assert_eq!(body["config"]["scope"], json!({"type": "span"})); + assert_eq!( + body["config"]["btql_filter"], + "metadata.test_enabled = true" + ); + assert_eq!(body["config"]["facet_functions"][0]["id"], "fn-test-facet"); + assert_eq!( + body["config"]["topic_map_functions"][0]["function"]["id"], + "fn-test-map" + ); + } } diff --git a/src/observability/template.rs b/src/observability/template.rs index c15e6ca7..dc7ab1d6 100644 --- a/src/observability/template.rs +++ b/src/observability/template.rs @@ -7,10 +7,11 @@ use serde_json::{json, Map, Value}; use crate::{functions::api::Function, topics::api::ProjectAutomation}; pub(crate) const KIND: &str = "active_observability_template"; -pub(crate) const SCHEMA_VERSION: u32 = 1; +pub(crate) const SCHEMA_VERSION: u32 = 2; pub(crate) const DEFAULT_EMBEDDING_MODEL: &str = "brain-embedding-1"; pub(crate) const DEFAULT_TOPICS_DESCRIPTION: &str = "Automatically extract facets and classify logs using topic maps"; +const WIRING_KEYS: [&str; 2] = ["facet_functions", "topic_map_functions"]; #[derive(Debug, Clone, Deserialize, Serialize, PartialEq)] pub(crate) struct ActiveObservabilityTemplate { @@ -18,6 +19,8 @@ pub(crate) struct ActiveObservabilityTemplate { pub schema_version: u32, #[serde(default)] pub facets: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub topics_automations: Vec, #[serde(default)] pub automations: Vec, } @@ -93,10 +96,20 @@ pub(crate) fn from_remote( let mut facet_templates = facets .into_iter() - .map(|facet| facet_from_remote(facet, topics.get(&facet.id).cloned(), &by_id)) + .map(|facet| { + facet_from_remote( + facet, + topics + .get(&facet.id) + .map(|automation| automation.name.clone()), + &by_id, + ) + }) .collect::>>()?; facet_templates.sort_by(|a, b| a.name.cmp(&b.name).then(a.slug.cmp(&b.slug))); + let topics_templates = bundled_topics_settings(topics.into_values())?; + let mut loop_templates = automations .iter() .filter(|automation| is_loop_config(&automation.config)) @@ -116,6 +129,7 @@ pub(crate) fn from_remote( kind: KIND.to_string(), schema_version: SCHEMA_VERSION, facets: facet_templates, + topics_automations: topics_templates, automations: loop_templates, }) } @@ -179,12 +193,12 @@ fn saved_preprocessor( Ok(Some(portable)) } -fn topics_by_facet( +fn topics_by_facet<'a>( facets: &[&Function], by_id: &HashMap<&str, &Function>, - automations: &[ProjectAutomation], -) -> Result> { - let mut names: HashMap> = HashMap::new(); + automations: &'a [ProjectAutomation], +) -> Result> { + let mut bindings: HashMap> = HashMap::new(); for automation in automations .iter() .filter(|automation| is_topics(&automation.config)) @@ -208,30 +222,51 @@ fn topics_by_facet( .copied() .filter(|facet| topic_map_matches(topic_map, facet)) { - names + bindings .entry(facet.id.clone()) .or_default() - .insert(automation.name.clone()); + .insert(automation.id.as_str(), automation); } } } - names + bindings .into_iter() - .map(|(facet_id, names)| { - if names.len() != 1 { - let mut names = names.into_iter().collect::>(); + .map(|(facet_id, automations)| { + if automations.len() != 1 { + let mut names = automations + .values() + .map(|automation| format!("{} ({})", automation.name, automation.id)) + .collect::>(); names.sort(); bail!( "facet '{facet_id}' belongs to multiple Topics automations ({}); use one Topics destination per facet", names.join(", ") ); } - Ok((facet_id, names.into_iter().next().expect("one name"))) + Ok((facet_id, automations.into_values().next().expect("one automation"))) }) .collect() } +fn bundled_topics_settings<'a>( + automations: impl Iterator, +) -> Result> { + let mut ids = HashSet::new(); + let mut settings = automations + .filter(|automation| ids.insert(automation.id.as_str())) + .map(|automation| { + Ok(AutomationTemplate { + name: automation.name.clone(), + description: automation.description.clone(), + config: topics_settings(&automation.config)?, + }) + }) + .collect::>>()?; + settings.sort_by(|a, b| a.name.cmp(&b.name)); + Ok(settings) +} + pub(crate) fn topic_map_matches(topic_map: &Function, facet: &Function) -> bool { let Some(data) = topic_map.function_data.as_ref() else { return false; @@ -255,9 +290,9 @@ pub(crate) fn validate(template: &ActiveObservabilityTemplate) -> Result<()> { if template.kind != KIND { bail!("template kind must be '{KIND}'"); } - if template.schema_version != SCHEMA_VERSION { + if !matches!(template.schema_version, 1 | SCHEMA_VERSION) { bail!( - "unsupported template schema version {}; supported version is {SCHEMA_VERSION}", + "unsupported template schema version {}; supported versions are 1 and {SCHEMA_VERSION}", template.schema_version ); } @@ -311,7 +346,40 @@ pub(crate) fn validate(template: &ActiveObservabilityTemplate) -> Result<()> { } } + if template.schema_version == 1 && !template.topics_automations.is_empty() { + bail!("bundled Topics settings require template schema version 2"); + } let mut automation_names = HashSet::new(); + for automation in &template.topics_automations { + require_text(&automation.name, "Topics automation name")?; + if !automation_names.insert(automation.name.as_str()) { + bail!( + "template contains duplicate automation name '{}'", + automation.name + ); + } + if !is_topics(&automation.config) { + bail!( + "automation '{}' is not a Topics automation (expected event_type 'topic')", + automation.name + ); + } + if WIRING_KEYS + .iter() + .any(|key| automation.config.get(key).is_some()) + { + bail!("Topics automation '{}' settings must not contain facet_functions or topic_map_functions", automation.name); + } + } + if template.schema_version == SCHEMA_VERSION { + for facet in &template.facets { + if let Some(name) = facet.topics_automation.as_deref() { + if !automation_names.contains(name) { + bail!("facet '{}' references Topics automation '{name}' without bundled settings in topics_automations", facet.name); + } + } + } + } for automation in &template.automations { require_text(&automation.name, "automation name")?; if !automation_names.insert(automation.name.as_str()) { @@ -330,6 +398,71 @@ pub(crate) fn validate(template: &ActiveObservabilityTemplate) -> Result<()> { Ok(()) } +pub(crate) fn validate_topics_scope(template: &ActiveObservabilityTemplate) -> Result<()> { + for automation in &template.topics_automations { + if automation + .config + .get("data_scope") + .and_then(|scope| scope.get("experiment_id")) + .is_some_and(|id| !id.is_null()) + { + let facet = template + .facets + .iter() + .find(|facet| facet.topics_automation.as_deref() == Some(automation.name.as_str())); + if let Some(facet) = facet { + bail!("facet '{}' uses Topics automation '{}' scoped to a specific experiment; its settings cannot be copied to another project", facet.name, automation.name); + } + } + } + Ok(()) +} + +pub(crate) fn retain_topics_settings( + template: &mut ActiveObservabilityTemplate, + functions: &[Function], + automations: &[ProjectAutomation], +) -> Result<()> { + let slugs = template + .facets + .iter() + .map(|facet| facet.slug.as_str()) + .collect::>(); + let facets = functions + .iter() + .filter(|function| { + function.function_type.as_deref() == Some("facet") + && slugs.contains(function.slug.as_str()) + }) + .collect::>(); + let by_id = functions + .iter() + .map(|function| (function.id.as_str(), function)) + .collect::>(); + let topics = topics_by_facet(&facets, &by_id, automations)?; + template.topics_automations = bundled_topics_settings(topics.into_values())?; + Ok(()) +} + +pub(crate) fn topics_settings(config: &Value) -> Result { + let mut settings = object(config, "Topics automation config")?.clone(); + for key in WIRING_KEYS { + settings.remove(key); + } + Ok(Value::Object(settings)) +} + +pub(crate) fn topics_config_for_target(settings: &Value, destination: &Value) -> Result { + let mut config = object(settings, "Topics automation settings")?.clone(); + for key in WIRING_KEYS { + config.insert( + key.to_string(), + destination.get(key).cloned().unwrap_or_else(|| json!([])), + ); + } + Ok(Value::Object(config)) +} + fn reserve_slug( slugs: &mut HashMap, slug: &str, @@ -818,4 +951,139 @@ mod tests { assert_eq!(twice["facet_functions"].as_array().unwrap().len(), 1); assert_eq!(twice["topic_map_functions"].as_array().unwrap().len(), 1); } + + #[test] + fn active_observability_pull_uses_bound_automation_id() { + let facet = function( + "fn-test-facet", + "test-facet", + "facet", + json!({"type": "facet"}), + ); + let map = function( + "fn-test-map", + "test-map", + "classifier", + json!({"type": "topic_map", "source_facet_function": {"type": "function", "id": facet.id}}), + ); + let mut bound = automation( + "Test Topics", + json!({ + "event_type": "topic", "scope": {"type": "span"}, + "topic_map_functions": [ + {"function": {"type": "function", "id": map.id}}, + {"function": {"type": "function", "id": map.id}} + ] + }), + ); + bound.id = "auto-test-bound".to_string(); + bound.description = Some("Test bound settings".to_string()); + let mut unrelated = automation( + "Test Topics", + json!({"event_type": "topic", "scope": {"type": "trace"}}), + ); + unrelated.id = "auto-test-unrelated".to_string(); + + for automations in [ + vec![bound.clone(), unrelated.clone()], + vec![unrelated.clone(), bound.clone()], + ] { + let mut template = from_remote(&[facet.clone(), map.clone()], &automations).unwrap(); + retain_topics_settings(&mut template, &[facet.clone(), map.clone()], &automations) + .unwrap(); + validate(&template).expect("an unrelated same-name automation must not block pull"); + assert_eq!(template.topics_automations.len(), 1); + assert_eq!(template.topics_automations[0].name, bound.name); + assert_eq!( + template.topics_automations[0].description, + bound.description + ); + assert_eq!( + template.topics_automations[0].config, + topics_settings(&bound.config).unwrap() + ); + assert_eq!( + template.facets[0].topics_automation.as_deref(), + Some("Test Topics") + ); + } + + let mut other_facet = facet.clone(); + other_facet.id = "fn-test-other-facet".to_string(); + other_facet.slug = "test-other-facet".to_string(); + let mut other_map = map.clone(); + other_map.id = "fn-test-other-map".to_string(); + other_map.function_data = Some( + json!({"type": "topic_map", "source_facet_function": {"type": "function", "id": other_facet.id}}), + ); + unrelated.config["topic_map_functions"] = + json!([{"function": {"type": "function", "id": other_map.id}}]); + let functions = vec![facet.clone(), map.clone(), other_facet, other_map]; + let automations = vec![unrelated.clone(), bound.clone()]; + let mut template = from_remote(&functions, &automations).unwrap(); + assert_eq!(template.topics_automations.len(), 2); + template + .facets + .retain(|selected| selected.slug == facet.slug); + retain_topics_settings(&mut template, &functions, &automations).unwrap(); + validate(&template).unwrap(); + assert_eq!(template.topics_automations.len(), 1); + assert_eq!( + template.topics_automations[0].description, + bound.description + ); + + unrelated.config = bound.config.clone(); + let error = from_remote(&[facet, map], &[bound, unrelated]).unwrap_err(); + assert!(error.to_string().contains("multiple Topics automations")); + } + + #[test] + fn active_observability_v2_settings_validate_and_follow_selection() { + let mut source: ActiveObservabilityTemplate = serde_json::from_value(json!({ + "kind": KIND, "schema_version": 2, + "facets": [{"name":"Test facet", "slug":"test-facet", "topics_automation":"Test Topics", "function_data":{"type":"facet"}}], + "topics_automations": [{"name":"Test Topics", "config":{"event_type":"topic", "backfill_time_range":"server-owned-value"}}] + })).unwrap(); + validate(&source).unwrap(); + source.topics_automations[0].config["facet_functions"] = json!([]); + assert!(validate(&source) + .unwrap_err() + .to_string() + .contains("facet_functions")); + source.topics_automations[0] + .config + .as_object_mut() + .unwrap() + .remove("facet_functions"); + source.topics_automations[0].config["event_type"] = json!("windowed"); + assert!(validate(&source) + .unwrap_err() + .to_string() + .contains("not a Topics")); + source.topics_automations[0].config["event_type"] = json!("topic"); + source + .topics_automations + .push(source.topics_automations[0].clone()); + assert!(validate(&source) + .unwrap_err() + .to_string() + .contains("duplicate")); + source.topics_automations.pop(); + let mut missing = source.clone(); + missing.topics_automations.clear(); + assert!(validate(&missing) + .unwrap_err() + .to_string() + .contains("without bundled settings")); + missing.schema_version = 1; + validate(&missing).unwrap(); + source.topics_automations[0].config["data_scope"] = + json!({"type":"experiment", "experiment_id":"test-experiment-id"}); + let error = validate_topics_scope(&source).unwrap_err().to_string(); + assert!(error.contains("Test facet") && error.contains("specific experiment")); + source.facets.clear(); + retain_topics_settings(&mut source, &[], &[]).unwrap(); + assert!(source.topics_automations.is_empty()); + } } diff --git a/tests/evals/js/eval-ts-esm-only-dep/package.json b/tests/evals/js/eval-ts-esm-only-dep/package.json index 779b5d64..f4647432 100644 --- a/tests/evals/js/eval-ts-esm-only-dep/package.json +++ b/tests/evals/js/eval-ts-esm-only-dep/package.json @@ -6,6 +6,7 @@ "esm-only-pkg": "file:./esm-only-pkg" }, "devDependencies": { - "tsx": "^4.23.15" + "tsx": "^4.23.15", + "vite-node": "^6.0.0" } } diff --git a/tests/evals/js/eval-ts-local-sdk/package.json b/tests/evals/js/eval-ts-local-sdk/package.json index 6f2a4a11..8f47b168 100644 --- a/tests/evals/js/eval-ts-local-sdk/package.json +++ b/tests/evals/js/eval-ts-local-sdk/package.json @@ -7,6 +7,7 @@ "esm-only-pkg": "file:esm-only-pkg" }, "devDependencies": { - "tsx": "^4.23.15" + "tsx": "^4.23.15", + "vite-node": "^6.0.0" } }