diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3bf28d77..a1a76073 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,6 +23,12 @@ jobs: submodules: true - run: npm ci - run: rustup update ${{ matrix.toolchain }} && rustup default ${{ matrix.toolchain }} - - run: cargo build --verbose --all-targets - - run: cargo test --verbose + # Add WASM target + - run: rustup target add wasm32-wasi + # Build non-WASM targets + - run: cargo build --verbose --all-targets --workspace --exclude fastly-edge-assignments + # Build WASM target separately + - run: cargo build --verbose -p fastly-edge-assignments --target wasm32-wasi + # Run tests (excluding WASM package) + - run: cargo test --verbose --workspace --exclude fastly-edge-assignments - run: cargo doc --verbose diff --git a/Cargo.toml b/Cargo.toml index 273f6fae..6548074f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,10 +1,11 @@ [workspace] resolver = "2" members = [ - "eppo_core", - "rust-sdk", - "python-sdk", - "ruby-sdk/ext/eppo_client", + "eppo_core", + "rust-sdk", + "python-sdk", + "ruby-sdk/ext/eppo_client", + "fastly-edge-assignments", ] [patch.crates-io] diff --git a/Makefile b/Makefile index 3f2cf23a..dc53cf6d 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,3 @@ -# Make settings - @see https://tech.davis-hansson.com/p/make/ SHELL := bash .ONESHELL: .SHELLFLAGS := -eu -o pipefail -c @@ -6,23 +5,43 @@ SHELL := bash MAKEFLAGS += --warn-undefined-variables MAKEFLAGS += --no-builtin-rules -# Log levels -DEBUG := $(shell printf "\e[2D\e[35m") -INFO := $(shell printf "\e[2D\e[36m🔵 ") -OK := $(shell printf "\e[2D\e[32m🟢 ") -WARN := $(shell printf "\e[2D\e[33m🟡 ") -ERROR := $(shell printf "\e[2D\e[31m🔴 ") -END := $(shell printf "\e[0m") +WASM_TARGET=wasm32-wasi +FASTLY_PACKAGE=fastly-edge-assignments +BUILD_DIR=target/$(WASM_TARGET)/release +WASM_FILE=$(BUILD_DIR)/$(FASTLY_PACKAGE).wasm -.PHONY: default -default: help - -## help - Print help message. +# Help target for easy documentation .PHONY: help -help: Makefile - @echo "usage: make " - @sed -n 's/^##//p' $< +help: + @echo "Available targets:" + @echo " all - Default target (build workspace)" + @echo " workspace-build - Build the entire workspace excluding the Fastly package" + @echo " workspace-test - Test the entire workspace excluding the Fastly package" + @echo " fastly-edge-assignments-build - Build only the Fastly package for WASM" + @echo " fastly-edge-assignments-test - Test only the Fastly package" + @echo " clean - Clean all build artifacts" .PHONY: test test: ${testDataDir} npm test + +# Build the entire workspace excluding the `fastly-edge-assignments` package +.PHONY: workspace-build +workspace-build: + cargo build --workspace --exclude $(FASTLY_PACKAGE) + +# Run tests for the entire workspace excluding the `fastly-edge-assignments` package +.PHONY: workspace-test +workspace-test: + cargo test --workspace --exclude $(FASTLY_PACKAGE) + +# Build only the `fastly-edge-assignments` package for WASM +.PHONY: fastly-edge-assignments-build +fastly-edge-assignments-build: + rustup target add $(WASM_TARGET) + cargo build --release --target $(WASM_TARGET) --package $(FASTLY_PACKAGE) + +# Test only the `fastly-edge-assignments` package +.PHONY: fastly-edge-assignments-test +fastly-edge-assignments-test: + cargo test --target $(WASM_TARGET) --package $(FASTLY_PACKAGE) diff --git a/eppo_core/src/lib.rs b/eppo_core/src/lib.rs index fe1d770a..a4bbf30d 100644 --- a/eppo_core/src/lib.rs +++ b/eppo_core/src/lib.rs @@ -55,6 +55,7 @@ pub mod configuration_store; pub mod eval; pub mod events; pub mod poller_thread; +pub mod precomputed_assignments; #[cfg(feature = "pyo3")] pub mod pyo3; pub mod sharder; diff --git a/eppo_core/src/precomputed_assignments.rs b/eppo_core/src/precomputed_assignments.rs new file mode 100644 index 00000000..d5e7f0e7 --- /dev/null +++ b/eppo_core/src/precomputed_assignments.rs @@ -0,0 +1,86 @@ +use crate::ufc::{Assignment, AssignmentFormat, Environment, VariationType}; +use crate::{Attributes, Configuration, Str}; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::sync::Arc; + +// Request +#[derive(Debug, Deserialize)] +pub struct PrecomputedAssignmentsServiceRequestBody { + pub subject_key: Str, + pub subject_attributes: Arc, + // TODO: Add bandit actions + // #[serde(rename = "banditActions")] + // #[serde(skip_serializing_if = "Option::is_none")] + // bandit_actions: Option>, +} + +// Response +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct FlagAssignment { + pub allocation_key: Str, + pub variation_key: Str, + pub variation_type: VariationType, + pub variation_value: serde_json::Value, + /// Additional user-defined logging fields for capturing extra information related to the + /// assignment. + #[serde(flatten)] + pub extra_logging: HashMap, + pub do_log: bool, +} + +impl FlagAssignment { + pub fn try_from_assignment(assignment: Assignment) -> Option { + // WARNING! There is a problem here. The event is only populated for splits + // that have `do_log` set to true in the wire format. This means that + // all the ones present here are logged, but any splits that are not + // logged are not present here. + // + // This is a problem for us because we want to be able to return + // precomputed assignments for any split, logged or not, since we + // want to be able to return them for all flags. + // + // We need to fix this. + assignment.event.as_ref().map(|event| Self { + allocation_key: event.base.allocation.clone(), + variation_key: event.base.variation.clone(), + variation_type: assignment.value.variation_type(), + variation_value: assignment.value.variation_value(), + extra_logging: event + .base + .extra_logging + .iter() + .map(|(k, v)| (k.clone(), v.clone())) + .collect(), + do_log: true, + }) + } +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PrecomputedAssignmentsServiceResponse { + created_at: chrono::DateTime, + format: AssignmentFormat, + environment: Environment, + flags: HashMap, +} + +impl PrecomputedAssignmentsServiceResponse { + pub fn from_configuration( + configuration: Arc, + flags: HashMap, + ) -> Self { + Self { + created_at: chrono::Utc::now(), + format: AssignmentFormat::Precomputed, + environment: { + Environment { + name: configuration.flags.compiled.environment.name.clone(), + } + }, + flags, + } + } +} diff --git a/eppo_core/src/ufc/assignment.rs b/eppo_core/src/ufc/assignment.rs index 594472d0..65b76b7b 100644 --- a/eppo_core/src/ufc/assignment.rs +++ b/eppo_core/src/ufc/assignment.rs @@ -4,6 +4,8 @@ use serde::{Deserialize, Serialize}; use crate::{events::AssignmentEvent, Str}; +use crate::ufc::VariationType; + /// Result of assignment evaluation. #[derive(Debug, Serialize, Clone)] #[serde(rename_all = "camelCase")] @@ -231,6 +233,52 @@ impl AssignmentValue { _ => None, } } + + /// Returns the type of the variation as a string. + /// + /// # Returns + /// - A string representing the type of the variation ("STRING", "INTEGER", "NUMERIC", "BOOLEAN", or "JSON"). + /// + /// # Examples + /// ``` + /// # use eppo_core::ufc::AssignmentValue; + /// # use eppo_core::ufc::VariationType; + /// let value = AssignmentValue::String("example".into()); + /// assert_eq!(value.variation_type(), VariationType::String); + /// ``` + pub fn variation_type(&self) -> VariationType { + match self { + AssignmentValue::String(_) => VariationType::String, + AssignmentValue::Integer(_) => VariationType::Integer, + AssignmentValue::Numeric(_) => VariationType::Numeric, + AssignmentValue::Boolean(_) => VariationType::Boolean, + AssignmentValue::Json(_) => VariationType::Json, + } + } + + /// Returns the raw value of the variation. + /// + /// # Returns + /// - A JSON Value containing the variation value. + /// + /// # Examples + /// ``` + /// # use eppo_core::ufc::AssignmentValue; + /// # use serde_json::json; + /// let value = AssignmentValue::String("example".into()); + /// assert_eq!(value.variation_value(), json!("example")); + /// ``` + pub fn variation_value(&self) -> serde_json::Value { + match self { + AssignmentValue::String(s) => serde_json::Value::String(s.to_string()), + AssignmentValue::Integer(i) => serde_json::Value::Number((*i).into()), + AssignmentValue::Numeric(n) => serde_json::Value::Number( + serde_json::Number::from_f64(*n).unwrap_or_else(|| serde_json::Number::from(0)), + ), + AssignmentValue::Boolean(b) => serde_json::Value::Bool(*b), + AssignmentValue::Json(j) => j.as_ref().clone(), + } + } } #[cfg(feature = "pyo3")] diff --git a/eppo_core/src/ufc/models.rs b/eppo_core/src/ufc/models.rs index 1c2d5c98..2a024a94 100644 --- a/eppo_core/src/ufc/models.rs +++ b/eppo_core/src/ufc/models.rs @@ -18,6 +18,7 @@ pub type Timestamp = chrono::DateTime; pub(crate) struct UniversalFlagConfigWire { /// When configuration was last updated. pub created_at: Timestamp, + pub format: AssignmentFormat, /// Environment this configuration belongs to. pub environment: Environment, /// Flags configuration. @@ -31,6 +32,14 @@ pub(crate) struct UniversalFlagConfigWire { pub bandits: HashMap>, } +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(rename_all = "UPPERCASE")] +pub(crate) enum AssignmentFormat { + Client, + Precomputed, + Server, +} + #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub(crate) struct Environment { @@ -540,6 +549,7 @@ mod tests { &r#" { "createdAt": "2024-07-18T00:00:00Z", + "format": "SERVER", "environment": {"name": "test"}, "flags": { "success": { diff --git a/fastly-edge-assignments/.cargo/config.toml b/fastly-edge-assignments/.cargo/config.toml new file mode 100644 index 00000000..6b77899c --- /dev/null +++ b/fastly-edge-assignments/.cargo/config.toml @@ -0,0 +1,2 @@ +[build] +target = "wasm32-wasi" diff --git a/fastly-edge-assignments/.gitignore b/fastly-edge-assignments/.gitignore new file mode 100644 index 00000000..5b148861 --- /dev/null +++ b/fastly-edge-assignments/.gitignore @@ -0,0 +1,2 @@ +pkg/ +bin/ \ No newline at end of file diff --git a/fastly-edge-assignments/Cargo.toml b/fastly-edge-assignments/Cargo.toml new file mode 100644 index 00000000..14ff822f --- /dev/null +++ b/fastly-edge-assignments/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "fastly-edge-assignments" +version = "0.1.0" +edition = "2021" +# Remove this line if you want to be able to publish this crate as open source on crates.io. +# Otherwise, `publish = false` prevents an accidental `cargo publish` from revealing private source. +publish = false + +[dependencies] +base64-url = "2.0.0" +chrono = "0.4.19" +eppo_core = { version = "=4.1.1", path = "../eppo_core" } +fastly = "0.11.0" +serde_json = "1.0.132" +serde = "1.0.192" +sha2 = "0.10.0" diff --git a/fastly-edge-assignments/README.md b/fastly-edge-assignments/README.md new file mode 100644 index 00000000..6c6800f7 --- /dev/null +++ b/fastly-edge-assignments/README.md @@ -0,0 +1,3 @@ +# Eppo Assignments on Fastly Compute@Edge + +TODO: Add a description diff --git a/fastly-edge-assignments/fastly.toml b/fastly-edge-assignments/fastly.toml new file mode 100644 index 00000000..122618f6 --- /dev/null +++ b/fastly-edge-assignments/fastly.toml @@ -0,0 +1,17 @@ +# This file describes a Fastly Compute package. To learn more visit: +# https://www.fastly.com/documentation/reference/compute/fastly-toml + +authors = ["engineering@geteppo.com"] +name = "Eppo Assignments on Fastly Compute@Edge" +description = "Edge compute service for pre-computed Eppo flag assignments" +language = "rust" +manifest_version = 3 + +[scripts] +build = "cargo build --bin fastly-edge-assignments --release --target wasm32-wasi --color always" + +[local_server] + [local_server.kv_stores] + [[local_server.kv_stores.edge-assignment-kv-store]] + key = "ufc-by-sdk-key-token-hash-V--77TScV5Etm78nIMTSOdiroOh1__NsupwUwsetEVM" + file = "../sdk-test-data/ufc/flags-v1.json" diff --git a/fastly-edge-assignments/rust-toolchain.toml b/fastly-edge-assignments/rust-toolchain.toml new file mode 100644 index 00000000..5914a562 --- /dev/null +++ b/fastly-edge-assignments/rust-toolchain.toml @@ -0,0 +1,3 @@ +[toolchain] +channel = "stable" +targets = [ "wasm32-wasi" ] diff --git a/fastly-edge-assignments/src/handlers/assignments.rs b/fastly-edge-assignments/src/handlers/assignments.rs new file mode 100644 index 00000000..fe3ae064 --- /dev/null +++ b/fastly-edge-assignments/src/handlers/assignments.rs @@ -0,0 +1,175 @@ +use eppo_core::configuration_store::ConfigurationStore; +use eppo_core::eval::{Evaluator, EvaluatorConfig}; +use eppo_core::precomputed_assignments::{ + FlagAssignment, PrecomputedAssignmentsServiceRequestBody, PrecomputedAssignmentsServiceResponse, +}; +use eppo_core::ufc::UniversalFlagConfig; +use eppo_core::{Attributes, Configuration, SdkMetadata}; +use fastly::http::StatusCode; +use fastly::kv_store::KVStoreError; +use fastly::{Error, KVStore, Request, Response}; +use sha2::{Digest, Sha256}; +use std::collections::HashMap; +use std::sync::Arc; + +const KV_STORE_NAME: &str = "edge-assignment-kv-store"; +const SDK_KEY_QUERY_PARAM: &str = "apiKey"; // For legacy reasons this is named `apiKey` + +const SDK_NAME: &str = "fastly-edge-assignments"; +const SDK_VERSION: &str = env!("CARGO_PKG_VERSION"); + +fn kv_store_key(token_hash: String) -> String { + format!("ufc-by-sdk-key-token-hash-{}", token_hash) +} + +fn token_hash(sdk_key: &str) -> String { + let mut hasher = Sha256::new(); + hasher.update(sdk_key.as_bytes()); + base64_url::encode(&hasher.finalize()) +} + +pub fn handle_assignments(mut req: Request) -> Result { + // Extract the SDK key and generate a token hash matching the pre-defined encoding. + let Some(token) = req + .get_query_parameter(SDK_KEY_QUERY_PARAM) + .filter(|it| !it.is_empty()) + else { + return Ok( + Response::from_status(StatusCode::BAD_REQUEST).with_body_text_plain(&format!( + "Missing required query parameter: {}", + SDK_KEY_QUERY_PARAM + )), + ); + }; + let token_hash = token_hash(token); + + // Deserialize the request body into a struct + let (subject_key, subject_attributes): (eppo_core::Str, Arc) = + match serde_json::from_slice::( + &req.take_body_bytes(), + ) { + Ok(body) => { + if body.subject_key.is_empty() { + return Ok(Response::from_status(StatusCode::BAD_REQUEST) + .with_body_text_plain("subject_key is required and cannot be empty")); + } + (body.subject_key, body.subject_attributes) + } + Err(e) => { + let error_message = if e.to_string().contains("subject_key") { + "subject_key is required in the request body" + } else { + "Invalid request body format" + }; + return Ok(Response::from_status(StatusCode::BAD_REQUEST) + .with_body_text_plain(error_message)); + } + }; + + // Open the KV store + let kv_store = KVStore::open(KV_STORE_NAME).map(|store| store.expect("KVStore exists"))?; + + let mut kv_store_item = match kv_store.lookup(&kv_store_key(token_hash.clone())) { + Ok(item) => item, + Err(e) => { + let (status, message) = match e { + KVStoreError::ItemNotFound => { + eprintln!("Missing configuration for SDK key: {}", token_hash); + + // Return unauthorized if the key does not exist. + // Our protocol lets the client know that the SDK key has not had a UFC + // configuration pre-computed for it in the KV Store. + (StatusCode::UNAUTHORIZED, "Invalid SDK key.".to_string()) + } + _ => { + eprintln!("KV Store error: {:?}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + "Unexpected KV Store error".to_string(), + ) + } + }; + + return Ok(Response::from_status(status).with_body_text_plain(&message)); + } + }; + + // Parse the response from the KV store + let kv_store_item_body = kv_store_item.take_body(); + let ufc_config = match UniversalFlagConfig::from_json( + SdkMetadata { + name: SDK_NAME, + version: SDK_VERSION, + }, + kv_store_item_body.into_bytes(), + ) { + Ok(config) => config, + Err(e) => { + eprintln!("Failed to parse UFC config: {:?}", e); + return Ok(Response::from_status(StatusCode::INTERNAL_SERVER_ERROR) + .with_body_text_plain("Invalid configuration format in KV store")); + } + }; + + let configuration = Configuration::from_server_response(ufc_config, None); + let configuration = Arc::new(configuration); + let flag_keys = configuration.flag_keys(); + let configuration_store = ConfigurationStore::new(); + configuration_store.set_configuration(configuration.clone()); + let evaluator = Evaluator::new(EvaluatorConfig { + configuration_store: Arc::new(configuration_store), + sdk_metadata: SdkMetadata { + name: SDK_NAME, + version: SDK_VERSION, + }, + }); + + let subject_assignments = flag_keys + .iter() + .filter_map(|key| { + match evaluator.get_assignment(key, &subject_key, &subject_attributes, None) { + Ok(Some(assignment)) => FlagAssignment::try_from_assignment(assignment) + .map(|flag_assignment| (key.clone(), flag_assignment)), + Ok(None) => None, + Err(e) => { + eprintln!("Failed to evaluate assignment for key {}: {:?}", key, e); + None + } + } + }) + .collect::>(); + + // Create the response + let assignments_response = PrecomputedAssignmentsServiceResponse::from_configuration( + configuration, + subject_assignments, + ); + + // Create an HTTP OK response with the assignments + let response = match Response::from_status(StatusCode::OK).with_body_json(&assignments_response) + { + Ok(response) => response, + Err(e) => { + eprintln!("Failed to serialize response: {:?}", e); + return Ok(Response::from_status(StatusCode::INTERNAL_SERVER_ERROR) + .with_body_text_plain("Failed to serialize response")); + } + }; + Ok(response) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_token_hash() { + // Test case with a known SDK key and its expected hash + let sdk_key = "5qCSVzH1lCI11.ZWg9ZDhlYnhsLmV2ZW50cy5lcHBvLmxvY2FsaG9zdA"; + let expected_hash = "V--77TScV5Etm78nIMTSOdiroOh1__NsupwUwsetEVM"; + + let result = token_hash(sdk_key); + + assert_eq!(result, expected_hash); + } +} diff --git a/fastly-edge-assignments/src/handlers/health.rs b/fastly-edge-assignments/src/handlers/health.rs new file mode 100644 index 00000000..47ca6a23 --- /dev/null +++ b/fastly-edge-assignments/src/handlers/health.rs @@ -0,0 +1,5 @@ +use fastly::{http::StatusCode, Error, Request, Response}; + +pub fn handle_health(_req: Request) -> Result { + Ok(Response::from_status(StatusCode::OK).with_body_text_plain("OK")) +} diff --git a/fastly-edge-assignments/src/handlers/mod.rs b/fastly-edge-assignments/src/handlers/mod.rs new file mode 100644 index 00000000..a6b6ae69 --- /dev/null +++ b/fastly-edge-assignments/src/handlers/mod.rs @@ -0,0 +1,7 @@ +// Declare submodules +pub mod assignments; +pub mod health; + +// Re-export items to make them more convenient to use +pub use assignments::handle_assignments; +pub use health::handle_health; diff --git a/fastly-edge-assignments/src/main.rs b/fastly-edge-assignments/src/main.rs new file mode 100644 index 00000000..665a755c --- /dev/null +++ b/fastly-edge-assignments/src/main.rs @@ -0,0 +1,28 @@ +mod handlers; + +use fastly::http::{Method, StatusCode}; +use fastly::{Error, Request, Response}; + +#[fastly::main] +fn main(req: Request) -> Result { + // Handle CORS preflight requests + if req.get_method() == Method::OPTIONS { + return Ok(Response::from_status(StatusCode::NO_CONTENT) + .with_header("Access-Control-Allow-Origin", "*") + .with_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS") + .with_header("Access-Control-Allow-Headers", "Content-Type") + .with_header("Access-Control-Max-Age", "86400")); + } + + // Handle regular requests and add CORS headers to the response + let response = match (req.get_method(), req.get_path()) { + (&Method::POST, "/assignments") => handlers::handle_assignments(req), + (&Method::GET, "/health") => handlers::handle_health(req), + _ => Ok(Response::from_status(StatusCode::NOT_FOUND).with_body_text_plain("Not Found")), + }?; + + // Add CORS headers to all responses + Ok(response + .with_header("Access-Control-Allow-Origin", "*") + .with_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")) +} diff --git a/python-sdk/tests/test_configuration.py b/python-sdk/tests/test_configuration.py index 81f9087f..1f55661e 100644 --- a/python-sdk/tests/test_configuration.py +++ b/python-sdk/tests/test_configuration.py @@ -8,6 +8,7 @@ FLAGS_CONFIG = json.dumps( { "createdAt": "2024-09-09T10:18:15.988Z", + "format": "SERVER", "environment": {"name": "test"}, "flags": {}, } @@ -16,6 +17,7 @@ FLAGS_CONFIG_WITH_BANDITS = json.dumps( { "createdAt": "2024-09-09T10:18:15.988Z", + "format": "SERVER", "environment": {"name": "test"}, "flags": {}, "bandits": { @@ -112,7 +114,7 @@ def test_init_invalid_format(self): """flags is specified as array instead of object""" with pytest.raises(Exception): Configuration( - flags_configuration=b'{"createdAt":"2024-09-09T10:18:15.988Z","environment":{"name":"test"},"flags":[]}' + flags_configuration=b'{"createdAt":"2024-09-09T10:18:15.988Z","format":"SERVER","environment":{"name":"test"},"flags":[]}' ) def test_get_bandits_configuration(self): @@ -145,5 +147,6 @@ def test_configuration_some(): flag_config = configuration.get_flags_configuration() result = json.loads(flag_config) + assert result["format"] == "SERVER" assert result["environment"] == {"name": "Test"} assert "numeric_flag" in result["flags"] diff --git a/python-sdk/tests/test_initial_configuration.py b/python-sdk/tests/test_initial_configuration.py index cd9a2885..cea252e9 100644 --- a/python-sdk/tests/test_initial_configuration.py +++ b/python-sdk/tests/test_initial_configuration.py @@ -22,7 +22,7 @@ def test_with_initial_configuration(): base_url="http://localhost:8378/api", assignment_logger=AssignmentLogger(), initial_configuration=Configuration( - flags_configuration=b'{"createdAt":"2024-09-09T10:18:15.988Z","environment":{"name":"test"},"flags":{}}' + flags_configuration=b'{"createdAt":"2024-09-09T10:18:15.988Z","format":"SERVER","environment":{"name":"test"},"flags":{}}' ), ) ) @@ -40,7 +40,7 @@ def test_update_configuration(): client.set_configuration( Configuration( - flags_configuration=b'{"createdAt":"2024-09-09T10:18:15.988Z","environment":{"name":"test"},"flags":{}}' + flags_configuration=b'{"createdAt":"2024-09-09T10:18:15.988Z","format":"SERVER","environment":{"name":"test"},"flags":{}}' ) ) diff --git a/sdk-test-data b/sdk-test-data index 11dace62..7db46318 160000 --- a/sdk-test-data +++ b/sdk-test-data @@ -1 +1 @@ -Subproject commit 11dace62a7ce97792bd54e48aaf354f62a034d63 +Subproject commit 7db46318cf74a3286a06afc448358cd379ae0cb9