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..f71ecd8 100644 --- a/launchdarkly-server-sdk/src/client.rs +++ b/launchdarkly-server-sdk/src/client.rs @@ -175,16 +175,18 @@ impl Client { } let tags = config.application_tag(); + let instance_id = config.instance_id().to_string(); let endpoints = config.service_endpoints_builder().build()?; + + let mut event_processor_builder = config.event_processor_builder().to_owned(); + event_processor_builder.set_instance_id(instance_id.clone()); 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())?; + 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/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..4de3bd7 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: Option<&str>, transport: T, ) -> std::result::Result { let stream_url = format!("{base_url}/all"); @@ -91,6 +92,10 @@ impl StreamingDataSource { .header("Authorization", sdk_key)? .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)?; } @@ -374,7 +379,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 +414,7 @@ mod tests { "sdk-key", Duration::from_secs(0), &tag, + None, launchdarkly_sdk_transport::HyperTransport::new() .expect("Failed to create streaming data source"), ) @@ -490,4 +504,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, + Some(&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", transport) + .with_instance_id(&instance_id); + + 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..ea8b43b 100644 --- a/launchdarkly-server-sdk/src/data_source_builders.rs +++ b/launchdarkly-server-sdk/src/data_source_builders.rs @@ -29,6 +29,14 @@ pub trait DataSourceFactory { sdk_key: &str, tags: Option, ) -> 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; } @@ -55,6 +63,7 @@ pub trait DataSourceFactory { pub struct StreamingDataSourceBuilder { initial_reconnect_delay: Duration, transport: Option, + instance_id: Option, } impl StreamingDataSourceBuilder { @@ -63,6 +72,7 @@ impl StreamingDataSourceBuilder Self { initial_reconnect_delay: DEFAULT_INITIAL_RECONNECT_DELAY, transport: None, + instance_id: None, } } @@ -91,6 +101,7 @@ impl DataSourceFactory sdk_key: &str, tags: Option, ) -> Result, BuildError> { + let instance_id = self.instance_id.as_deref(); let data_source_result = match &self.transport { #[cfg(any( feature = "hyper-rustls-native-roots", @@ -109,6 +120,7 @@ impl DataSourceFactory sdk_key, self.initial_reconnect_delay, &tags, + instance_id, transport, )) } @@ -125,6 +137,7 @@ impl DataSourceFactory sdk_key, self.initial_reconnect_delay, &tags, + instance_id, transport.clone(), )), }; @@ -133,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()) } @@ -200,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. @@ -231,6 +249,7 @@ impl PollingDataSourceBuilder { Self { poll_interval: MINIMUM_POLL_INTERVAL, transport: None, + instance_id: None, } } @@ -260,6 +279,7 @@ impl DataSourceFactory for PollingDataSourceBuilder { sdk_key: &str, tags: Option, ) -> Result, BuildError> { + let instance_id = self.instance_id.as_deref(); let feature_requester_builder: Result, BuildError> = match &self.transport { #[cfg(any( @@ -274,12 +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, 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", @@ -289,11 +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, - 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>> = @@ -304,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()) } @@ -389,7 +422,7 @@ mod tests { .build( &crate::ServiceEndpointsBuilder::new().build().unwrap(), "test", - None + None, ) .is_ok()); } diff --git a/launchdarkly-server-sdk/src/events/processor_builders.rs b/launchdarkly-server-sdk/src/events/processor_builders.rs index 5a2440a..52494b2 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::{ @@ -47,6 +47,14 @@ pub trait EventProcessorFactory { sdk_key: &str, tags: Option, ) -> 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; } @@ -80,6 +88,7 @@ pub struct EventProcessorBuilder, omit_anonymous_contexts: bool, compress_events: bool, + instance_id: Option, // diagnostic_recording_interval: Duration } @@ -97,6 +106,9 @@ impl EventProcessorFactory for EventProcessorBuilder { if let Some(tags) = tags { default_headers.insert(LAUNCHDARKLY_TAGS_HEADER, tags); } + 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. @@ -159,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()) } @@ -178,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"))] @@ -434,4 +451,49 @@ mod tests { mock.assert() } + + // 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", + 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 mut builder = + EventProcessorBuilder::::new(); + builder.set_instance_id(instance_id.clone()); + let processor = builder + .build(&service_endpoints, "sdk-key", None) + .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() + } } diff --git a/launchdarkly-server-sdk/src/feature_requester_builders.rs b/launchdarkly-server-sdk/src/feature_requester_builders.rs index 3c29f40..0b780c6 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,6 +27,7 @@ pub trait FeatureRequesterFactory: Send { pub struct HttpFeatureRequesterBuilder { url: String, sdk_key: String, + instance_id: Option, transport: T, } @@ -36,8 +37,17 @@ impl HttpFeatureRequesterBuilder { transport, url: url.into(), sdk_key: sdk_key.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 { @@ -49,6 +59,9 @@ impl FeatureRequesterFactory for HttpFeatureRequesterBuilder =