diff --git a/docs/service_spec.md b/docs/service_spec.md index f3ce7b0..10fc0f3 100644 --- a/docs/service_spec.md +++ b/docs/service_spec.md @@ -60,6 +60,14 @@ This means that the SDK supports Big Segments and can be configured with a custo For tests that involve Big Segments, the test harness will provide parameters in the `bigSegments` property of the configuration object, including a `callbackUri` that points to one of the test harness's callback services (see [Callback endpoints](#callback-endpoints)). The test service should configure the SDK with its own implementation of a Big Segment store, where every method of the store delegates to a corresponding endpoint in the callback service. +#### Capability `"client-prereq-events"` + +This means that the SDK supports client-side prerequisite events. + +In short, this means that a client SDK emits prerequisite evaluation events along side the main evaluation event. This requires an updated understanding of the flag evaluation model. + +For server side SDKs, this means `allFlagData` will reflect that updated flag evaluation model. + #### Capability `"context-type"` This means that the SDK has its own type for evaluation contexts (as opposed to just representing them as a JSON-equivalent generic data structure) and convert that type to and from JSON. diff --git a/mockld/sdk_data.go b/mockld/sdk_data.go index d3e7488..fe60bd5 100644 --- a/mockld/sdk_data.go +++ b/mockld/sdk_data.go @@ -78,6 +78,7 @@ type ClientSDKFlag struct { TrackEvents bool `json:"trackEvents"` TrackReason bool `json:"trackReason"` DebugEventsUntilDate o.Maybe[ldtime.UnixMillisecondTime] `json:"debugEventsUntilDate"` + Prerequisites []string `json:"prerequisites"` } // ClientSDKFlagWithKey is used only in stream updates, where the key is within the same object. diff --git a/sdktests/client_side_events_all.go b/sdktests/client_side_events_all.go index 46ac0ce..94c6573 100644 --- a/sdktests/client_side_events_all.go +++ b/sdktests/client_side_events_all.go @@ -22,6 +22,9 @@ func doClientSideEventTests(t *ldtest.T) { t.Run("context properties", doClientSideEventContextTests) t.Run("event capacity", doClientSideEventBufferTests) t.Run("disabling", doClientSideEventDisableTests) + + t.RequireCapability(servicedef.CapabilityClientPrereqEvents) + t.Run("prerequisite events emit in order", doClientSideInOrderPrereqEventTests) } func doClientSideEventRequestTests(t *ldtest.T) { diff --git a/sdktests/client_side_events_eval.go b/sdktests/client_side_events_eval.go index 5a5bd8a..8baf8dd 100644 --- a/sdktests/client_side_events_eval.go +++ b/sdktests/client_side_events_eval.go @@ -6,6 +6,7 @@ import ( "github.com/launchdarkly/sdk-test-harness/v2/data" h "github.com/launchdarkly/sdk-test-harness/v2/framework/helpers" "github.com/launchdarkly/sdk-test-harness/v2/framework/ldtest" + o "github.com/launchdarkly/sdk-test-harness/v2/framework/opt" "github.com/launchdarkly/sdk-test-harness/v2/mockld" "github.com/launchdarkly/sdk-test-harness/v2/servicedef" @@ -253,6 +254,79 @@ func doClientSideFeatureEventTests(t *ldtest.T) { }) } +func doClientSideInOrderPrereqEventTests(t *ldtest.T) { + dataBuilder := mockld.NewClientSDKDataBuilder() + dataBuilder. + Flag("topLevel", mockld.ClientSDKFlag{ + Value: ldvalue.String("value1"), + Variation: o.Some(0), + TrackEvents: true, + Prerequisites: []string{"prereq1", "preqreq2"}, + }). + Flag("prereq1", mockld.ClientSDKFlag{ + Value: ldvalue.String("value2"), + TrackEvents: true, + Variation: o.Some(0), + Prerequisites: []string{"prereq2"}, + }). + Flag("prereq2", mockld.ClientSDKFlag{ + Value: ldvalue.String("value3"), + TrackEvents: true, + Variation: o.Some(0), + }) + dataSource := NewSDKDataSource(t, dataBuilder.Build()) + context := ldcontext.New("user") + + events := NewSDKEventSink(t) + client := NewSDKClient(t, + WithClientSideInitialContext(context), + dataSource, events) + + _ = client.EvaluateFlag(t, servicedef.EvaluateFlagParams{ + FlagKey: "topLevel", + DefaultValue: ldvalue.Null(), + ValueType: servicedef.ValueTypeAny, + }) + + client.FlushEvents(t) + payload := events.ExpectAnalyticsEvents(t, defaultEventTimeout) + + prereq2FeatureEvent := IsValidFeatureEventWithConditions( + t, false, context, + m.JSONProperty("key").Should(m.Equal("prereq2")), + m.JSONProperty("version").Should(m.Equal(0)), + m.JSONProperty("value").Should(m.JSONEqual(ldvalue.String("value3"))), + m.JSONOptProperty("variation").Should(m.JSONEqual(0)), + JSONPropertyNullOrAbsent("prereqOf"), + ) + + prereq1FeatureEvent := IsValidFeatureEventWithConditions( + t, false, context, + m.JSONProperty("key").Should(m.Equal("prereq1")), + m.JSONProperty("version").Should(m.Equal(0)), + m.JSONProperty("value").Should(m.JSONEqual(ldvalue.String("value2"))), + m.JSONOptProperty("variation").Should(m.JSONEqual(0)), + JSONPropertyNullOrAbsent("prereqOf"), + ) + + topLevelFeatureEvent := IsValidFeatureEventWithConditions( + t, false, context, + m.JSONProperty("key").Should(m.Equal("topLevel")), + m.JSONProperty("version").Should(m.Equal(0)), + m.JSONProperty("value").Should(m.JSONEqual(ldvalue.String("value1"))), + m.JSONOptProperty("variation").Should(m.JSONEqual(0)), + JSONPropertyNullOrAbsent("prereqOf"), + ) + + m.In(t).Assert(payload, m.ItemsInAnyOrder( + IsIdentifyEventForContext(context), + prereq2FeatureEvent, + prereq1FeatureEvent, + topLevelFeatureEvent, + IsSummaryEvent(), + )) +} + func doClientSideDebugEventTests(t *ldtest.T) { // These tests could misbehave if the system clocks of the host that's running the test harness // and the host that's running the test service are out of sync by at least an hour. However, diff --git a/sdktests/client_side_events_summary.go b/sdktests/client_side_events_summary.go index 8191bca..2bc22cf 100644 --- a/sdktests/client_side_events_summary.go +++ b/sdktests/client_side_events_summary.go @@ -21,6 +21,12 @@ func doClientSideSummaryEventTests(t *ldtest.T) { t.Run("context kinds", doClientSideSummaryEventContextKindsTest) t.Run("unknown flag", doClientSideSummaryEventUnknownFlagTest) t.Run("reset after each flush", doClientSideSummaryEventResetTest) + + t.Run("prerequisites", func(t *ldtest.T) { + t.RequireCapability(servicedef.CapabilityClientPrereqEvents) + t.Run("basic behavior", doClientSideSummaryBasicPrereqTest) + t.Run("emits unknown event", doClientSideSummaryPrereqUnknownFlagTest) + }) } func doClientSideSummaryEventBasicTest(t *ldtest.T) { @@ -276,3 +282,148 @@ func doClientSideSummaryEventResetTest(t *ldtest.T) { )), ) } + +func doClientSideSummaryBasicPrereqTest(t *ldtest.T) { + topLevelKey := "flag1" + topLevelResult := mockld.ClientSDKFlag{ + Value: ldvalue.String("value1-a"), + Variation: o.Some(0), + FlagVersion: o.Some(1), + Version: 11, + Prerequisites: []string{"prereq1", "prereq2"}, + } + + prereq1Key := "prereq1" + prereq1Result := mockld.ClientSDKFlag{ + Value: ldvalue.String("prereq1"), + Variation: o.Some(0), + FlagVersion: o.Some(1), + Version: 11, + Prerequisites: []string{"prereq3"}, + } + + prereq2Key := "prereq2" + prereq2Result := mockld.ClientSDKFlag{ + Value: ldvalue.String("prereq2"), + Variation: o.Some(0), + FlagVersion: o.Some(1), + Version: 11, + } + + prereq3Key := "prereq3" + prereq3Result := mockld.ClientSDKFlag{ + Value: ldvalue.String("prereq3"), + Variation: o.Some(0), + FlagVersion: o.Some(1), + Version: 11, + } + + contextA := ldcontext.New("user-a") + default1 := ldvalue.String("default1") + default2 := ldvalue.String("default2") + default3 := ldvalue.String("default3") + + dataBuilder := mockld.NewClientSDKDataBuilder() + dataBuilder.Flag(topLevelKey, topLevelResult). + Flag(prereq1Key, prereq1Result). + Flag(prereq2Key, prereq2Result). + Flag(prereq3Key, prereq3Result) + + dataSource := NewSDKDataSource(t, dataBuilder.Build()) + events := NewSDKEventSinkWithGzip(t, t.Capabilities().Has(servicedef.CapabilityEventGzip)) + client := NewSDKClient(t, + WithClientSideInitialContext(contextA), + dataSource, events) + + _ = client.EvaluateFlag(t, servicedef.EvaluateFlagParams{FlagKey: prereq1Key, DefaultValue: default1}) + _ = client.EvaluateFlag(t, servicedef.EvaluateFlagParams{FlagKey: topLevelKey, DefaultValue: default2}) + _ = client.EvaluateFlag(t, servicedef.EvaluateFlagParams{FlagKey: prereq2Key, DefaultValue: default3}) + + client.FlushEvents(t) + payload := events.ExpectAnalyticsEvents(t, defaultEventTimeout) + + m.In(t).Assert(payload, m.ItemsInAnyOrder( + IsIdentifyEventForContext(contextA), + IsValidSummaryEventWithFlags( + m.KV(topLevelKey, m.MapOf( + // Was first evaluated through the EvaluateFlag call, so it has a default value. + m.KV("default", m.JSONEqual(default2)), + m.KV("counters", m.ItemsInAnyOrder( + flagCounter(topLevelResult.Value, topLevelResult.Variation.Value(), topLevelResult.FlagVersion.Value(), 1), + )), + m.KV("contextKinds", anyContextKindsList()), + )), + m.KV(prereq1Key, m.MapOf( + // Was first evaluated through the EvaluateFlag call, so it has a default value. + m.KV("default", m.JSONEqual(default1)), + m.KV("counters", m.ItemsInAnyOrder( + flagCounter(prereq1Result.Value, prereq1Result.Variation.Value(), prereq1Result.FlagVersion.Value(), 2), + )), + m.KV("contextKinds", anyContextKindsList()), + )), + m.KV(prereq2Key, m.MapOf( + m.KV("counters", m.ItemsInAnyOrder( + flagCounter(prereq2Result.Value, prereq2Result.Variation.Value(), prereq2Result.FlagVersion.Value(), 2), + )), + m.KV("contextKinds", anyContextKindsList()), + )), + m.KV(prereq3Key, m.MapOf( + m.KV("counters", m.ItemsInAnyOrder( + flagCounter(prereq3Result.Value, prereq3Result.Variation.Value(), prereq3Result.FlagVersion.Value(), 2), + )), + m.KV("contextKinds", anyContextKindsList()), + )), + )), + ) +} + +func doClientSideSummaryPrereqUnknownFlagTest(t *ldtest.T) { + topLevelKey := "flag1" + topLevelResult := mockld.ClientSDKFlag{ + Value: ldvalue.String("value1-a"), + Variation: o.Some(0), + FlagVersion: o.Some(1), + Version: 11, + Prerequisites: []string{"unknown"}, + } + + contextA := ldcontext.New("user-a") + default1 := ldvalue.String("default1") + + dataBuilder := mockld.NewClientSDKDataBuilder() + dataBuilder.Flag(topLevelKey, topLevelResult) + + dataSource := NewSDKDataSource(t, dataBuilder.Build()) + events := NewSDKEventSinkWithGzip(t, t.Capabilities().Has(servicedef.CapabilityEventGzip)) + client := NewSDKClient(t, + WithClientSideInitialContext(contextA), + dataSource, events) + + _ = client.EvaluateFlag(t, servicedef.EvaluateFlagParams{FlagKey: topLevelKey, DefaultValue: default1}) + + client.FlushEvents(t) + payload := events.ExpectAnalyticsEvents(t, defaultEventTimeout) + + m.In(t).Assert(payload, m.ItemsInAnyOrder( + IsIdentifyEventForContext(contextA), + IsValidSummaryEventWithFlags( + m.KV(topLevelKey, m.MapOf( + // Was first evaluated through the EvaluateFlag call, so it has a default value. + m.KV("default", m.JSONEqual(default1)), + m.KV("counters", m.ItemsInAnyOrder( + flagCounter(topLevelResult.Value, topLevelResult.Variation.Value(), topLevelResult.FlagVersion.Value(), 1), + )), + m.KV("contextKinds", anyContextKindsList()), + )), + m.KV("unknown", m.AllOf( + m.JSONOptProperty("default").Should(m.BeNil()), + m.MapIncluding( + m.KV("counters", m.ItemsInAnyOrder( + unknownFlagCounter(ldvalue.Null(), 1), + )), + m.KV("contextKinds", anyContextKindsList()), + ), + )), + )), + ) +} diff --git a/sdktests/server_side_eval_all_flags.go b/sdktests/server_side_eval_all_flags.go index bd5a07e..ec42e59 100644 --- a/sdktests/server_side_eval_all_flags.go +++ b/sdktests/server_side_eval_all_flags.go @@ -31,6 +31,13 @@ func runServerSideEvalAllFlagsTests(t *ldtest.T) { t.Run("details only for tracked flags", doServerSideAllFlagsDetailsOnlyForTrackedFlagsTest) t.Run("client not ready", doServerSideAllFlagsClientNotReadyTest) t.Run("compact representations", doServerSideAllFlagsCompactRepresentationsTest) + + t.Run("prerequisites", func(t *ldtest.T) { + t.RequireCapability(servicedef.CapabilityClientPrereqEvents) + t.Run("includes top level", doServerSideAllFlagsIncludesToplevelPreqrequisitesTest) + t.Run("ignores if not evaluated", doServerSideAllFlagsIgnoresPrereqsIfNotEvaluatedTest) + t.Run("ignores client-side only for prereq keys", doServerSideAllFlagsIgnoresClientSideOnlyForPrereqKeys) + }) } func doServerSideAllFlagsBasicTest(t *ldtest.T) { @@ -399,6 +406,174 @@ func doServerSideAllFlagsCompactRepresentationsTest(t *ldtest.T) { m.In(t).Assert(resultJSON, m.JSONStrEqual(expectedMetadata)) } +func doServerSideAllFlagsIncludesToplevelPreqrequisitesTest(t *ldtest.T) { + topLevel := ldbuilders.NewFlagBuilder("topLevel").Version(100). + Variations(ldvalue.String("value1")).On(true).FallthroughVariation(0). + AddPrerequisite("directPrereq1", 0). + AddPrerequisite("directPrereq2", 0). + Build() + + directPrereq1 := ldbuilders.NewFlagBuilder("directPrereq1").Version(200). + Variations(ldvalue.String("value2")).On(true).FallthroughVariation(0). + AddPrerequisite("indirectPrereqOf1", 0). + Build() + directPrereq2 := ldbuilders.NewFlagBuilder("directPrereq2").Version(200). + Variations(ldvalue.String("value3")).On(true).FallthroughVariation(0). + Build() + indirectPrereqOf2 := ldbuilders.NewFlagBuilder("indirectPrereqOf1").Version(300). + Variations(ldvalue.String("value4")).On(true).FallthroughVariation(0). + Build() + + dataBuilder := mockld.NewServerSDKDataBuilder() + dataBuilder.Flag(topLevel, directPrereq1, directPrereq2, indirectPrereqOf2) + + dataSource := NewSDKDataSource(t, dataBuilder.Build()) + client := NewSDKClient(t, dataSource) + context := ldcontext.New("user-key") + + result := client.EvaluateAllFlags(t, servicedef.EvaluateAllFlagsParams{ + Context: o.Some(context), + }) + resultJSON, _ := json.Marshal(canonicalizeAllFlagsData(result.State)) + expectedJSON := `{ + "topLevel": "value1", + "directPrereq1": "value2", + "directPrereq2": "value3", + "indirectPrereqOf1": "value4", + "$flagsState": { + "topLevel": { + "variation": 0, "version": 100, "prerequisites": [ "directPrereq1", "directPrereq2" ] + }, + "directPrereq1": { + "variation": 0, "version": 200, "prerequisites": [ "indirectPrereqOf1" ] + }, + "directPrereq2": { + "variation": 0, "version": 200 + }, + "indirectPrereqOf1": { + "variation": 0, "version": 300 + } + }, + "$valid": true + }` + m.In(t).Assert(resultJSON, m.JSONStrEqual(expectedJSON)) +} + +func doServerSideAllFlagsIgnoresPrereqsIfNotEvaluatedTest(t *ldtest.T) { + flagOn := ldbuilders.NewFlagBuilder("flagOn").Version(100). + Variations(ldvalue.String("value1")).On(true).FallthroughVariation(0). + AddPrerequisite("prereq1", 0). + Build() + + // Since this flag is off, the prerequisites should not be evaluated, and + // thus will not be reflected in the resulting JSON. + flagOff := ldbuilders.NewFlagBuilder("flagOff").Version(100). + Variations(ldvalue.String("value1")).On(false).OffVariation(0). + AddPrerequisite("prereq1", 0). + Build() + + // The first prerequisite fails because the variation index is incorrect. + // As a result, we should NOT see the prereq2 key listed in the result as + // it wasn't actually evaluated. + failedPrereq := ldbuilders.NewFlagBuilder("failedPrereq").Version(100). + Variations(ldvalue.String("value1")).On(true).FallthroughVariation(0). + AddPrerequisite("prereq1", 1). + AddPrerequisite("prereq2", 0). + Build() + + prereq1 := ldbuilders.NewFlagBuilder("prereq1").Version(200). + Variations(ldvalue.String("value2")).On(true).FallthroughVariation(0). + Build() + + prereq2 := ldbuilders.NewFlagBuilder("prereq2").Version(200). + Variations(ldvalue.String("value2")).On(true).FallthroughVariation(0). + Build() + + dataBuilder := mockld.NewServerSDKDataBuilder() + dataBuilder.Flag(flagOn, flagOff, failedPrereq, prereq1, prereq2) + + dataSource := NewSDKDataSource(t, dataBuilder.Build()) + client := NewSDKClient(t, dataSource) + context := ldcontext.New("user-key") + + result := client.EvaluateAllFlags(t, servicedef.EvaluateAllFlagsParams{ + Context: o.Some(context), + }) + resultJSON, _ := json.Marshal(canonicalizeAllFlagsData(result.State)) + expectedJSON := `{ + "flagOn": "value1", + "flagOff": "value1", + "failedPrereq": null, + "prereq1": "value2", + "prereq2": "value2", + "$flagsState": { + "flagOn": { + "variation": 0, "version": 100, "prerequisites": [ "prereq1" ] + }, + "flagOff": { + "variation": 0, "version": 100 + }, + "failedPrereq": { + "version": 100, "prerequisites": [ "prereq1" ] + }, + "prereq1": { + "variation": 0, "version": 200 + }, + "prereq2": { + "variation": 0, "version": 200 + } + }, + "$valid": true + }` + m.In(t).Assert(resultJSON, m.JSONStrEqual(expectedJSON)) +} + +func doServerSideAllFlagsIgnoresClientSideOnlyForPrereqKeys(t *ldtest.T) { + flag := ldbuilders.NewFlagBuilder("flag").Version(100). + ClientSideUsingEnvironmentID(true). + Variations(ldvalue.String("value1")).On(true).FallthroughVariation(0). + AddPrerequisite("prereq1", 0). + AddPrerequisite("prereq2", 0). + Build() + + prereq1 := ldbuilders.NewFlagBuilder("prereq1").Version(200). + ClientSideUsingEnvironmentID(true). + Variations(ldvalue.String("value2")).On(true).FallthroughVariation(0). + Build() + + prereq2 := ldbuilders.NewFlagBuilder("prereq2").Version(200). + ClientSideUsingEnvironmentID(false). + Variations(ldvalue.String("value2")).On(true).FallthroughVariation(0). + Build() + + dataBuilder := mockld.NewServerSDKDataBuilder() + dataBuilder.Flag(flag, prereq1, prereq2) + + dataSource := NewSDKDataSource(t, dataBuilder.Build()) + client := NewSDKClient(t, dataSource) + context := ldcontext.New("user-key") + + result := client.EvaluateAllFlags(t, servicedef.EvaluateAllFlagsParams{ + Context: o.Some(context), + ClientSideOnly: true, + }) + resultJSON, _ := json.Marshal(canonicalizeAllFlagsData(result.State)) + expectedJSON := `{ + "flag": "value1", + "prereq1": "value2", + "$flagsState": { + "flag": { + "variation": 0, "version": 100, "prerequisites": [ "prereq1", "prereq2" ] + }, + "prereq1": { + "variation": 0, "version": 200 + } + }, + "$valid": true + }` + m.In(t).Assert(resultJSON, m.JSONStrEqual(expectedJSON)) +} + // canonicalizeAllFlagsData transforms the JSON flags data to adjust for variable SDK behavior that // we don't care about: 1. SDKs may or may not strip null properties in the metadata, so we'll // strip them all; 2. SDKs are allowed to omit $valid, in which case it's assumed to be true. diff --git a/servicedef/service_params.go b/servicedef/service_params.go index 09e8ccf..8e93b0a 100644 --- a/servicedef/service_params.go +++ b/servicedef/service_params.go @@ -38,6 +38,7 @@ const ( CapabilityAnonymousRedaction = "anonymous-redaction" CapabilityPollingGzip = "polling-gzip" CapabilityEvaluationHooks = "evaluation-hooks" + CapabilityClientPrereqEvents = "client-prereq-events" // CapabilityTLSVerifyPeer means the SDK is capable of establishing a TLS session and verifying // its peer. This is generally a standard capability of all SDKs.