diff --git a/src/ldclient_config.erl b/src/ldclient_config.erl index 4a13de4..d66247e 100644 --- a/src/ldclient_config.erl +++ b/src/ldclient_config.erl @@ -84,7 +84,8 @@ datasource => poll | stream | file | testdata | undefined, http_options => http_options(), stream_initial_retry_delay_ms => non_neg_integer(), - application => app_info() + application => app_info(), + instance_id => binary() }. % Settings stored for each running SDK instance @@ -203,6 +204,14 @@ parse_options(SdkKey, Options) when is_list(SdkKey), is_map(Options) -> HttpOptions = parse_http_options(maps:get(http_options, Options, undefined)), AppInfo = parse_application_info(maps:get(application, Options, ?APPLICATION_DEFAULT_OPTIONS)), RedisTls = maps:get(redis_tls, Options, ?DEFAULT_REDIS_TLS), + %% Per SCMP-server-connection-minutes-polling, each SDK instance gets a + %% stable v4 UUID that is sent as the X-LaunchDarkly-Instance-Id header on + %% every outbound request (polling, streaming, and events). It is + %% generated once here in parse_options/2, which is called exactly once + %% per ldclient_instance:start/3, and then stored in the per-instance + %% settings so ldclient_headers can pick it up alongside the other + %% default headers. + InstanceId = uuid:uuid_to_string(uuid:get_v4(), binary_standard), #{ sdk_key => SdkKey, base_uri => BaseUri, @@ -237,7 +246,8 @@ parse_options(SdkKey, Options) when is_list(SdkKey), is_map(Options) -> testdata_tag => TestDataTag, datasource => DataSource, stream_initial_retry_delay_ms => StreamInitialRetryDelayMs, - application => AppInfo + application => AppInfo, + instance_id => InstanceId }. %% @doc Get all registered tags diff --git a/src/ldclient_headers.erl b/src/ldclient_headers.erl index 142f17f..e18d45c 100644 --- a/src/ldclient_headers.erl +++ b/src/ldclient_headers.erl @@ -42,7 +42,13 @@ get_default_headers(Tag, _Format = string_pairs) -> get_default_headers(Tag) -> with_tags(Tag, #{ <<"authorization">> => list_to_binary(ldclient_config:get_value(Tag, sdk_key)), - <<"user-agent">> => list_to_binary(ldclient_config:get_user_agent()) + <<"user-agent">> => list_to_binary(ldclient_config:get_user_agent()), + %% Per SCMP-server-connection-minutes-polling, every outbound request + %% carries a per-instance v4 UUID. It is generated once per SDK + %% instance in ldclient_config:parse_options/2 and stored under the + %% instance_id key in the instance settings, so this lookup returns + %% the same value for the lifetime of the instance. + <<"x-launchdarkly-instance-id">> => ldclient_config:get_value(Tag, instance_id) }). %% @doc Append the tags header to the given map. diff --git a/test-service/src/ts_service_request_handler.erl b/test-service/src/ts_service_request_handler.erl index 7404923..c3064ef 100644 --- a/test-service/src/ts_service_request_handler.erl +++ b/test-service/src/ts_service_request_handler.erl @@ -80,7 +80,8 @@ get_service_detail(Req, State) -> <<"tls:skip-verify-peer">>, <<"tls:verify-peer">>, <<"client-prereq-events">>, - <<"all-flags-client-side-only">> + <<"all-flags-client-side-only">>, + <<"instance-id">> ], <<"clientVersion">> => ldclient_config:get_version() }), diff --git a/test/ldclient_headers_SUITE.erl b/test/ldclient_headers_SUITE.erl index ccd9241..68e2fa5 100644 --- a/test/ldclient_headers_SUITE.erl +++ b/test/ldclient_headers_SUITE.erl @@ -13,7 +13,11 @@ -export([ can_sort_tags/1, can_combine_tags/1, - can_get_default_headers/1 + can_get_default_headers/1, + instance_id_header_is_uuid_v4/1, + instance_id_header_is_stable_across_calls/1, + instance_id_header_differs_between_instances/1, + instance_id_header_in_string_pairs_format/1 ]). %%==================================================================== @@ -24,7 +28,11 @@ all() -> [ can_sort_tags, can_combine_tags, - can_get_default_headers + can_get_default_headers, + instance_id_header_is_uuid_v4, + instance_id_header_is_stable_across_calls, + instance_id_header_differs_between_instances, + instance_id_header_in_string_pairs_format ]. init_per_suite(Config) -> @@ -135,3 +143,75 @@ can_get_default_headers(_) -> } = ldclient_headers:get_default_headers(empty_application), application:stop(ldclient). + +instance_id_header_is_uuid_v4(_) -> + %% Every call to get_default_headers must produce an X-LaunchDarkly-Instance-Id header + %% containing a parseable v4 UUID. This is the spec contract for + %% SCMP-server-connection-minutes-polling section 1.1. + {ok, _} = application:ensure_all_started(ldclient), + ldclient:start_instance("an-sdk-key", instance_id_v4, #{ + stream => false, + polling_update_requestor => ldclient_update_requestor_test + }), + Headers = ldclient_headers:get_default_headers(instance_id_v4), + InstanceId = maps:get(<<"x-launchdarkly-instance-id">>, Headers), + true = is_binary(InstanceId), + %% A standard UUID string is 36 characters: 8-4-4-4-12 hex with dashes. + 36 = byte_size(InstanceId), + %% uuid:is_v4/1 takes the binary uuid representation. Parse the string + %% form back to that representation before validating the version bits. + Parsed = uuid:string_to_uuid(binary_to_list(InstanceId)), + true = uuid:is_v4(Parsed), + application:stop(ldclient). + +instance_id_header_is_stable_across_calls(_) -> + %% The GUID must remain constant throughout the lifetime of the SDK + %% instance, so two successive calls to get_default_headers for the same + %% tag must yield the same value. + {ok, _} = application:ensure_all_started(ldclient), + ldclient:start_instance("an-sdk-key", instance_id_stable, #{ + stream => false, + polling_update_requestor => ldclient_update_requestor_test + }), + H1 = ldclient_headers:get_default_headers(instance_id_stable), + H2 = ldclient_headers:get_default_headers(instance_id_stable), + Id1 = maps:get(<<"x-launchdarkly-instance-id">>, H1), + Id2 = maps:get(<<"x-launchdarkly-instance-id">>, H2), + Id1 = Id2, + application:stop(ldclient). + +instance_id_header_differs_between_instances(_) -> + %% Different SDK instances must get different GUIDs. + {ok, _} = application:ensure_all_started(ldclient), + ldclient:start_instance("an-sdk-key", instance_id_a, #{ + stream => false, + polling_update_requestor => ldclient_update_requestor_test + }), + ldclient:start_instance("an-sdk-key", instance_id_b, #{ + stream => false, + polling_update_requestor => ldclient_update_requestor_test + }), + IdA = maps:get(<<"x-launchdarkly-instance-id">>, + ldclient_headers:get_default_headers(instance_id_a)), + IdB = maps:get(<<"x-launchdarkly-instance-id">>, + ldclient_headers:get_default_headers(instance_id_b)), + true = IdA =/= IdB, + application:stop(ldclient). + +instance_id_header_in_string_pairs_format(_) -> + %% The string_pairs format is used by httpc-based clients (polling and + %% events). Confirm the instance id header survives the binary->string + %% conversion and the value is still a 36-character UUID string. + {ok, _} = application:ensure_all_started(ldclient), + ldclient:start_instance("an-sdk-key", instance_id_string_pairs, #{ + stream => false, + polling_update_requestor => ldclient_update_requestor_test + }), + Pairs = ldclient_headers:get_default_headers(instance_id_string_pairs, string_pairs), + {value, {_, InstanceIdStr}} = lists:search( + fun({K, _V}) -> K =:= "x-launchdarkly-instance-id" end, + Pairs), + true = is_list(InstanceIdStr), + 36 = length(InstanceIdStr), + true = uuid:is_v4(uuid:string_to_uuid(InstanceIdStr)), + application:stop(ldclient). diff --git a/test/ldclient_update_requestor_httpc_SUITE.erl b/test/ldclient_update_requestor_httpc_SUITE.erl index 0ca73b6..6cff9f9 100644 --- a/test/ldclient_update_requestor_httpc_SUITE.erl +++ b/test/ldclient_update_requestor_httpc_SUITE.erl @@ -14,6 +14,9 @@ authorization_header_set_on_request/1, user_agent_header_set_on_request/1, event_schema_set_on_request/1, + instance_id_header_set_on_request/1, + instance_id_header_stable_across_requests/1, + instance_id_header_differs_between_instances/1, none_match_is_not_set_with_empty_state/1, none_match_is_set_with_state/1, etag_response_recorded/1, @@ -30,6 +33,9 @@ all() -> authorization_header_set_on_request, user_agent_header_set_on_request, event_schema_set_on_request, + instance_id_header_set_on_request, + instance_id_header_stable_across_requests, + instance_id_header_differs_between_instances, none_match_is_not_set_with_empty_state, none_match_is_set_with_state, etag_response_recorded, @@ -157,3 +163,46 @@ custom_headers_appended(_Config) -> %% The custom headers are there as well. "String" = bookish_spork_request:header(Request, "basic-string-header"), "Binary" = bookish_spork_request:header(Request, "binary-string-header"). + +%% End-to-end check that polling requests carry the spec-required +%% X-LaunchDarkly-Instance-Id header and that the value is a v4 UUID. +instance_id_header_set_on_request(_Config) -> + State = ldclient_update_requestor_httpc:init(default, "sdk-key"), + bookish_spork:stub_request([200, #{}, <<>>]), + {{ok, <<>>}, _} = ldclient_update_requestor_httpc:all(?MOCK_URI, State), + {ok, Request} = bookish_spork:capture_request(), + InstanceId = bookish_spork_request:header(Request, "x-launchdarkly-instance-id"), + true = is_list(InstanceId), + 36 = length(InstanceId), + true = uuid:is_v4(uuid:string_to_uuid(InstanceId)). + +%% The instance id must remain constant across multiple polling requests +%% for the same SDK instance. +instance_id_header_stable_across_requests(_Config) -> + State0 = ldclient_update_requestor_httpc:init(default, "sdk-key"), + bookish_spork:stub_request([200, #{}, <<>>]), + {{ok, <<>>}, State1} = ldclient_update_requestor_httpc:all(?MOCK_URI, State0), + {ok, Req1} = bookish_spork:capture_request(), + bookish_spork:stub_request([200, #{}, <<>>]), + {{ok, <<>>}, _} = ldclient_update_requestor_httpc:all(?MOCK_URI, State1), + {ok, Req2} = bookish_spork:capture_request(), + Id1 = bookish_spork_request:header(Req1, "x-launchdarkly-instance-id"), + Id2 = bookish_spork_request:header(Req2, "x-launchdarkly-instance-id"), + Id1 = Id2. + +%% Different SDK instances (different tags) must produce different +%% instance ids on their polling requests. +instance_id_header_differs_between_instances(_Config) -> + OtherSettings = ldclient_config:parse_options("sdk-key", #{}), + ok = ldclient_config:register(other_instance, OtherSettings), + StateA = ldclient_update_requestor_httpc:init(default, "sdk-key"), + StateB = ldclient_update_requestor_httpc:init(other_instance, "sdk-key"), + bookish_spork:stub_request([200, #{}, <<>>]), + {{ok, <<>>}, _} = ldclient_update_requestor_httpc:all(?MOCK_URI, StateA), + {ok, ReqA} = bookish_spork:capture_request(), + bookish_spork:stub_request([200, #{}, <<>>]), + {{ok, <<>>}, _} = ldclient_update_requestor_httpc:all(?MOCK_URI, StateB), + {ok, ReqB} = bookish_spork:capture_request(), + IdA = bookish_spork_request:header(ReqA, "x-launchdarkly-instance-id"), + IdB = bookish_spork_request:header(ReqB, "x-launchdarkly-instance-id"), + true = IdA =/= IdB.