diff --git a/contract-tests/server-contract-tests/src/main.cpp b/contract-tests/server-contract-tests/src/main.cpp index e017a8634..d81d12081 100644 --- a/contract-tests/server-contract-tests/src/main.cpp +++ b/contract-tests/server-contract-tests/src/main.cpp @@ -51,6 +51,7 @@ int main(int argc, char* argv[]) { srv.add_capability("evaluation-hooks"); srv.add_capability("track-hooks"); srv.add_capability("wrapper"); + srv.add_capability("instance-id"); net::signal_set signals{ioc, SIGINT, SIGTERM}; diff --git a/libs/server-sdk/src/CMakeLists.txt b/libs/server-sdk/src/CMakeLists.txt index 62a017d41..f7b9255c4 100644 --- a/libs/server-sdk/src/CMakeLists.txt +++ b/libs/server-sdk/src/CMakeLists.txt @@ -20,6 +20,8 @@ target_sources(${LIBNAME} client.cpp client_impl.cpp data_source_status.cpp + instance_id.hpp + instance_id.cpp config/config.cpp config/config_builder.cpp config/builders/data_system/background_sync_builder.cpp diff --git a/libs/server-sdk/src/client_impl.cpp b/libs/server-sdk/src/client_impl.cpp index 0c53440d7..9910dd23c 100644 --- a/libs/server-sdk/src/client_impl.cpp +++ b/libs/server-sdk/src/client_impl.cpp @@ -5,6 +5,7 @@ #include "data_systems/lazy_load/lazy_load_system.hpp" #include "data_systems/offline.hpp" #include "evaluation/evaluation_stack.hpp" +#include "instance_id.hpp" #include "prereq_event_recorder/prereq_event_recorder.hpp" #include "data_interfaces/system/idata_system.hpp" @@ -119,6 +120,13 @@ ClientImpl::ClientImpl(Config config, std::string const& version) .Header("user-agent", "CPPServer/" + version) .Header("authorization", config.SdkKey()) .Header("x-launchdarkly-tags", config.ApplicationTag()) + // Per SCMP-server-connection-minutes-polling, every polling + // request must carry a per-instance GUID v4. We attach it to the + // shared HTTP properties so it's also present on streaming and + // event requests, and we generate it here (once during + // ClientImpl construction) so it remains stable for the lifetime + // of the SDK instance. + .Header(kInstanceIdHeader, MakeInstanceId()) .Build()), logger_(MakeLogger(config.Logging())), ioc_(kAsioConcurrencyHint), diff --git a/libs/server-sdk/src/instance_id.cpp b/libs/server-sdk/src/instance_id.cpp new file mode 100644 index 000000000..e0df67bf5 --- /dev/null +++ b/libs/server-sdk/src/instance_id.cpp @@ -0,0 +1,18 @@ +#include "instance_id.hpp" + +#include +#include +#include + +namespace launchdarkly::server_side { + +std::string MakeInstanceId() { + // boost::uuids::random_generator emits a version 4 (random) UUID, which is + // what the SCMP spec requires. The generator carries state (an internal + // RNG), so constructing it on every call is fine for our use case where we + // only call MakeInstanceId once per SDK instance. + static thread_local boost::uuids::random_generator generator; + return boost::uuids::to_string(generator()); +} + +} // namespace launchdarkly::server_side diff --git a/libs/server-sdk/src/instance_id.hpp b/libs/server-sdk/src/instance_id.hpp new file mode 100644 index 000000000..a7a194314 --- /dev/null +++ b/libs/server-sdk/src/instance_id.hpp @@ -0,0 +1,28 @@ +#pragma once + +#include + +namespace launchdarkly::server_side { + +/** + * Name of the HTTP header used to identify this SDK instance for the purpose of + * estimating server-connection-minutes when polling. The value is a v4 UUID + * that is generated once per SDK instance and remains constant for the + * lifetime of the client. + * + * See: sdk-specs / SCMP-server-connection-minutes-polling. + */ +inline constexpr char const* kInstanceIdHeader = "X-LaunchDarkly-Instance-Id"; + +/** + * Generate a fresh v4 UUID suitable for use as the value of the + * X-LaunchDarkly-Instance-Id header. Each call returns a new identifier; + * callers are expected to generate the value exactly once per SDK instance + * and reuse it for the lifetime of that instance. + * + * @return A string formatted as a lowercase v4 UUID, e.g. + * "550e8400-e29b-41d4-a716-446655440000". + */ +[[nodiscard]] std::string MakeInstanceId(); + +} // namespace launchdarkly::server_side diff --git a/libs/server-sdk/tests/instance_id_test.cpp b/libs/server-sdk/tests/instance_id_test.cpp new file mode 100644 index 000000000..f3fb8df7c --- /dev/null +++ b/libs/server-sdk/tests/instance_id_test.cpp @@ -0,0 +1,75 @@ +#include + +#include "instance_id.hpp" + +#include +#include + +#include +#include +#include +#include + +using namespace launchdarkly; +using namespace launchdarkly::server_side; + +namespace { + +// Matches a canonical UUID v4 in lowercase hex: +// xxxxxxxx-xxxx-4xxx-Yxxx-xxxxxxxxxxxx +// where 'Y' is one of 8, 9, a, or b (RFC 4122 variant). +bool IsUuidV4(std::string const& s) { + static std::regex const re( + "^[0-9a-f]{8}-" + "[0-9a-f]{4}-" + "4[0-9a-f]{3}-" + "[89ab][0-9a-f]{3}-" + "[0-9a-f]{12}$"); + return std::regex_match(s, re); +} + +} // namespace + +// Spec: SCMP-server-connection-minutes-polling section 1.1 requires the +// X-LaunchDarkly-Instance-Id value to be a v4 UUID. +TEST(InstanceIdTest, GeneratedValueIsUuidV4) { + auto id = MakeInstanceId(); + ASSERT_FALSE(id.empty()) << "MakeInstanceId returned an empty string"; + EXPECT_TRUE(IsUuidV4(id)) + << "MakeInstanceId returned " << id << " which is not a v4 UUID"; +} + +// Each invocation must yield a different value; spec requires "the GUID MUST +// be used uniquely for this purpose". +TEST(InstanceIdTest, GeneratedValuesAreUnique) { + constexpr int kSamples = 100; + std::set seen; + for (int i = 0; i < kSamples; ++i) { + auto id = MakeInstanceId(); + ASSERT_FALSE(id.empty()); + EXPECT_TRUE(seen.insert(id).second) + << "duplicate UUID emitted from MakeInstanceId: " << id; + } + EXPECT_EQ(seen.size(), static_cast(kSamples)); +} + +// The header name constant must match the spec verbatim. This guards against +// accidental renaming (the header name is part of the wire contract). +TEST(InstanceIdTest, HeaderNameMatchesSpec) { + EXPECT_STREQ(kInstanceIdHeader, "X-LaunchDarkly-Instance-Id"); +} + +// Sanity-check that a Client can be constructed; the integration that the +// instance-id header actually ends up on outbound requests is covered by the +// cross-SDK contract test harness (capability: "instance-id"). +TEST(InstanceIdTest, ClientConstructsWithInstanceIdHeader) { + // Building a Client exercises the code path that stamps the instance-id + // header into the shared HttpProperties. We can't observe the header + // directly from the public API, so this test simply asserts that the + // construction succeeds; the spec-level guarantees about the header value + // are exercised by GeneratedValueIsUuidV4 and GeneratedValuesAreUnique, + // and the on-the-wire guarantee is covered by the cross-SDK contract test + // harness (capability: "instance-id"). + Client client(ConfigBuilder("sdk-123").Build().value()); + EXPECT_NE(client.Version(), nullptr); +}