Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 15 additions & 8 deletions ldclient_scoped.go
Original file line number Diff line number Diff line change
Expand Up @@ -88,12 +88,13 @@ type LDScopedClient struct {
// guarantees or semantic versioning. It is not suitable for production usage. Do
// not use it. You have been warned.
func NewScopedClient(client *LDClient, context ldcontext.Context) *LDScopedClient {
_ = client.TrackData("$ld:scoped:usage", context, ldvalue.String("new"))
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we send this telemetry using Track* (i.e. as custom events) then they will be visible to the customer - is that intended and desirable? e.g. they will show up in the customer's Event Explorer and Live Events.

Besides showing our laundry, I'd worry that the proliferation of these "meta" events might clutter those views and get in the way of customers seeing the actual events they are sending.

I don't think that's a deal breaker since it'll only affect customers who start using this, but it might be something we could improve on for v2.

As an alternative did you look at all about extending the "diagnostic events" that the SDK already sends (that I believe already has a summarization mechanism)? We'd need to check with Data Platform (@karayusuf and @rengawm again) but I think I heard they had plans to (or already were?) ingest diagnostic events into Clickhouse, where it would be straightforward to query them for this data.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree I'm not a fan of the showing our laundry here. @wchieng mentioned this is reasonable for now and in line with the metrics team's vision, and in the future we can build filters so that the laundry isn't shown, thanks to the hardcoded keys here. This follows a precedent set in a similar AI usage event.

Some other alternatives I considered:

  • Diagnostic events - this would be ideal end result because my use case lines up with what those were made for, but it would take a lot of refactoring to plumb this info through to the part of the SDK that sends the diagnostic events
  • Summary events - I could fairly easily abuse the part of the SDK that sends summary events, to send some summary events about scoped client usage by recording an "evaluation" with a "flag key" that's actually a special key like $ld:scoped:usage. Main concern with this are that it's pretty ugly & unusual, and I'm not totally sure about our clickhouse capability to use summary events as internal diagnostics like this.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Although the ingest volume is probably not concerning (per other comment), one issue I could see is if a customer could clog up their SDK event queue with these "meta" events. That'd be especially unfortunate since the event queue doesn't have a sense of priority, so we could end up dropping important events like RG/experiment measurements.

It looks like the number of events is roughly one per "scope" (= per request, in most cases?) per context kind, which isn't excessive, but I believe is more than what a server-side SDK would currently be sending - 1 index event per multi-context rather than per context kind, and per dedupe interval rather than per request. So a server-side SDK handling a bunch of requests concurrently might hit the max event queue size sooner than it would without this.

Again, not a deal breaker since this is limited to usage of this new feature.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As you pointed out, both of the alternatives I mentioned have the advantage of summarizing the data instead of having distinct events to send about this usage, which is all I really need for basic usage tracking. However, the downsides felt significant enough to go either route.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The summary event idea is interesting. I think it would work in Clickhouse if done as you describe (fake flag key).

I think that it would be hard for customers to encounter the fake flag key, since they can't (e.g.) navigate to the flag page for the fake flag in order to see the fake evals chart.

A downside of that way would be you wouldn't get the granular events per added context. If you don't need those anyway, maybe you could do your own mini summarization in the method and just send one event per call to AddContext (with ctx being the current state of the context being built?) and a value of the number of contexts added. That would cut down the event amplification factor a bit, although they could still just call AddContext a bunch of times.

cc := &LDScopedClient{
client: client,
contexts: make(map[ldcontext.Kind]ldcontext.Context),
rebuild: true,
}
cc.AddContext(context)
cc.addContext(context)
return cc
}

Expand All @@ -105,6 +106,16 @@ func (c *LDScopedClient) addIndividualContext(context ldcontext.Context) {
c.contexts[context.Kind()] = context
}

func (c *LDScopedClient) addContext(context ldcontext.Context) {
if context.Multiple() {
for _, individual := range context.GetAllIndividualContexts(nil) {
c.addIndividualContext(individual)
}
return
}
c.addIndividualContext(context)
}

// AddContext adds additional evaluation contexts to the scoped client's current context.
// This affects all future operations on it, like flag evaluations and event tracking.
//
Expand All @@ -121,13 +132,8 @@ func (c *LDScopedClient) AddContext(contexts ...ldcontext.Context) {
c.rebuild = true

for _, ctx := range contexts {
if ctx.Multiple() {
for _, individual := range ctx.GetAllIndividualContexts(nil) {
c.addIndividualContext(individual)
}
continue
}
c.addIndividualContext(ctx)
_ = c.client.TrackData("$ld:scoped:usage", ctx, ldvalue.String("add"))
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How are you planning to use the data value (this string)? If you're just going to query these events in Clickhouse/Athena this is fine. I think our "metric filters" project might only work for map-valued data (e.g. {"operation":"add"} rather than just "add"), but that would only be if we were creating a customer-visible metric for some of these events, which presumably we don't want to do.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I stole the idea of the single string from launchdarkly/python-server-sdk-ai#60. I figured this only needs to be distinct enough that any hand-rolled queries in Clickhouse/Athena can use it, but yeah, as you said, I'm not targeting our actual product ways of diving into this data.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It would be pretty neat to extend diagnostic events to allow some external events into them. The idea with these is just to do them until we have an idea that customers are actually getting value from things. Then we should prune them.

So it would probably be good to make a task to come back and prune this once we are happy with it.

c.addContext(ctx)
}
}

Expand Down Expand Up @@ -156,6 +162,7 @@ func (c *LDScopedClient) OverwriteContextByKind(contexts ...ldcontext.Context) {
c.rebuild = true

for _, ctx := range contexts {
_ = c.client.TrackData("$ld:scoped:usage", ctx, ldvalue.String("overwrite"))
if ctx.Multiple() {
for _, individual := range ctx.GetAllIndividualContexts(nil) {
c.overwriteIndividualContextByKind(individual)
Expand Down
76 changes: 64 additions & 12 deletions ldclient_scoped_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

"github.com/launchdarkly/go-sdk-common/v3/ldcontext"
"github.com/launchdarkly/go-sdk-common/v3/ldlog"
Expand Down Expand Up @@ -65,11 +66,42 @@ func TestScopedClientCollectsContexts(t *testing.T) {

assert.Equal(t, ldcontext.NewMulti(ldctx2, ldctx3, ldctx4, dupeCtx), c.CurrentContext())
})

t.Run("calling scoped client methods sends usage events", func(t *testing.T) {
client := makeTestClient()
c := NewScopedClient(client, ldctx1)
c.AddContext(ldctx2, ldctx3)
c.OverwriteContextByKind(ldctx3, ldctx4)

events := client.eventProcessor.(*mocks.CapturingEventProcessor).Events
expectedEvents := []struct {
Key string
Context ldcontext.Context
DataString string
}{
{"$ld:scoped:usage", ldctx1, "new"},
{"$ld:scoped:usage", ldctx2, "add"},
{"$ld:scoped:usage", ldctx3, "add"},
{"$ld:scoped:usage", ldctx3, "overwrite"},
{"$ld:scoped:usage", ldctx4, "overwrite"},
}
require.Equal(t, len(expectedEvents), len(events))
for i, expected := range expectedEvents {
e := events[i].(ldevents.CustomEventData)
assert.Equal(t, expected.Key, e.Key)
assert.Equal(t, ldevents.Context(expected.Context), e.Context)
assert.Equal(t, ldvalue.String(expected.DataString), e.Data)
}
})
}

// Testing the scoped versions of all the evaluation methods
// Almost the same as the tests in ldclient_evaluation_test.go, but with the scoped client instead

func clearCapturedEvents(client *LDClient) {
client.eventProcessor.(*mocks.CapturingEventProcessor).Events = nil
}

func TestScopedBoolVariation(t *testing.T) {
expected, defaultVal := true, false

Expand All @@ -78,6 +110,7 @@ func TestScopedBoolVariation(t *testing.T) {
p.setupSingleValueFlag(evalFlagKey, ldvalue.Bool(true))

scopedClient := NewScopedClient(p.client, evalTestUser)
clearCapturedEvents(p.client)
actual, err := scopedClient.BoolVariation(evalFlagKey, defaultVal)

assert.NoError(t, err)
Expand All @@ -91,6 +124,7 @@ func TestScopedBoolVariation(t *testing.T) {
p.setupSingleValueFlag(evalFlagKey, ldvalue.Bool(true))

scopedClient := NewScopedClient(p.client, evalTestUser)
clearCapturedEvents(p.client)
actual, err := scopedClient.BoolVariationCtx(gocontext.TODO(), evalFlagKey, defaultVal)

assert.NoError(t, err)
Expand All @@ -104,6 +138,7 @@ func TestScopedBoolVariation(t *testing.T) {
p.setupSingleValueFlag(evalFlagKey, ldvalue.Bool(true))

scopedClient := NewScopedClient(p.client, evalTestUser)
clearCapturedEvents(p.client)
actual, detail, err := scopedClient.BoolVariationDetail(evalFlagKey, defaultVal)

assert.NoError(t, err)
Expand All @@ -119,6 +154,7 @@ func TestScopedBoolVariation(t *testing.T) {
p.setupSingleValueFlag(evalFlagKey, ldvalue.Bool(true))

scopedClient := NewScopedClient(p.client, evalTestUser)
clearCapturedEvents(p.client)
actual, detail, err := scopedClient.BoolVariationDetailCtx(gocontext.TODO(), evalFlagKey, defaultVal)

assert.NoError(t, err)
Expand All @@ -139,6 +175,7 @@ func TestScopedIntVariation(t *testing.T) {
p.setupSingleValueFlag(evalFlagKey, ldvalue.Int(expected))

scopedClient := NewScopedClient(p.client, evalTestUser)
clearCapturedEvents(p.client)
actual, err := scopedClient.IntVariation(evalFlagKey, defaultVal)

assert.NoError(t, err)
Expand All @@ -152,6 +189,7 @@ func TestScopedIntVariation(t *testing.T) {
p.setupSingleValueFlag(evalFlagKey, ldvalue.Int(expected))

scopedClient := NewScopedClient(p.client, evalTestUser)
clearCapturedEvents(p.client)
actual, err := scopedClient.IntVariationCtx(gocontext.TODO(), evalFlagKey, defaultVal)

assert.NoError(t, err)
Expand All @@ -165,6 +203,7 @@ func TestScopedIntVariation(t *testing.T) {
p.setupSingleValueFlag(evalFlagKey, ldvalue.Int(expected))

scopedClient := NewScopedClient(p.client, evalTestUser)
clearCapturedEvents(p.client)
actual, detail, err := scopedClient.IntVariationDetail(evalFlagKey, defaultVal)

assert.NoError(t, err)
Expand All @@ -180,6 +219,7 @@ func TestScopedIntVariation(t *testing.T) {
p.setupSingleValueFlag(evalFlagKey, ldvalue.Int(expected))

scopedClient := NewScopedClient(p.client, evalTestUser)
clearCapturedEvents(p.client)
actual, detail, err := scopedClient.IntVariationDetailCtx(gocontext.TODO(), evalFlagKey, defaultVal)

assert.NoError(t, err)
Expand All @@ -200,6 +240,7 @@ func TestScopedFloat64Variation(t *testing.T) {
p.setupSingleValueFlag(evalFlagKey, ldvalue.Float64(expected))

scopedClient := NewScopedClient(p.client, evalTestUser)
clearCapturedEvents(p.client)
actual, err := scopedClient.Float64Variation(evalFlagKey, defaultVal)

assert.NoError(t, err)
Expand All @@ -213,6 +254,7 @@ func TestScopedFloat64Variation(t *testing.T) {
p.setupSingleValueFlag(evalFlagKey, ldvalue.Float64(expected))

scopedClient := NewScopedClient(p.client, evalTestUser)
clearCapturedEvents(p.client)
actual, err := scopedClient.Float64VariationCtx(gocontext.TODO(), evalFlagKey, defaultVal)

assert.NoError(t, err)
Expand All @@ -226,6 +268,7 @@ func TestScopedFloat64Variation(t *testing.T) {
p.setupSingleValueFlag(evalFlagKey, ldvalue.Float64(expected))

scopedClient := NewScopedClient(p.client, evalTestUser)
clearCapturedEvents(p.client)
actual, detail, err := scopedClient.Float64VariationDetail(evalFlagKey, defaultVal)

assert.NoError(t, err)
Expand All @@ -241,6 +284,7 @@ func TestScopedFloat64Variation(t *testing.T) {
p.setupSingleValueFlag(evalFlagKey, ldvalue.Float64(expected))

scopedClient := NewScopedClient(p.client, evalTestUser)
clearCapturedEvents(p.client)
actual, detail, err := scopedClient.Float64VariationDetailCtx(gocontext.TODO(), evalFlagKey, defaultVal)

assert.NoError(t, err)
Expand All @@ -261,6 +305,7 @@ func TestScopedStringVariation(t *testing.T) {
p.setupSingleValueFlag(evalFlagKey, ldvalue.String(expected))

scopedClient := NewScopedClient(p.client, evalTestUser)
clearCapturedEvents(p.client)
actual, err := scopedClient.StringVariation(evalFlagKey, defaultVal)

assert.NoError(t, err)
Expand All @@ -274,6 +319,7 @@ func TestScopedStringVariation(t *testing.T) {
p.setupSingleValueFlag(evalFlagKey, ldvalue.String(expected))

scopedClient := NewScopedClient(p.client, evalTestUser)
clearCapturedEvents(p.client)
actual, err := scopedClient.StringVariationCtx(gocontext.TODO(), evalFlagKey, defaultVal)

assert.NoError(t, err)
Expand All @@ -287,6 +333,7 @@ func TestScopedStringVariation(t *testing.T) {
p.setupSingleValueFlag(evalFlagKey, ldvalue.String(expected))

scopedClient := NewScopedClient(p.client, evalTestUser)
clearCapturedEvents(p.client)
actual, detail, err := scopedClient.StringVariationDetail(evalFlagKey, defaultVal)

assert.NoError(t, err)
Expand All @@ -302,6 +349,7 @@ func TestScopedStringVariation(t *testing.T) {
p.setupSingleValueFlag(evalFlagKey, ldvalue.String(expected))

scopedClient := NewScopedClient(p.client, evalTestUser)
clearCapturedEvents(p.client)
actual, detail, err := scopedClient.StringVariationDetailCtx(gocontext.TODO(), evalFlagKey, defaultVal)

assert.NoError(t, err)
Expand All @@ -323,6 +371,7 @@ func TestScopedJSONVariation(t *testing.T) {
p.setupSingleValueFlag(evalFlagKey, expected)

scopedClient := NewScopedClient(p.client, evalTestUser)
clearCapturedEvents(p.client)
actual, err := scopedClient.JSONVariation(evalFlagKey, defaultVal)

assert.NoError(t, err)
Expand All @@ -336,6 +385,7 @@ func TestScopedJSONVariation(t *testing.T) {
p.setupSingleValueFlag(evalFlagKey, expected)

scopedClient := NewScopedClient(p.client, evalTestUser)
clearCapturedEvents(p.client)
actual, err := scopedClient.JSONVariationCtx(gocontext.TODO(), evalFlagKey, defaultVal)

assert.NoError(t, err)
Expand All @@ -349,6 +399,7 @@ func TestScopedJSONVariation(t *testing.T) {
p.setupSingleValueFlag(evalFlagKey, expected)

scopedClient := NewScopedClient(p.client, evalTestUser)
clearCapturedEvents(p.client)
actual, detail, err := scopedClient.JSONVariationDetail(evalFlagKey, defaultVal)

assert.NoError(t, err)
Expand All @@ -364,6 +415,7 @@ func TestScopedJSONVariation(t *testing.T) {
p.setupSingleValueFlag(evalFlagKey, expected)

scopedClient := NewScopedClient(p.client, evalTestUser)
clearCapturedEvents(p.client)
actual, detail, err := scopedClient.JSONVariationDetailCtx(gocontext.TODO(), evalFlagKey, defaultVal)

assert.NoError(t, err)
Expand Down Expand Up @@ -416,8 +468,8 @@ func TestScopedTrackCalls(t *testing.T) {
assert.NoError(t, err)

events := client.eventProcessor.(*mocks.CapturingEventProcessor).Events
assert.Equal(t, 1, len(events))
e := events[0].(ldevents.CustomEventData)
assert.Equal(t, 2, len(events))
e := events[1].(ldevents.CustomEventData)
assert.Equal(t, ldevents.Context(evalTestUser), e.Context)
assert.Equal(t, key, e.Key)
})
Expand All @@ -431,8 +483,8 @@ func TestScopedTrackCalls(t *testing.T) {
assert.NoError(t, err)

events := client.eventProcessor.(*mocks.CapturingEventProcessor).Events
assert.Equal(t, 1, len(events))
e := events[0].(ldevents.CustomEventData)
assert.Equal(t, 2, len(events))
e := events[1].(ldevents.CustomEventData)
assert.Equal(t, ldevents.Context(evalTestUser), e.Context)
assert.Equal(t, key, e.Key)
})
Expand All @@ -447,8 +499,8 @@ func TestScopedTrackCalls(t *testing.T) {
assert.NoError(t, err)

events := client.eventProcessor.(*mocks.CapturingEventProcessor).Events
assert.Equal(t, 1, len(events))
e := events[0].(ldevents.CustomEventData)
assert.Equal(t, 2, len(events))
e := events[1].(ldevents.CustomEventData)
assert.Equal(t, ldevents.Context(evalTestUser), e.Context)
assert.Equal(t, key, e.Key)
assert.Equal(t, data, e.Data)
Expand All @@ -464,8 +516,8 @@ func TestScopedTrackCalls(t *testing.T) {
assert.NoError(t, err)

events := client.eventProcessor.(*mocks.CapturingEventProcessor).Events
assert.Equal(t, 1, len(events))
e := events[0].(ldevents.CustomEventData)
assert.Equal(t, 2, len(events))
e := events[1].(ldevents.CustomEventData)
assert.Equal(t, ldevents.Context(evalTestUser), e.Context)
assert.Equal(t, key, e.Key)
assert.Equal(t, data, e.Data)
Expand All @@ -482,8 +534,8 @@ func TestScopedTrackCalls(t *testing.T) {
assert.NoError(t, err)

events := client.eventProcessor.(*mocks.CapturingEventProcessor).Events
assert.Equal(t, 1, len(events))
e := events[0].(ldevents.CustomEventData)
assert.Equal(t, 2, len(events))
e := events[1].(ldevents.CustomEventData)
assert.Equal(t, ldevents.Context(evalTestUser), e.Context)
assert.Equal(t, key, e.Key)
assert.Equal(t, data, e.Data)
Expand All @@ -501,8 +553,8 @@ func TestScopedTrackCalls(t *testing.T) {
assert.NoError(t, err)

events := client.eventProcessor.(*mocks.CapturingEventProcessor).Events
assert.Equal(t, 1, len(events))
e := events[0].(ldevents.CustomEventData)
assert.Equal(t, 2, len(events))
e := events[1].(ldevents.CustomEventData)
assert.Equal(t, ldevents.Context(evalTestUser), e.Context)
assert.Equal(t, key, e.Key)
assert.Equal(t, data, e.Data)
Expand Down