From fb4de9ce2f21874028ea5198fad2b6b0eff6ee32 Mon Sep 17 00:00:00 2001 From: Matthew Keeler Date: Tue, 12 May 2026 16:29:12 -0400 Subject: [PATCH 1/2] feat: add X-LaunchDarkly-Instance-Id header (SDK-2359) Generate a v4 UUID once per SDK instance in ConfigBuilder::build and stamp it on the X-LaunchDarkly-Instance-Id header sent by the streaming data source, polling feature requester, and event processor. The header value is stable for the lifetime of the SDK instance and unique across instances, satisfying SCMP-server-connection-minutes-polling section 1.1. The instance id is plumbed through the existing DataSourceFactory, FeatureRequesterFactory (via HttpFeatureRequesterBuilder), and EventProcessorFactory build paths so streaming, polling, and event requests all carry the same identifier without per-channel plumbing. Registers the "instance-id" capability with the contract-test service so the cross-SDK harness can verify the header on each request type. --- contract-tests/src/main.rs | 1 + launchdarkly-server-sdk/src/client.rs | 21 ++- launchdarkly-server-sdk/src/config.rs | 60 ++++++++ launchdarkly-server-sdk/src/data_source.rs | 145 +++++++++++++++++- .../src/data_source_builders.rs | 12 +- .../src/events/processor_builders.rs | 52 ++++++- .../src/feature_requester_builders.rs | 8 +- launchdarkly-server-sdk/src/lib.rs | 6 + launchdarkly-server-sdk/src/test_data.rs | 17 +- 9 files changed, 301 insertions(+), 21 deletions(-) diff --git a/contract-tests/src/main.rs b/contract-tests/src/main.rs index 676b361..87136b2 100644 --- a/contract-tests/src/main.rs +++ b/contract-tests/src/main.rs @@ -122,6 +122,7 @@ async fn status() -> impl Responder { "client-prereq-events".to_string(), "event-gzip".to_string(), "optional-event-gzip".to_string(), + "instance-id".to_string(), ], }) } diff --git a/launchdarkly-server-sdk/src/client.rs b/launchdarkly-server-sdk/src/client.rs index 7bdfbfb..de7883b 100644 --- a/launchdarkly-server-sdk/src/client.rs +++ b/launchdarkly-server-sdk/src/client.rs @@ -175,16 +175,21 @@ impl Client { } let tags = config.application_tag(); + let instance_id = config.instance_id(); let endpoints = config.service_endpoints_builder().build()?; - let event_processor = - config - .event_processor_builder() - .build(&endpoints, config.sdk_key(), tags.clone())?; - let data_source = - config - .data_source_builder() - .build(&endpoints, config.sdk_key(), tags.clone())?; + let event_processor = config.event_processor_builder().build( + &endpoints, + config.sdk_key(), + tags.clone(), + instance_id, + )?; + let data_source = config.data_source_builder().build( + &endpoints, + config.sdk_key(), + tags.clone(), + instance_id, + )?; let data_store = config.data_store_builder().build()?; let events_default = EventsScope { diff --git a/launchdarkly-server-sdk/src/config.rs b/launchdarkly-server-sdk/src/config.rs index 5f5b6f3..32413b7 100644 --- a/launchdarkly-server-sdk/src/config.rs +++ b/launchdarkly-server-sdk/src/config.rs @@ -138,6 +138,7 @@ pub struct Config { data_source_builder: Box, event_processor_builder: Box, application_tag: Option, + instance_id: String, offline: bool, daemon_mode: bool, } @@ -182,6 +183,13 @@ impl Config { pub fn application_tag(&self) -> &Option { &self.application_tag } + + /// Returns the per-SDK-instance identifier. This is a v4 UUID, generated once when the + /// [Config] is built, that is included in the `X-LaunchDarkly-Instance-Id` HTTP header + /// on outbound requests for the lifetime of the SDK instance. + pub fn instance_id(&self) -> &str { + &self.instance_id + } } /// Error type used to represent failures when building a Config instance. @@ -381,6 +389,13 @@ impl ConfigBuilder { _ => None, }; + // Per SCMP-server-connection-minutes-polling, every polling request must carry a + // per-SDK-instance v4 UUID. We generate it once here, store it on Config, and pass it + // into the data source, feature requester, and event processor so that streaming, + // polling, and event requests all carry the same stable identifier for the lifetime + // of this client. + let instance_id = uuid::Uuid::new_v4().to_string(); + Ok(Config { sdk_key: self.sdk_key, service_endpoints_builder, @@ -388,6 +403,7 @@ impl ConfigBuilder { data_source_builder, event_processor_builder, application_tag, + instance_id, offline: self.offline, daemon_mode: self.daemon_mode, }) @@ -431,6 +447,50 @@ mod tests { assert_eq!(None, config.application_tag); } + #[test] + #[cfg(any( + feature = "hyper-rustls-native-roots", + feature = "hyper-rustls-webpki-roots", + feature = "native-tls" + ))] + fn instance_id_is_a_uuid_v4() { + let config = ConfigBuilder::new("sdk-key") + .build() + .expect("config should build"); + + let parsed = uuid::Uuid::parse_str(config.instance_id()) + .expect("instance id should be a parseable UUID"); + assert_eq!( + uuid::Version::Random, + parsed.get_version().expect("uuid should have a version"), + "instance id must be UUID v4" + ); + } + + #[test] + #[cfg(any( + feature = "hyper-rustls-native-roots", + feature = "hyper-rustls-webpki-roots", + feature = "native-tls" + ))] + fn instance_id_is_unique_per_config() { + // Each call to ConfigBuilder::build represents a new SDK instance; each must get its own + // GUID so connection-minutes accounting on the server side can distinguish them. + let c1 = ConfigBuilder::new("sdk-key") + .build() + .expect("config should build"); + let c2 = ConfigBuilder::new("sdk-key") + .build() + .expect("config should build"); + assert!(!c1.instance_id().is_empty()); + assert!(!c2.instance_id().is_empty()); + assert_ne!( + c1.instance_id(), + c2.instance_id(), + "each SDK instance should generate its own instance id" + ); + } + #[test_case("id", "version", Some("application-id/id application-version/version".to_string()))] #[test_case("Invalid id", "version", Some("application-version/version".to_string()))] #[test_case("id", "Invalid version", Some("application-id/id".to_string()))] diff --git a/launchdarkly-server-sdk/src/data_source.rs b/launchdarkly-server-sdk/src/data_source.rs index 47b5c88..ba582b8 100644 --- a/launchdarkly-server-sdk/src/data_source.rs +++ b/launchdarkly-server-sdk/src/data_source.rs @@ -3,7 +3,7 @@ use crate::feature_requester::FeatureRequesterError; use crate::feature_requester_builders::FeatureRequesterFactory; use crate::reqwest::is_http_error_recoverable; use crate::stores::store::{DataStore, UpdateError}; -use crate::LAUNCHDARKLY_TAGS_HEADER; +use crate::{LAUNCHDARKLY_INSTANCE_ID_HEADER, LAUNCHDARKLY_TAGS_HEADER}; use es::{Client, ClientBuilder, ReconnectOptionsBuilder}; use eventsource_client as es; use futures::StreamExt; @@ -75,6 +75,7 @@ impl StreamingDataSource { sdk_key: &str, initial_reconnect_delay: Duration, tags: &Option, + instance_id: &str, transport: T, ) -> std::result::Result { let stream_url = format!("{base_url}/all"); @@ -89,7 +90,8 @@ impl StreamingDataSource { .build(), ) .header("Authorization", sdk_key)? - .header("User-Agent", &crate::USER_AGENT)?; + .header("User-Agent", &crate::USER_AGENT)? + .header(LAUNCHDARKLY_INSTANCE_ID_HEADER, instance_id)?; if let Some(tags) = tags { client_builder = client_builder.header(LAUNCHDARKLY_TAGS_HEADER, tags)?; @@ -374,7 +376,15 @@ mod tests { use super::{DataSource, PollingDataSource, StreamingDataSource}; use crate::feature_requester_builders::HttpFeatureRequesterBuilder; - use crate::{stores::store::InMemoryDataStore, LAUNCHDARKLY_TAGS_HEADER}; + use crate::{ + stores::store::InMemoryDataStore, LAUNCHDARKLY_INSTANCE_ID_HEADER, LAUNCHDARKLY_TAGS_HEADER, + }; + + // Matches lowercased canonical UUID v4 format, e.g. + // "550e8400-e29b-41d4-a716-446655440000". The third group must start with "4" (UUID + // version) and the fourth must start with one of 8/9/a/b (variant 10x). + const UUID_V4_REGEX: &str = + r"^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$"; #[test_case(Some("application-id/abc:application-sha/xyz".into()), "application-id/abc:application-sha/xyz")] #[test_case(None, Matcher::Missing)] @@ -401,6 +411,7 @@ mod tests { "sdk-key", Duration::from_secs(0), &tag, + "test-instance-id", launchdarkly_sdk_transport::HyperTransport::new() .expect("Failed to create streaming data source"), ) @@ -455,7 +466,12 @@ mod tests { let transport = launchdarkly_sdk_transport::HyperTransport::new() .expect("Failed to create transport for polling data source"); - let hyper_builder = HttpFeatureRequesterBuilder::new(&server.url(), "sdk-key", transport); + let hyper_builder = HttpFeatureRequesterBuilder::new( + &server.url(), + "sdk-key", + "test-instance-id", + transport, + ); let polling = PollingDataSource::new( Arc::new(Mutex::new(Box::new(hyper_builder))), @@ -490,4 +506,125 @@ mod tests { mock.assert() } + + // Asserts that streaming requests carry the X-LaunchDarkly-Instance-Id header, that its + // value matches the value passed in, and that a UUID-v4-shaped value is accepted (this + // mirrors how the value is generated in ConfigBuilder::build). + #[tokio::test(flavor = "multi_thread")] + async fn streaming_source_passes_along_instance_id_header() { + let mut server = mockito::Server::new_async().await; + let mock = server + .mock("GET", "/all") + .with_status(200) + .with_body("event:one\ndata:One\n\n") + .expect_at_least(1) + .match_header( + LAUNCHDARKLY_INSTANCE_ID_HEADER, + Matcher::Regex(UUID_V4_REGEX.into()), + ) + .create_async() + .await; + + let (shutdown_tx, _) = broadcast::channel::<()>(1); + let initialized = Arc::new(AtomicBool::new(false)); + + let instance_id = uuid::Uuid::new_v4().to_string(); + let streaming = StreamingDataSource::new( + &server.url(), + "sdk-key", + Duration::from_secs(0), + &None, + &instance_id, + launchdarkly_sdk_transport::HyperTransport::new() + .expect("Failed to create streaming data source"), + ) + .unwrap(); + + let data_store = Arc::new(RwLock::new(InMemoryDataStore::new())); + + let init_state = initialized.clone(); + streaming.subscribe( + data_store, + Arc::new(move |success| init_state.store(success, Ordering::SeqCst)), + shutdown_tx.subscribe(), + ); + + let mut attempts = 0; + loop { + if initialized.load(Ordering::SeqCst) { + break; + } + + attempts += 1; + if attempts > 10 { + break; + } + + std::thread::sleep(Duration::from_millis(100)); + } + + let _ = shutdown_tx.send(()); + mock.assert() + } + + // Asserts that polling requests carry the X-LaunchDarkly-Instance-Id header. The polling + // feature requester is what actually issues the HTTP request, so this is the level at + // which the spec's "every polling request must carry the header" requirement is verified. + #[tokio::test(flavor = "multi_thread")] + async fn polling_source_passes_along_instance_id_header() { + let mut server = mockito::Server::new_async().await; + let mock = server + .mock("GET", "/sdk/latest-all") + .with_status(200) + .with_body("{}") + .expect_at_least(1) + .match_header( + LAUNCHDARKLY_INSTANCE_ID_HEADER, + Matcher::Regex(UUID_V4_REGEX.into()), + ) + .create_async() + .await; + + let (shutdown_tx, _) = broadcast::channel::<()>(1); + let initialized = Arc::new(AtomicBool::new(false)); + + let transport = launchdarkly_sdk_transport::HyperTransport::new() + .expect("Failed to create transport for polling data source"); + let instance_id = uuid::Uuid::new_v4().to_string(); + let hyper_builder = + HttpFeatureRequesterBuilder::new(&server.url(), "sdk-key", &instance_id, transport); + + let polling = PollingDataSource::new( + Arc::new(Mutex::new(Box::new(hyper_builder))), + Duration::from_secs(10), + None, + ); + + let data_store = Arc::new(RwLock::new(InMemoryDataStore::new())); + + let init_state = initialized.clone(); + polling.subscribe( + data_store, + Arc::new(move |success| init_state.store(success, Ordering::SeqCst)), + shutdown_tx.subscribe(), + ); + + let mut attempts = 0; + loop { + if initialized.load(Ordering::SeqCst) { + break; + } + + attempts += 1; + if attempts > 10 { + break; + } + + std::thread::sleep(Duration::from_millis(100)); + } + + let _ = shutdown_tx.send(()); + + mock.assert() + } } diff --git a/launchdarkly-server-sdk/src/data_source_builders.rs b/launchdarkly-server-sdk/src/data_source_builders.rs index d0a56e3..fcd1730 100644 --- a/launchdarkly-server-sdk/src/data_source_builders.rs +++ b/launchdarkly-server-sdk/src/data_source_builders.rs @@ -28,6 +28,7 @@ pub trait DataSourceFactory { endpoints: &service_endpoints::ServiceEndpoints, sdk_key: &str, tags: Option, + instance_id: &str, ) -> Result, BuildError>; fn to_owned(&self) -> Box; } @@ -90,6 +91,7 @@ impl DataSourceFactory endpoints: &service_endpoints::ServiceEndpoints, sdk_key: &str, tags: Option, + instance_id: &str, ) -> Result, BuildError> { let data_source_result = match &self.transport { #[cfg(any( @@ -109,6 +111,7 @@ impl DataSourceFactory sdk_key, self.initial_reconnect_delay, &tags, + instance_id, transport, )) } @@ -125,6 +128,7 @@ impl DataSourceFactory sdk_key, self.initial_reconnect_delay, &tags, + instance_id, transport.clone(), )), }; @@ -159,6 +163,7 @@ impl DataSourceFactory for NullDataSourceBuilder { _: &service_endpoints::ServiceEndpoints, _: &str, _: Option, + _: &str, ) -> Result, BuildError> { Ok(Arc::new(NullDataSource::new())) } @@ -259,6 +264,7 @@ impl DataSourceFactory for PollingDataSourceBuilder { endpoints: &service_endpoints::ServiceEndpoints, sdk_key: &str, tags: Option, + instance_id: &str, ) -> Result, BuildError> { let feature_requester_builder: Result, BuildError> = match &self.transport { @@ -278,6 +284,7 @@ impl DataSourceFactory for PollingDataSourceBuilder { Ok(Box::new(HttpFeatureRequesterBuilder::new( endpoints.polling_base_url(), sdk_key, + instance_id, transport, ))) } @@ -292,6 +299,7 @@ impl DataSourceFactory for PollingDataSourceBuilder { Some(transport) => Ok(Box::new(HttpFeatureRequesterBuilder::new( endpoints.polling_base_url(), sdk_key, + instance_id, transport.clone(), ))), }; @@ -344,6 +352,7 @@ impl DataSourceFactory for MockDataSourceBuilder { _endpoints: &service_endpoints::ServiceEndpoints, _sdk_key: &str, _tags: Option, + _instance_id: &str, ) -> Result, BuildError> { Ok(self.data_source.as_ref().unwrap().clone()) } @@ -389,7 +398,8 @@ mod tests { .build( &crate::ServiceEndpointsBuilder::new().build().unwrap(), "test", - None + None, + "test-instance-id", ) .is_ok()); } diff --git a/launchdarkly-server-sdk/src/events/processor_builders.rs b/launchdarkly-server-sdk/src/events/processor_builders.rs index 5a2440a..3ecfb3f 100644 --- a/launchdarkly-server-sdk/src/events/processor_builders.rs +++ b/launchdarkly-server-sdk/src/events/processor_builders.rs @@ -9,7 +9,7 @@ use launchdarkly_server_sdk_evaluation::Reference; use thiserror::Error; use crate::events::sender::HttpEventSender; -use crate::{service_endpoints, LAUNCHDARKLY_TAGS_HEADER}; +use crate::{service_endpoints, LAUNCHDARKLY_INSTANCE_ID_HEADER, LAUNCHDARKLY_TAGS_HEADER}; use launchdarkly_sdk_transport::HttpTransport; use super::processor::{ @@ -46,6 +46,7 @@ pub trait EventProcessorFactory { endpoints: &service_endpoints::ServiceEndpoints, sdk_key: &str, tags: Option, + instance_id: &str, ) -> Result, BuildError>; fn to_owned(&self) -> Box; } @@ -89,6 +90,7 @@ impl EventProcessorFactory for EventProcessorBuilder { endpoints: &service_endpoints::ServiceEndpoints, sdk_key: &str, tags: Option, + instance_id: &str, ) -> Result, BuildError> { let url_string = format!("{}/bulk", endpoints.events_base_url()); @@ -97,6 +99,7 @@ impl EventProcessorFactory for EventProcessorBuilder { if let Some(tags) = tags { default_headers.insert(LAUNCHDARKLY_TAGS_HEADER, tags); } + default_headers.insert(LAUNCHDARKLY_INSTANCE_ID_HEADER, instance_id.to_string()); let event_sender_result: Result, BuildError> = // NOTE: This would only be possible under unit testing conditions. @@ -298,6 +301,7 @@ impl EventProcessorFactory for NullEventProcessorBuilder { _: &service_endpoints::ServiceEndpoints, _: &str, _: Option, + _: &str, ) -> Result, BuildError> { Ok(Arc::new(NullEventProcessor::new())) } @@ -419,7 +423,51 @@ mod tests { let builder = EventProcessorBuilder::::new(); let processor = builder - .build(&service_endpoints, "sdk-key", tag) + .build(&service_endpoints, "sdk-key", tag, "test-instance-id") + .expect("Processor failed to build"); + + let event_factory = EventFactory::new(false); + + let context = ContextBuilder::new("bob") + .build() + .expect("Failed to create context"); + let identify_event = event_factory.new_identify(context); + + processor.send(identify_event); + processor.close(); + + mock.assert() + } + + // Verifies that event POSTs carry the X-LaunchDarkly-Instance-Id header. The Go reference + // attaches the per-instance GUID at the default-headers layer so streaming, polling, and + // events all inherit it; this is the events-channel half of that contract. + #[cfg(any( + feature = "hyper-rustls-native-roots", + feature = "hyper-rustls-webpki-roots", + feature = "native-tls" + ))] + #[test] + fn processor_sends_instance_id_header() { + let mut server = mockito::Server::new(); + let instance_id = uuid::Uuid::new_v4().to_string(); + let mock = server + .mock("POST", "/bulk") + .with_status(200) + .expect_at_least(1) + .match_header(LAUNCHDARKLY_INSTANCE_ID_HEADER, instance_id.as_str()) + .create(); + + let service_endpoints = ServiceEndpointsBuilder::new() + .events_base_url(&server.url()) + .polling_base_url(&server.url()) + .streaming_base_url(&server.url()) + .build() + .expect("Service endpoints failed to be created"); + + let builder = EventProcessorBuilder::::new(); + let processor = builder + .build(&service_endpoints, "sdk-key", None, &instance_id) .expect("Processor failed to build"); let event_factory = EventFactory::new(false); diff --git a/launchdarkly-server-sdk/src/feature_requester_builders.rs b/launchdarkly-server-sdk/src/feature_requester_builders.rs index 3c29f40..3bb04c2 100644 --- a/launchdarkly-server-sdk/src/feature_requester_builders.rs +++ b/launchdarkly-server-sdk/src/feature_requester_builders.rs @@ -1,5 +1,5 @@ use crate::feature_requester::{FeatureRequester, HttpFeatureRequester}; -use crate::LAUNCHDARKLY_TAGS_HEADER; +use crate::{LAUNCHDARKLY_INSTANCE_ID_HEADER, LAUNCHDARKLY_TAGS_HEADER}; use http::Uri; use launchdarkly_sdk_transport::HttpTransport; use std::collections::HashMap; @@ -27,15 +27,17 @@ pub trait FeatureRequesterFactory: Send { pub struct HttpFeatureRequesterBuilder { url: String, sdk_key: String, + instance_id: String, transport: T, } impl HttpFeatureRequesterBuilder { - pub fn new(url: &str, sdk_key: &str, transport: T) -> Self { + pub fn new(url: &str, sdk_key: &str, instance_id: &str, transport: T) -> Self { Self { transport, url: url.into(), sdk_key: sdk_key.into(), + instance_id: instance_id.into(), } } } @@ -49,6 +51,7 @@ impl FeatureRequesterFactory for HttpFeatureRequesterBuilder = diff --git a/launchdarkly-server-sdk/src/test_data.rs b/launchdarkly-server-sdk/src/test_data.rs index 1d3a37c..060172e 100644 --- a/launchdarkly-server-sdk/src/test_data.rs +++ b/launchdarkly-server-sdk/src/test_data.rs @@ -174,6 +174,7 @@ impl DataSourceFactory for TestData { _endpoints: &service_endpoints::ServiceEndpoints, _sdk_key: &str, _tags: Option, + _instance_id: &str, ) -> Result, BuildError> { Ok(Arc::new(TestDataSource { inner: self.inner.clone(), @@ -240,7 +241,9 @@ mod tests { fn subscribe_store(td: &TestData, store: &Arc>) -> broadcast::Sender<()> { let factory: &dyn DataSourceFactory = td; let endpoints = crate::ServiceEndpointsBuilder::new().build().unwrap(); - let ds = factory.build(&endpoints, "fake-key", None).unwrap(); + let ds = factory + .build(&endpoints, "fake-key", None, "test-instance-id") + .unwrap(); let (shutdown_tx, shutdown_rx) = broadcast::channel(1); ds.subscribe(store.clone(), Arc::new(|_| {}), shutdown_rx); @@ -343,7 +346,9 @@ mod tests { let factory: &dyn DataSourceFactory = &td; let endpoints = crate::ServiceEndpointsBuilder::new().build().unwrap(); - let ds = factory.build(&endpoints, "fake-key", None).unwrap(); + let ds = factory + .build(&endpoints, "fake-key", None, "test-instance-id") + .unwrap(); let initialized = Arc::new(AtomicBool::new(false)); let init_clone = initialized.clone(); @@ -430,7 +435,9 @@ mod tests { let factory: &dyn DataSourceFactory = &td; let endpoints = crate::ServiceEndpointsBuilder::new().build().unwrap(); - let ds = factory.build(&endpoints, "key", None).unwrap(); + let ds = factory + .build(&endpoints, "key", None, "test-instance-id") + .unwrap(); let store = make_store(); let (_tx, rx) = broadcast::channel(1); @@ -447,7 +454,9 @@ mod tests { td.update(FlagBuilder::new("shared-state")); let endpoints = crate::ServiceEndpointsBuilder::new().build().unwrap(); - let ds = owned.build(&endpoints, "key", None).unwrap(); + let ds = owned + .build(&endpoints, "key", None, "test-instance-id") + .unwrap(); let store = make_store(); let (_tx, rx) = broadcast::channel(1); From 3fa8c785a12462ad3940b419974dec50c7ebcff4 Mon Sep 17 00:00:00 2001 From: Matthew Keeler Date: Wed, 13 May 2026 13:17:12 -0400 Subject: [PATCH 2/2] refactor: use setter pattern for instance-id plumbing (SDK-2359) DataSourceFactory::build and EventProcessorFactory::build are public traits; adding an `instance_id` parameter is a breaking change for any external implementor. This commit reverts that and instead adds a `set_instance_id(&mut self, String)` method on each trait with a default no-op implementation, so external implementors are unaffected. LD-owned builders (StreamingDataSourceBuilder, PollingDataSourceBuilder, EventProcessorBuilder) override the setter to store the value, and use it in build() to stamp the `X-LaunchDarkly-Instance-Id` header on outbound requests. Client::build calls `.to_owned().set_instance_id(id)` on each builder before invoking build(). HttpFeatureRequesterBuilder::new also reverted to its prior signature; the instance id is now applied via a fluent with_instance_id() setter. StreamingDataSource::new and the polling feature requester now treat the instance id as Option<&str> and skip the header when absent -- consistent with the no-op default on the trait setter. cargo fmt --check / clippy --all-targets -- -D warnings / cargo test (304 unit + 12 doctests) all pass. --- launchdarkly-server-sdk/src/client.rs | 23 ++++---- launchdarkly-server-sdk/src/data_source.rs | 24 ++++---- .../src/data_source_builders.rs | 55 +++++++++++++------ .../src/events/processor_builders.rs | 34 ++++++++---- .../src/feature_requester_builders.rs | 19 +++++-- launchdarkly-server-sdk/src/test_data.rs | 17 ++---- 6 files changed, 102 insertions(+), 70 deletions(-) diff --git a/launchdarkly-server-sdk/src/client.rs b/launchdarkly-server-sdk/src/client.rs index de7883b..f71ecd8 100644 --- a/launchdarkly-server-sdk/src/client.rs +++ b/launchdarkly-server-sdk/src/client.rs @@ -175,21 +175,18 @@ impl Client { } let tags = config.application_tag(); - let instance_id = config.instance_id(); + let instance_id = config.instance_id().to_string(); let endpoints = config.service_endpoints_builder().build()?; - let event_processor = config.event_processor_builder().build( - &endpoints, - config.sdk_key(), - tags.clone(), - instance_id, - )?; - let data_source = config.data_source_builder().build( - &endpoints, - config.sdk_key(), - tags.clone(), - instance_id, - )?; + + let mut event_processor_builder = config.event_processor_builder().to_owned(); + event_processor_builder.set_instance_id(instance_id.clone()); + let event_processor = + event_processor_builder.build(&endpoints, config.sdk_key(), tags.clone())?; + + let mut data_source_builder = config.data_source_builder().to_owned(); + data_source_builder.set_instance_id(instance_id); + let data_source = data_source_builder.build(&endpoints, config.sdk_key(), tags.clone())?; let data_store = config.data_store_builder().build()?; let events_default = EventsScope { diff --git a/launchdarkly-server-sdk/src/data_source.rs b/launchdarkly-server-sdk/src/data_source.rs index ba582b8..4de3bd7 100644 --- a/launchdarkly-server-sdk/src/data_source.rs +++ b/launchdarkly-server-sdk/src/data_source.rs @@ -75,7 +75,7 @@ impl StreamingDataSource { sdk_key: &str, initial_reconnect_delay: Duration, tags: &Option, - instance_id: &str, + instance_id: Option<&str>, transport: T, ) -> std::result::Result { let stream_url = format!("{base_url}/all"); @@ -90,8 +90,11 @@ impl StreamingDataSource { .build(), ) .header("Authorization", sdk_key)? - .header("User-Agent", &crate::USER_AGENT)? - .header(LAUNCHDARKLY_INSTANCE_ID_HEADER, instance_id)?; + .header("User-Agent", &crate::USER_AGENT)?; + + if let Some(instance_id) = instance_id { + client_builder = client_builder.header(LAUNCHDARKLY_INSTANCE_ID_HEADER, instance_id)?; + } if let Some(tags) = tags { client_builder = client_builder.header(LAUNCHDARKLY_TAGS_HEADER, tags)?; @@ -411,7 +414,7 @@ mod tests { "sdk-key", Duration::from_secs(0), &tag, - "test-instance-id", + None, launchdarkly_sdk_transport::HyperTransport::new() .expect("Failed to create streaming data source"), ) @@ -466,12 +469,7 @@ mod tests { let transport = launchdarkly_sdk_transport::HyperTransport::new() .expect("Failed to create transport for polling data source"); - let hyper_builder = HttpFeatureRequesterBuilder::new( - &server.url(), - "sdk-key", - "test-instance-id", - transport, - ); + let hyper_builder = HttpFeatureRequesterBuilder::new(&server.url(), "sdk-key", transport); let polling = PollingDataSource::new( Arc::new(Mutex::new(Box::new(hyper_builder))), @@ -534,7 +532,7 @@ mod tests { "sdk-key", Duration::from_secs(0), &None, - &instance_id, + Some(&instance_id), launchdarkly_sdk_transport::HyperTransport::new() .expect("Failed to create streaming data source"), ) @@ -591,8 +589,8 @@ mod tests { let transport = launchdarkly_sdk_transport::HyperTransport::new() .expect("Failed to create transport for polling data source"); let instance_id = uuid::Uuid::new_v4().to_string(); - let hyper_builder = - HttpFeatureRequesterBuilder::new(&server.url(), "sdk-key", &instance_id, transport); + let hyper_builder = HttpFeatureRequesterBuilder::new(&server.url(), "sdk-key", transport) + .with_instance_id(&instance_id); let polling = PollingDataSource::new( Arc::new(Mutex::new(Box::new(hyper_builder))), diff --git a/launchdarkly-server-sdk/src/data_source_builders.rs b/launchdarkly-server-sdk/src/data_source_builders.rs index fcd1730..ea8b43b 100644 --- a/launchdarkly-server-sdk/src/data_source_builders.rs +++ b/launchdarkly-server-sdk/src/data_source_builders.rs @@ -28,8 +28,15 @@ pub trait DataSourceFactory { endpoints: &service_endpoints::ServiceEndpoints, sdk_key: &str, tags: Option, - instance_id: &str, ) -> Result, BuildError>; + + /// Sets the per-SDK-instance identifier used to populate the + /// `X-LaunchDarkly-Instance-Id` header on outbound requests. LD-owned builders override + /// this to stamp the header on streaming, polling, and event requests. External + /// implementors of this trait may ignore this — the default no-op is correct unless the + /// implementor constructs HTTP clients that talk to LaunchDarkly's API. + fn set_instance_id(&mut self, _instance_id: String) {} + fn to_owned(&self) -> Box; } @@ -56,6 +63,7 @@ pub trait DataSourceFactory { pub struct StreamingDataSourceBuilder { initial_reconnect_delay: Duration, transport: Option, + instance_id: Option, } impl StreamingDataSourceBuilder { @@ -64,6 +72,7 @@ impl StreamingDataSourceBuilder Self { initial_reconnect_delay: DEFAULT_INITIAL_RECONNECT_DELAY, transport: None, + instance_id: None, } } @@ -91,8 +100,8 @@ impl DataSourceFactory endpoints: &service_endpoints::ServiceEndpoints, sdk_key: &str, tags: Option, - instance_id: &str, ) -> Result, BuildError> { + let instance_id = self.instance_id.as_deref(); let data_source_result = match &self.transport { #[cfg(any( feature = "hyper-rustls-native-roots", @@ -137,6 +146,10 @@ impl DataSourceFactory Ok(Arc::new(data_source)) } + fn set_instance_id(&mut self, instance_id: String) { + self.instance_id = Some(instance_id); + } + fn to_owned(&self) -> Box { Box::new(self.clone()) } @@ -163,7 +176,6 @@ impl DataSourceFactory for NullDataSourceBuilder { _: &service_endpoints::ServiceEndpoints, _: &str, _: Option, - _: &str, ) -> Result, BuildError> { Ok(Arc::new(NullDataSource::new())) } @@ -205,6 +217,7 @@ impl Default for NullDataSourceBuilder { pub struct PollingDataSourceBuilder { poll_interval: Duration, transport: Option, + instance_id: Option, } /// Contains methods for configuring the polling data source. @@ -236,6 +249,7 @@ impl PollingDataSourceBuilder { Self { poll_interval: MINIMUM_POLL_INTERVAL, transport: None, + instance_id: None, } } @@ -264,8 +278,8 @@ impl DataSourceFactory for PollingDataSourceBuilder { endpoints: &service_endpoints::ServiceEndpoints, sdk_key: &str, tags: Option, - instance_id: &str, ) -> Result, BuildError> { + let instance_id = self.instance_id.as_deref(); let feature_requester_builder: Result, BuildError> = match &self.transport { #[cfg(any( @@ -280,13 +294,15 @@ impl DataSourceFactory for PollingDataSourceBuilder { "failed to create default https transport: {e:?}" )) })?; - - Ok(Box::new(HttpFeatureRequesterBuilder::new( + let mut builder = HttpFeatureRequesterBuilder::new( endpoints.polling_base_url(), sdk_key, - instance_id, transport, - ))) + ); + if let Some(instance_id) = instance_id { + builder = builder.with_instance_id(instance_id); + } + Ok(Box::new(builder)) } #[cfg(not(any( feature = "hyper-rustls-native-roots", @@ -296,12 +312,17 @@ impl DataSourceFactory for PollingDataSourceBuilder { None => Err(BuildError::InvalidConfig( "transport is required when hyper-rustls-native-roots, hyper-rustls-webpki-roots, or native-tls features are disabled".into(), )), - Some(transport) => Ok(Box::new(HttpFeatureRequesterBuilder::new( - endpoints.polling_base_url(), - sdk_key, - instance_id, - transport.clone(), - ))), + Some(transport) => { + let mut builder = HttpFeatureRequesterBuilder::new( + endpoints.polling_base_url(), + sdk_key, + transport.clone(), + ); + if let Some(instance_id) = instance_id { + builder = builder.with_instance_id(instance_id); + } + Ok(Box::new(builder)) + } }; let feature_requester_factory: Arc>> = @@ -312,6 +333,10 @@ impl DataSourceFactory for PollingDataSourceBuilder { Ok(Arc::new(data_source)) } + fn set_instance_id(&mut self, instance_id: String) { + self.instance_id = Some(instance_id); + } + fn to_owned(&self) -> Box { Box::new(self.clone()) } @@ -352,7 +377,6 @@ impl DataSourceFactory for MockDataSourceBuilder { _endpoints: &service_endpoints::ServiceEndpoints, _sdk_key: &str, _tags: Option, - _instance_id: &str, ) -> Result, BuildError> { Ok(self.data_source.as_ref().unwrap().clone()) } @@ -399,7 +423,6 @@ mod tests { &crate::ServiceEndpointsBuilder::new().build().unwrap(), "test", None, - "test-instance-id", ) .is_ok()); } diff --git a/launchdarkly-server-sdk/src/events/processor_builders.rs b/launchdarkly-server-sdk/src/events/processor_builders.rs index 3ecfb3f..52494b2 100644 --- a/launchdarkly-server-sdk/src/events/processor_builders.rs +++ b/launchdarkly-server-sdk/src/events/processor_builders.rs @@ -46,8 +46,15 @@ pub trait EventProcessorFactory { endpoints: &service_endpoints::ServiceEndpoints, sdk_key: &str, tags: Option, - instance_id: &str, ) -> Result, BuildError>; + + /// Sets the per-SDK-instance identifier used to populate the + /// `X-LaunchDarkly-Instance-Id` header on outbound requests. LD-owned builders override + /// this to stamp the header on event posts. External implementors of this trait may + /// ignore this — the default no-op is correct unless the implementor constructs HTTP + /// clients that talk to LaunchDarkly's API. + fn set_instance_id(&mut self, _instance_id: String) {} + fn to_owned(&self) -> Box; } @@ -81,6 +88,7 @@ pub struct EventProcessorBuilder, omit_anonymous_contexts: bool, compress_events: bool, + instance_id: Option, // diagnostic_recording_interval: Duration } @@ -90,7 +98,6 @@ impl EventProcessorFactory for EventProcessorBuilder { endpoints: &service_endpoints::ServiceEndpoints, sdk_key: &str, tags: Option, - instance_id: &str, ) -> Result, BuildError> { let url_string = format!("{}/bulk", endpoints.events_base_url()); @@ -99,7 +106,9 @@ impl EventProcessorFactory for EventProcessorBuilder { if let Some(tags) = tags { default_headers.insert(LAUNCHDARKLY_TAGS_HEADER, tags); } - default_headers.insert(LAUNCHDARKLY_INSTANCE_ID_HEADER, instance_id.to_string()); + if let Some(instance_id) = &self.instance_id { + default_headers.insert(LAUNCHDARKLY_INSTANCE_ID_HEADER, instance_id.clone()); + } let event_sender_result: Result, BuildError> = // NOTE: This would only be possible under unit testing conditions. @@ -162,6 +171,10 @@ impl EventProcessorFactory for EventProcessorBuilder { Ok(Arc::new(events_processor)) } + fn set_instance_id(&mut self, instance_id: String) { + self.instance_id = Some(instance_id); + } + fn to_owned(&self) -> Box { Box::new(self.clone()) } @@ -181,6 +194,7 @@ impl EventProcessorBuilder { private_attributes: HashSet::new(), omit_anonymous_contexts: false, transport: None, + instance_id: None, #[cfg(feature = "event-compression")] compress_events: true, #[cfg(not(feature = "event-compression"))] @@ -301,7 +315,6 @@ impl EventProcessorFactory for NullEventProcessorBuilder { _: &service_endpoints::ServiceEndpoints, _: &str, _: Option, - _: &str, ) -> Result, BuildError> { Ok(Arc::new(NullEventProcessor::new())) } @@ -423,7 +436,7 @@ mod tests { let builder = EventProcessorBuilder::::new(); let processor = builder - .build(&service_endpoints, "sdk-key", tag, "test-instance-id") + .build(&service_endpoints, "sdk-key", tag) .expect("Processor failed to build"); let event_factory = EventFactory::new(false); @@ -439,9 +452,8 @@ mod tests { mock.assert() } - // Verifies that event POSTs carry the X-LaunchDarkly-Instance-Id header. The Go reference - // attaches the per-instance GUID at the default-headers layer so streaming, polling, and - // events all inherit it; this is the events-channel half of that contract. + // Verifies that event POSTs carry the X-LaunchDarkly-Instance-Id header when one has been + // injected via the trait's set_instance_id setter (the path Client::build uses). #[cfg(any( feature = "hyper-rustls-native-roots", feature = "hyper-rustls-webpki-roots", @@ -465,9 +477,11 @@ mod tests { .build() .expect("Service endpoints failed to be created"); - let builder = EventProcessorBuilder::::new(); + let mut builder = + EventProcessorBuilder::::new(); + builder.set_instance_id(instance_id.clone()); let processor = builder - .build(&service_endpoints, "sdk-key", None, &instance_id) + .build(&service_endpoints, "sdk-key", None) .expect("Processor failed to build"); let event_factory = EventFactory::new(false); diff --git a/launchdarkly-server-sdk/src/feature_requester_builders.rs b/launchdarkly-server-sdk/src/feature_requester_builders.rs index 3bb04c2..0b780c6 100644 --- a/launchdarkly-server-sdk/src/feature_requester_builders.rs +++ b/launchdarkly-server-sdk/src/feature_requester_builders.rs @@ -27,19 +27,27 @@ pub trait FeatureRequesterFactory: Send { pub struct HttpFeatureRequesterBuilder { url: String, sdk_key: String, - instance_id: String, + instance_id: Option, transport: T, } impl HttpFeatureRequesterBuilder { - pub fn new(url: &str, sdk_key: &str, instance_id: &str, transport: T) -> Self { + pub fn new(url: &str, sdk_key: &str, transport: T) -> Self { Self { transport, url: url.into(), sdk_key: sdk_key.into(), - instance_id: instance_id.into(), + instance_id: None, } } + + /// Sets the per-SDK-instance identifier that will be sent on outbound polling requests as + /// the `X-LaunchDarkly-Instance-Id` header. This is set by the SDK at client construction + /// time and is not part of the public API surface of the polling data source. + pub fn with_instance_id(mut self, instance_id: &str) -> Self { + self.instance_id = Some(instance_id.into()); + self + } } impl FeatureRequesterFactory for HttpFeatureRequesterBuilder { @@ -51,7 +59,9 @@ impl FeatureRequesterFactory for HttpFeatureRequesterBuilder, - _instance_id: &str, ) -> Result, BuildError> { Ok(Arc::new(TestDataSource { inner: self.inner.clone(), @@ -241,9 +240,7 @@ mod tests { fn subscribe_store(td: &TestData, store: &Arc>) -> broadcast::Sender<()> { let factory: &dyn DataSourceFactory = td; let endpoints = crate::ServiceEndpointsBuilder::new().build().unwrap(); - let ds = factory - .build(&endpoints, "fake-key", None, "test-instance-id") - .unwrap(); + let ds = factory.build(&endpoints, "fake-key", None).unwrap(); let (shutdown_tx, shutdown_rx) = broadcast::channel(1); ds.subscribe(store.clone(), Arc::new(|_| {}), shutdown_rx); @@ -346,9 +343,7 @@ mod tests { let factory: &dyn DataSourceFactory = &td; let endpoints = crate::ServiceEndpointsBuilder::new().build().unwrap(); - let ds = factory - .build(&endpoints, "fake-key", None, "test-instance-id") - .unwrap(); + let ds = factory.build(&endpoints, "fake-key", None).unwrap(); let initialized = Arc::new(AtomicBool::new(false)); let init_clone = initialized.clone(); @@ -435,9 +430,7 @@ mod tests { let factory: &dyn DataSourceFactory = &td; let endpoints = crate::ServiceEndpointsBuilder::new().build().unwrap(); - let ds = factory - .build(&endpoints, "key", None, "test-instance-id") - .unwrap(); + let ds = factory.build(&endpoints, "key", None).unwrap(); let store = make_store(); let (_tx, rx) = broadcast::channel(1); @@ -454,9 +447,7 @@ mod tests { td.update(FlagBuilder::new("shared-state")); let endpoints = crate::ServiceEndpointsBuilder::new().build().unwrap(); - let ds = owned - .build(&endpoints, "key", None, "test-instance-id") - .unwrap(); + let ds = owned.build(&endpoints, "key", None).unwrap(); let store = make_store(); let (_tx, rx) = broadcast::channel(1);