From 468130c8efb7cb29077ce0cdf2c3bb5efad49843 Mon Sep 17 00:00:00 2001 From: Nicklas Lundin Date: Fri, 17 Jul 2026 16:08:19 +0200 Subject: [PATCH 1/9] feat: add provider_init_rate to telemetry On the first telemetry flush after provider initialization, include a ProviderInitRate entry with labels describing the SDK configuration (e.g. encryption: true/false). This lets the backend track which customers have adopted encryption without tagging every metric. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../flags/resolver/v1/internal_api.proto | 10 + confidence-resolver/src/telemetry.rs | 1 + .../internal/local_resolver/local_resolver.go | 15 +- .../internal/local_resolver/wasm.go | 27 +- .../proto/resolverinternal/internal_api.pb.go | 183 +++++++++---- .../go/confidence/provider_builder.go | 12 +- .../sdk/OpenFeatureLocalResolveProvider.java | 11 +- .../confidence/sdk/WasmLocalResolver.java | 37 ++- .../sdk/ApplyFlagsErrorHandlingTest.java | 7 +- .../spotify/confidence/sdk/ResolveTest.java | 2 +- .../confidence/sdk/WasmMemoryLeakTest.java | 3 +- .../sdk/WasmResolveApiFlushCloseRaceTest.java | 3 +- openfeature-provider/js/demo.mjs | 246 ++++++++++++++++++ openfeature-provider/js/proto/test-only.proto | 8 + .../js/src/ConfidenceServerProviderLocal.ts | 14 +- .../flags/resolver/v1/internal_api.proto | 8 + .../python/src/confidence/provider.py | 27 +- openfeature-provider/rust/AGENTS.md | 1 + openfeature-provider/rust/src/logger.rs | 18 +- openfeature-provider/rust/src/provider.rs | 6 +- 20 files changed, 557 insertions(+), 82 deletions(-) create mode 100644 openfeature-provider/js/demo.mjs create mode 120000 openfeature-provider/rust/AGENTS.md diff --git a/confidence-resolver/protos/confidence/flags/resolver/v1/internal_api.proto b/confidence-resolver/protos/confidence/flags/resolver/v1/internal_api.proto index 9ab42a0dd..9a9c781f6 100644 --- a/confidence-resolver/protos/confidence/flags/resolver/v1/internal_api.proto +++ b/confidence-resolver/protos/confidence/flags/resolver/v1/internal_api.proto @@ -112,6 +112,8 @@ message WriteFlagAssignedResponse { // consumers everything needed to reconstruct bucket boundaries and resample // between different histogram configurations if needed. message TelemetryData { + reserved 1; // was: int64 dropped_events + // Information about the SDK/provider Sdk sdk = 2 [ (google.api.field_behavior) = OPTIONAL @@ -128,6 +130,14 @@ message TelemetryData { // Set from the confidence-resolver crate version at build time. string resolver_version = 8; + repeated ProviderInitRate provider_init_rate = 9; + + message ProviderInitRate { + uint32 count = 1; + reserved 2; // status — tbd + map labels = 3; + } + message ResolveLatency { // Delta sum of observed values since the last flush. uint32 sum = 1; diff --git a/confidence-resolver/src/telemetry.rs b/confidence-resolver/src/telemetry.rs index e075953c9..bfd18de55 100644 --- a/confidence-resolver/src/telemetry.rs +++ b/confidence-resolver/src/telemetry.rs @@ -498,6 +498,7 @@ impl Telemetry { state_age, memory_bytes: (self.memory_provider)(), resolver_version: crate::version::VERSION.to_string(), + provider_init_rate: Vec::new(), } } } diff --git a/openfeature-provider/go/confidence/internal/local_resolver/local_resolver.go b/openfeature-provider/go/confidence/internal/local_resolver/local_resolver.go index 17d35127d..088c5cfb0 100644 --- a/openfeature-provider/go/confidence/internal/local_resolver/local_resolver.go +++ b/openfeature-provider/go/confidence/internal/local_resolver/local_resolver.go @@ -58,11 +58,24 @@ func NewLocalResolverWithPoolSize(ctx context.Context, logSink LogSink, poolSize } func NewLocalResolver(ctx context.Context, logSink LogSink, cfg LocalResolverConfig) LocalResolver { + return newLocalResolver(ctx, logSink, cfg, nil) +} + +func NewLocalResolverWithLabels(ctx context.Context, logSink LogSink, cfg LocalResolverConfig, initLabels map[string]string) LocalResolver { + return newLocalResolver(ctx, logSink, cfg, initLabels) +} + +func newLocalResolver(ctx context.Context, logSink LogSink, cfg LocalResolverConfig, initLabels map[string]string) LocalResolver { poolSize := cfg.PoolSize if poolSize <= 0 { poolSize = DefaultPoolSize } - factory := NewWasmResolverFactory(logSink, cfg.UseWasmInterpreter) + var factory LocalResolverFactory + if len(initLabels) > 0 { + factory = NewWasmResolverFactoryWithLabels(logSink, cfg.UseWasmInterpreter, initLabels) + } else { + factory = NewWasmResolverFactory(logSink, cfg.UseWasmInterpreter) + } factory = NewRecoveringResolverFactory(factory) return &localResolverImpl{ PooledResolver: *NewPooledResolver(poolSize, factory.New), diff --git a/openfeature-provider/go/confidence/internal/local_resolver/wasm.go b/openfeature-provider/go/confidence/internal/local_resolver/wasm.go index c028daa06..aaade9b99 100644 --- a/openfeature-provider/go/confidence/internal/local_resolver/wasm.go +++ b/openfeature-provider/go/confidence/internal/local_resolver/wasm.go @@ -42,6 +42,8 @@ type WasmResolver struct { mu *sync.Mutex instanceID string fnCache sync.Map + initLabels map[string]string + firstFlush bool } var _ LocalResolver = (*WasmResolver)(nil) @@ -89,6 +91,16 @@ func (r *WasmResolver) ApplyFlags(request *resolver.ApplyFlagsRequest) error { func (r *WasmResolver) FlushAllLogs() error { resp := &resolverv1.WriteFlagLogsRequest{} err := r.call("wasm_msg_guest_bounded_flush_logs", nil, resp) + if err == nil && r.firstFlush { + r.firstFlush = false + if resp.TelemetryData == nil { + resp.TelemetryData = &resolverv1.TelemetryData{} + } + resp.TelemetryData.ProviderInitRate = append( + resp.TelemetryData.ProviderInitRate, + &resolverv1.TelemetryData_ProviderInitRate{Count: 1, Labels: r.initLabels}, + ) + } if err == nil && proto.Size(resp) > 0 { r.logSink(resp) } @@ -161,9 +173,10 @@ func (r *WasmResolver) call(fnName string, request proto.Message, response proto } type WasmResolverFactory struct { - runtime wazero.Runtime - module wazero.CompiledModule - logSink LogSink + runtime wazero.Runtime + module wazero.CompiledModule + logSink LogSink + initLabels map[string]string } var _ LocalResolverFactory = (*WasmResolverFactory)(nil) @@ -220,6 +233,12 @@ func NewWasmResolverFactory(logSink LogSink, useInterpreter bool) LocalResolverF } } +func NewWasmResolverFactoryWithLabels(logSink LogSink, useInterpreter bool, initLabels map[string]string) LocalResolverFactory { + factory := NewWasmResolverFactory(logSink, useInterpreter).(*WasmResolverFactory) + factory.initLabels = initLabels + return factory +} + func (wrf *WasmResolverFactory) New() LocalResolver { ctx := context.Background() config := wazero.NewModuleConfig().WithName("") @@ -233,6 +252,8 @@ func (wrf *WasmResolverFactory) New() LocalResolver { logSink: wrf.logSink, mu: &sync.Mutex{}, instanceID: fmt.Sprintf("%d", id), + initLabels: wrf.initLabels, + firstFlush: true, } } diff --git a/openfeature-provider/go/confidence/internal/proto/resolverinternal/internal_api.pb.go b/openfeature-provider/go/confidence/internal/proto/resolverinternal/internal_api.pb.go index e2564c792..b8c3e8216 100644 --- a/openfeature-provider/go/confidence/internal/proto/resolverinternal/internal_api.pb.go +++ b/openfeature-provider/go/confidence/internal/proto/resolverinternal/internal_api.pb.go @@ -243,9 +243,10 @@ func (x *IngestFlagLogsRequest) GetBatch() *WriteFlagLogsRequest { type TelemetryData struct { state protoimpl.MessageState `protogen:"open.v1"` // Information about the SDK/provider - Sdk *resolver.Sdk `protobuf:"bytes,2,opt,name=sdk,proto3" json:"sdk,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + Sdk *resolver.Sdk `protobuf:"bytes,2,opt,name=sdk,proto3" json:"sdk,omitempty"` + ProviderInitRate []*TelemetryData_ProviderInitRate `protobuf:"bytes,9,rep,name=provider_init_rate,json=providerInitRate,proto3" json:"provider_init_rate,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *TelemetryData) Reset() { @@ -285,6 +286,13 @@ func (x *TelemetryData) GetSdk() *resolver.Sdk { return nil } +func (x *TelemetryData) GetProviderInitRate() []*TelemetryData_ProviderInitRate { + if x != nil { + return x.ProviderInitRate + } + return nil +} + type ClientInfo struct { state protoimpl.MessageState `protogen:"open.v1"` Client string `protobuf:"bytes,1,opt,name=client,proto3" json:"client,omitempty"` @@ -1163,6 +1171,58 @@ func (x *ReadOperationsResult) GetResults() []*ReadResult { return nil } +type TelemetryData_ProviderInitRate struct { + state protoimpl.MessageState `protogen:"open.v1"` + Count uint32 `protobuf:"varint,1,opt,name=count,proto3" json:"count,omitempty"` + Labels map[string]string `protobuf:"bytes,3,rep,name=labels,proto3" json:"labels,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *TelemetryData_ProviderInitRate) Reset() { + *x = TelemetryData_ProviderInitRate{} + mi := &file_confidence_flags_resolver_v1_internal_api_proto_msgTypes[19] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *TelemetryData_ProviderInitRate) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*TelemetryData_ProviderInitRate) ProtoMessage() {} + +func (x *TelemetryData_ProviderInitRate) ProtoReflect() protoreflect.Message { + mi := &file_confidence_flags_resolver_v1_internal_api_proto_msgTypes[19] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use TelemetryData_ProviderInitRate.ProtoReflect.Descriptor instead. +func (*TelemetryData_ProviderInitRate) Descriptor() ([]byte, []int) { + return file_confidence_flags_resolver_v1_internal_api_proto_rawDescGZIP(), []int{3, 0} +} + +func (x *TelemetryData_ProviderInitRate) GetCount() uint32 { + if x != nil { + return x.Count + } + return 0 +} + +func (x *TelemetryData_ProviderInitRate) GetLabels() map[string]string { + if x != nil { + return x.Labels + } + return nil +} + type FlagAssigned_AppliedFlag struct { state protoimpl.MessageState `protogen:"open.v1"` Flag string `protobuf:"bytes,1,opt,name=flag,proto3" json:"flag,omitempty"` @@ -1183,7 +1243,7 @@ type FlagAssigned_AppliedFlag struct { func (x *FlagAssigned_AppliedFlag) Reset() { *x = FlagAssigned_AppliedFlag{} - mi := &file_confidence_flags_resolver_v1_internal_api_proto_msgTypes[19] + mi := &file_confidence_flags_resolver_v1_internal_api_proto_msgTypes[21] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1195,7 +1255,7 @@ func (x *FlagAssigned_AppliedFlag) String() string { func (*FlagAssigned_AppliedFlag) ProtoMessage() {} func (x *FlagAssigned_AppliedFlag) ProtoReflect() protoreflect.Message { - mi := &file_confidence_flags_resolver_v1_internal_api_proto_msgTypes[19] + mi := &file_confidence_flags_resolver_v1_internal_api_proto_msgTypes[21] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1311,7 +1371,7 @@ type FlagAssigned_AssignmentInfo struct { func (x *FlagAssigned_AssignmentInfo) Reset() { *x = FlagAssigned_AssignmentInfo{} - mi := &file_confidence_flags_resolver_v1_internal_api_proto_msgTypes[20] + mi := &file_confidence_flags_resolver_v1_internal_api_proto_msgTypes[22] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1323,7 +1383,7 @@ func (x *FlagAssigned_AssignmentInfo) String() string { func (*FlagAssigned_AssignmentInfo) ProtoMessage() {} func (x *FlagAssigned_AssignmentInfo) ProtoReflect() protoreflect.Message { - mi := &file_confidence_flags_resolver_v1_internal_api_proto_msgTypes[20] + mi := &file_confidence_flags_resolver_v1_internal_api_proto_msgTypes[22] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1362,7 +1422,7 @@ type FlagAssigned_DefaultAssignment struct { func (x *FlagAssigned_DefaultAssignment) Reset() { *x = FlagAssigned_DefaultAssignment{} - mi := &file_confidence_flags_resolver_v1_internal_api_proto_msgTypes[21] + mi := &file_confidence_flags_resolver_v1_internal_api_proto_msgTypes[23] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1374,7 +1434,7 @@ func (x *FlagAssigned_DefaultAssignment) String() string { func (*FlagAssigned_DefaultAssignment) ProtoMessage() {} func (x *FlagAssigned_DefaultAssignment) ProtoReflect() protoreflect.Message { - mi := &file_confidence_flags_resolver_v1_internal_api_proto_msgTypes[21] + mi := &file_confidence_flags_resolver_v1_internal_api_proto_msgTypes[23] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1408,7 +1468,7 @@ type ClientResolveInfo_EvaluationContextSchemaInstance struct { func (x *ClientResolveInfo_EvaluationContextSchemaInstance) Reset() { *x = ClientResolveInfo_EvaluationContextSchemaInstance{} - mi := &file_confidence_flags_resolver_v1_internal_api_proto_msgTypes[22] + mi := &file_confidence_flags_resolver_v1_internal_api_proto_msgTypes[24] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1420,7 +1480,7 @@ func (x *ClientResolveInfo_EvaluationContextSchemaInstance) String() string { func (*ClientResolveInfo_EvaluationContextSchemaInstance) ProtoMessage() {} func (x *ClientResolveInfo_EvaluationContextSchemaInstance) ProtoReflect() protoreflect.Message { - mi := &file_confidence_flags_resolver_v1_internal_api_proto_msgTypes[22] + mi := &file_confidence_flags_resolver_v1_internal_api_proto_msgTypes[24] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1456,7 +1516,7 @@ type FlagResolveInfo_VariantResolveInfo struct { func (x *FlagResolveInfo_VariantResolveInfo) Reset() { *x = FlagResolveInfo_VariantResolveInfo{} - mi := &file_confidence_flags_resolver_v1_internal_api_proto_msgTypes[24] + mi := &file_confidence_flags_resolver_v1_internal_api_proto_msgTypes[26] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1468,7 +1528,7 @@ func (x *FlagResolveInfo_VariantResolveInfo) String() string { func (*FlagResolveInfo_VariantResolveInfo) ProtoMessage() {} func (x *FlagResolveInfo_VariantResolveInfo) ProtoReflect() protoreflect.Message { - mi := &file_confidence_flags_resolver_v1_internal_api_proto_msgTypes[24] + mi := &file_confidence_flags_resolver_v1_internal_api_proto_msgTypes[26] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1512,9 +1572,16 @@ const file_confidence_flags_resolver_v1_internal_api_proto_rawDesc = "" + "\x15IngestFlagLogsRequest\x12\x1d\n" + "\n" + "account_id\x18\x01 \x01(\tR\taccountId\x12H\n" + - "\x05batch\x18\x02 \x01(\v22.confidence.flags.resolver.v1.WriteFlagLogsRequestR\x05batch\"D\n" + + "\x05batch\x18\x02 \x01(\v22.confidence.flags.resolver.v1.WriteFlagLogsRequestR\x05batch\"\xfe\x02\n" + "\rTelemetryData\x123\n" + - "\x03sdk\x18\x02 \x01(\v2!.confidence.flags.resolver.v1.SdkR\x03sdk\"\x86\x01\n" + + "\x03sdk\x18\x02 \x01(\v2!.confidence.flags.resolver.v1.SdkR\x03sdk\x12j\n" + + "\x12provider_init_rate\x18\t \x03(\v2<.confidence.flags.resolver.v1.TelemetryData.ProviderInitRateR\x10providerInitRate\x1a\xcb\x01\n" + + "\x10ProviderInitRate\x12\x14\n" + + "\x05count\x18\x01 \x01(\rR\x05count\x12`\n" + + "\x06labels\x18\x03 \x03(\v2H.confidence.flags.resolver.v1.TelemetryData.ProviderInitRate.LabelsEntryR\x06labels\x1a9\n" + + "\vLabelsEntry\x12\x10\n" + + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01J\x04\b\x02\x10\x03\"\x86\x01\n" + "\n" + "ClientInfo\x12\x16\n" + "\x06client\x18\x01 \x01(\tR\x06client\x12+\n" + @@ -1622,7 +1689,7 @@ func file_confidence_flags_resolver_v1_internal_api_proto_rawDescGZIP() []byte { } var file_confidence_flags_resolver_v1_internal_api_proto_enumTypes = make([]protoimpl.EnumInfo, 1) -var file_confidence_flags_resolver_v1_internal_api_proto_msgTypes = make([]protoimpl.MessageInfo, 25) +var file_confidence_flags_resolver_v1_internal_api_proto_msgTypes = make([]protoimpl.MessageInfo, 27) var file_confidence_flags_resolver_v1_internal_api_proto_goTypes = []any{ (FlagAssigned_DefaultAssignment_DefaultAssignmentReason)(0), // 0: confidence.flags.resolver.v1.FlagAssigned.DefaultAssignment.DefaultAssignmentReason (*WriteFlagLogsRequest)(nil), // 1: confidence.flags.resolver.v1.WriteFlagLogsRequest @@ -1644,14 +1711,16 @@ var file_confidence_flags_resolver_v1_internal_api_proto_goTypes = []any{ (*InclusionData)(nil), // 17: confidence.flags.resolver.v1.InclusionData (*ReadResult)(nil), // 18: confidence.flags.resolver.v1.ReadResult (*ReadOperationsResult)(nil), // 19: confidence.flags.resolver.v1.ReadOperationsResult - (*FlagAssigned_AppliedFlag)(nil), // 20: confidence.flags.resolver.v1.FlagAssigned.AppliedFlag - (*FlagAssigned_AssignmentInfo)(nil), // 21: confidence.flags.resolver.v1.FlagAssigned.AssignmentInfo - (*FlagAssigned_DefaultAssignment)(nil), // 22: confidence.flags.resolver.v1.FlagAssigned.DefaultAssignment - (*ClientResolveInfo_EvaluationContextSchemaInstance)(nil), // 23: confidence.flags.resolver.v1.ClientResolveInfo.EvaluationContextSchemaInstance - nil, // 24: confidence.flags.resolver.v1.ClientResolveInfo.EvaluationContextSchemaInstance.SchemaEntry - (*FlagResolveInfo_VariantResolveInfo)(nil), // 25: confidence.flags.resolver.v1.FlagResolveInfo.VariantResolveInfo - (*resolver.Sdk)(nil), // 26: confidence.flags.resolver.v1.Sdk - (*timestamppb.Timestamp)(nil), // 27: google.protobuf.Timestamp + (*TelemetryData_ProviderInitRate)(nil), // 20: confidence.flags.resolver.v1.TelemetryData.ProviderInitRate + nil, // 21: confidence.flags.resolver.v1.TelemetryData.ProviderInitRate.LabelsEntry + (*FlagAssigned_AppliedFlag)(nil), // 22: confidence.flags.resolver.v1.FlagAssigned.AppliedFlag + (*FlagAssigned_AssignmentInfo)(nil), // 23: confidence.flags.resolver.v1.FlagAssigned.AssignmentInfo + (*FlagAssigned_DefaultAssignment)(nil), // 24: confidence.flags.resolver.v1.FlagAssigned.DefaultAssignment + (*ClientResolveInfo_EvaluationContextSchemaInstance)(nil), // 25: confidence.flags.resolver.v1.ClientResolveInfo.EvaluationContextSchemaInstance + nil, // 26: confidence.flags.resolver.v1.ClientResolveInfo.EvaluationContextSchemaInstance.SchemaEntry + (*FlagResolveInfo_VariantResolveInfo)(nil), // 27: confidence.flags.resolver.v1.FlagResolveInfo.VariantResolveInfo + (*resolver.Sdk)(nil), // 28: confidence.flags.resolver.v1.Sdk + (*timestamppb.Timestamp)(nil), // 29: google.protobuf.Timestamp } var file_confidence_flags_resolver_v1_internal_api_proto_depIdxs = []int32{ 6, // 0: confidence.flags.resolver.v1.WriteFlagLogsRequest.flag_assigned:type_name -> confidence.flags.resolver.v1.FlagAssigned @@ -1659,36 +1728,38 @@ var file_confidence_flags_resolver_v1_internal_api_proto_depIdxs = []int32{ 8, // 2: confidence.flags.resolver.v1.WriteFlagLogsRequest.client_resolve_info:type_name -> confidence.flags.resolver.v1.ClientResolveInfo 9, // 3: confidence.flags.resolver.v1.WriteFlagLogsRequest.flag_resolve_info:type_name -> confidence.flags.resolver.v1.FlagResolveInfo 1, // 4: confidence.flags.resolver.v1.IngestFlagLogsRequest.batch:type_name -> confidence.flags.resolver.v1.WriteFlagLogsRequest - 26, // 5: confidence.flags.resolver.v1.TelemetryData.sdk:type_name -> confidence.flags.resolver.v1.Sdk - 26, // 6: confidence.flags.resolver.v1.ClientInfo.sdk:type_name -> confidence.flags.resolver.v1.Sdk - 5, // 7: confidence.flags.resolver.v1.FlagAssigned.client_info:type_name -> confidence.flags.resolver.v1.ClientInfo - 20, // 8: confidence.flags.resolver.v1.FlagAssigned.flags:type_name -> confidence.flags.resolver.v1.FlagAssigned.AppliedFlag - 23, // 9: confidence.flags.resolver.v1.ClientResolveInfo.schema:type_name -> confidence.flags.resolver.v1.ClientResolveInfo.EvaluationContextSchemaInstance - 25, // 10: confidence.flags.resolver.v1.FlagResolveInfo.variant_resolve_info:type_name -> confidence.flags.resolver.v1.FlagResolveInfo.VariantResolveInfo - 16, // 11: confidence.flags.resolver.v1.WriteOperationsRequest.store_variant_op:type_name -> confidence.flags.resolver.v1.VariantData - 12, // 12: confidence.flags.resolver.v1.ReadOp.variant_read_op:type_name -> confidence.flags.resolver.v1.VariantReadOp - 13, // 13: confidence.flags.resolver.v1.ReadOp.inclusion_read_op:type_name -> confidence.flags.resolver.v1.InclusionReadOp - 14, // 14: confidence.flags.resolver.v1.ReadOperationsRequest.ops:type_name -> confidence.flags.resolver.v1.ReadOp - 16, // 15: confidence.flags.resolver.v1.ReadResult.variant_result:type_name -> confidence.flags.resolver.v1.VariantData - 17, // 16: confidence.flags.resolver.v1.ReadResult.inclusion_result:type_name -> confidence.flags.resolver.v1.InclusionData - 18, // 17: confidence.flags.resolver.v1.ReadOperationsResult.results:type_name -> confidence.flags.resolver.v1.ReadResult - 21, // 18: confidence.flags.resolver.v1.FlagAssigned.AppliedFlag.assignment_info:type_name -> confidence.flags.resolver.v1.FlagAssigned.AssignmentInfo - 22, // 19: confidence.flags.resolver.v1.FlagAssigned.AppliedFlag.default_assignment:type_name -> confidence.flags.resolver.v1.FlagAssigned.DefaultAssignment - 7, // 20: confidence.flags.resolver.v1.FlagAssigned.AppliedFlag.fallthrough_assignments:type_name -> confidence.flags.resolver.v1.FallthroughAssignment - 27, // 21: confidence.flags.resolver.v1.FlagAssigned.AppliedFlag.apply_time:type_name -> google.protobuf.Timestamp - 0, // 22: confidence.flags.resolver.v1.FlagAssigned.DefaultAssignment.reason:type_name -> confidence.flags.resolver.v1.FlagAssigned.DefaultAssignment.DefaultAssignmentReason - 24, // 23: confidence.flags.resolver.v1.ClientResolveInfo.EvaluationContextSchemaInstance.schema:type_name -> confidence.flags.resolver.v1.ClientResolveInfo.EvaluationContextSchemaInstance.SchemaEntry - 1, // 24: confidence.flags.resolver.v1.InternalFlagLoggerService.ClientWriteFlagLogs:input_type -> confidence.flags.resolver.v1.WriteFlagLogsRequest - 10, // 25: confidence.flags.resolver.v1.InternalFlagLoggerService.WriteMaterializedOperations:input_type -> confidence.flags.resolver.v1.WriteOperationsRequest - 15, // 26: confidence.flags.resolver.v1.InternalFlagLoggerService.ReadMaterializedOperations:input_type -> confidence.flags.resolver.v1.ReadOperationsRequest - 2, // 27: confidence.flags.resolver.v1.InternalFlagLoggerService.ClientWriteFlagLogs:output_type -> confidence.flags.resolver.v1.WriteFlagLogsResponse - 11, // 28: confidence.flags.resolver.v1.InternalFlagLoggerService.WriteMaterializedOperations:output_type -> confidence.flags.resolver.v1.WriteOperationsResult - 19, // 29: confidence.flags.resolver.v1.InternalFlagLoggerService.ReadMaterializedOperations:output_type -> confidence.flags.resolver.v1.ReadOperationsResult - 27, // [27:30] is the sub-list for method output_type - 24, // [24:27] is the sub-list for method input_type - 24, // [24:24] is the sub-list for extension type_name - 24, // [24:24] is the sub-list for extension extendee - 0, // [0:24] is the sub-list for field type_name + 28, // 5: confidence.flags.resolver.v1.TelemetryData.sdk:type_name -> confidence.flags.resolver.v1.Sdk + 20, // 6: confidence.flags.resolver.v1.TelemetryData.provider_init_rate:type_name -> confidence.flags.resolver.v1.TelemetryData.ProviderInitRate + 28, // 7: confidence.flags.resolver.v1.ClientInfo.sdk:type_name -> confidence.flags.resolver.v1.Sdk + 5, // 8: confidence.flags.resolver.v1.FlagAssigned.client_info:type_name -> confidence.flags.resolver.v1.ClientInfo + 22, // 9: confidence.flags.resolver.v1.FlagAssigned.flags:type_name -> confidence.flags.resolver.v1.FlagAssigned.AppliedFlag + 25, // 10: confidence.flags.resolver.v1.ClientResolveInfo.schema:type_name -> confidence.flags.resolver.v1.ClientResolveInfo.EvaluationContextSchemaInstance + 27, // 11: confidence.flags.resolver.v1.FlagResolveInfo.variant_resolve_info:type_name -> confidence.flags.resolver.v1.FlagResolveInfo.VariantResolveInfo + 16, // 12: confidence.flags.resolver.v1.WriteOperationsRequest.store_variant_op:type_name -> confidence.flags.resolver.v1.VariantData + 12, // 13: confidence.flags.resolver.v1.ReadOp.variant_read_op:type_name -> confidence.flags.resolver.v1.VariantReadOp + 13, // 14: confidence.flags.resolver.v1.ReadOp.inclusion_read_op:type_name -> confidence.flags.resolver.v1.InclusionReadOp + 14, // 15: confidence.flags.resolver.v1.ReadOperationsRequest.ops:type_name -> confidence.flags.resolver.v1.ReadOp + 16, // 16: confidence.flags.resolver.v1.ReadResult.variant_result:type_name -> confidence.flags.resolver.v1.VariantData + 17, // 17: confidence.flags.resolver.v1.ReadResult.inclusion_result:type_name -> confidence.flags.resolver.v1.InclusionData + 18, // 18: confidence.flags.resolver.v1.ReadOperationsResult.results:type_name -> confidence.flags.resolver.v1.ReadResult + 21, // 19: confidence.flags.resolver.v1.TelemetryData.ProviderInitRate.labels:type_name -> confidence.flags.resolver.v1.TelemetryData.ProviderInitRate.LabelsEntry + 23, // 20: confidence.flags.resolver.v1.FlagAssigned.AppliedFlag.assignment_info:type_name -> confidence.flags.resolver.v1.FlagAssigned.AssignmentInfo + 24, // 21: confidence.flags.resolver.v1.FlagAssigned.AppliedFlag.default_assignment:type_name -> confidence.flags.resolver.v1.FlagAssigned.DefaultAssignment + 7, // 22: confidence.flags.resolver.v1.FlagAssigned.AppliedFlag.fallthrough_assignments:type_name -> confidence.flags.resolver.v1.FallthroughAssignment + 29, // 23: confidence.flags.resolver.v1.FlagAssigned.AppliedFlag.apply_time:type_name -> google.protobuf.Timestamp + 0, // 24: confidence.flags.resolver.v1.FlagAssigned.DefaultAssignment.reason:type_name -> confidence.flags.resolver.v1.FlagAssigned.DefaultAssignment.DefaultAssignmentReason + 26, // 25: confidence.flags.resolver.v1.ClientResolveInfo.EvaluationContextSchemaInstance.schema:type_name -> confidence.flags.resolver.v1.ClientResolveInfo.EvaluationContextSchemaInstance.SchemaEntry + 1, // 26: confidence.flags.resolver.v1.InternalFlagLoggerService.ClientWriteFlagLogs:input_type -> confidence.flags.resolver.v1.WriteFlagLogsRequest + 10, // 27: confidence.flags.resolver.v1.InternalFlagLoggerService.WriteMaterializedOperations:input_type -> confidence.flags.resolver.v1.WriteOperationsRequest + 15, // 28: confidence.flags.resolver.v1.InternalFlagLoggerService.ReadMaterializedOperations:input_type -> confidence.flags.resolver.v1.ReadOperationsRequest + 2, // 29: confidence.flags.resolver.v1.InternalFlagLoggerService.ClientWriteFlagLogs:output_type -> confidence.flags.resolver.v1.WriteFlagLogsResponse + 11, // 30: confidence.flags.resolver.v1.InternalFlagLoggerService.WriteMaterializedOperations:output_type -> confidence.flags.resolver.v1.WriteOperationsResult + 19, // 31: confidence.flags.resolver.v1.InternalFlagLoggerService.ReadMaterializedOperations:output_type -> confidence.flags.resolver.v1.ReadOperationsResult + 29, // [29:32] is the sub-list for method output_type + 26, // [26:29] is the sub-list for method input_type + 26, // [26:26] is the sub-list for extension type_name + 26, // [26:26] is the sub-list for extension extendee + 0, // [0:26] is the sub-list for field type_name } func init() { file_confidence_flags_resolver_v1_internal_api_proto_init() } @@ -1704,7 +1775,7 @@ func file_confidence_flags_resolver_v1_internal_api_proto_init() { (*ReadResult_VariantResult)(nil), (*ReadResult_InclusionResult)(nil), } - file_confidence_flags_resolver_v1_internal_api_proto_msgTypes[19].OneofWrappers = []any{ + file_confidence_flags_resolver_v1_internal_api_proto_msgTypes[21].OneofWrappers = []any{ (*FlagAssigned_AppliedFlag_AssignmentInfo)(nil), (*FlagAssigned_AppliedFlag_DefaultAssignment)(nil), } @@ -1714,7 +1785,7 @@ func file_confidence_flags_resolver_v1_internal_api_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_confidence_flags_resolver_v1_internal_api_proto_rawDesc), len(file_confidence_flags_resolver_v1_internal_api_proto_rawDesc)), NumEnums: 1, - NumMessages: 25, + NumMessages: 27, NumExtensions: 0, NumServices: 1, }, diff --git a/openfeature-provider/go/confidence/provider_builder.go b/openfeature-provider/go/confidence/provider_builder.go index 519c0c07b..ae033f9f0 100644 --- a/openfeature-provider/go/confidence/provider_builder.go +++ b/openfeature-provider/go/confidence/provider_builder.go @@ -6,6 +6,7 @@ import ( "log/slog" "net/http" "os" + "strconv" "time" fl "github.com/spotify/confidence-resolver/openfeature-provider/go/confidence/internal/flag_logger" @@ -104,7 +105,10 @@ func NewProvider(ctx context.Context, config ProviderConfig) (*LocalResolverProv materializationStore = newRemoteMaterializationStore(resolverv1.NewInternalFlagLoggerServiceClient(conn), config.ClientSecret) } - resolverSupplier := newLocalResolverSupplier(config.ResolverPoolSize, config.UseWasmInterpreter) + initLabels := map[string]string{ + "encryption": strconv.FormatBool(config.EncryptionKey != ""), + } + resolverSupplier := newLocalResolverSupplier(config.ResolverPoolSize, config.UseWasmInterpreter, initLabels) resolverSupplierWithMaterialization := wrapResolverSupplierWithMaterializations(resolverSupplier, materializationStore) providerOpts := buildProviderOptions(config.StatePollInterval, config.LogPollInterval, config.EnableApplyDedup, config.DisableExposureCollection) provider := NewLocalResolverProvider(resolverSupplierWithMaterialization, stateProvider, flagLogger, config.ClientSecret, logger, providerOpts...) @@ -131,7 +135,7 @@ func NewProviderForTest(ctx context.Context, config ProviderTestConfig) (*LocalR if materializationStore == nil { materializationStore = newUnsupportedMaterializationStore() } - resolverSupplier := newLocalResolverSupplier(config.ResolverPoolSize, config.UseWasmInterpreter) + resolverSupplier := newLocalResolverSupplier(config.ResolverPoolSize, config.UseWasmInterpreter, nil) resolverSupplierWithMaterialization := wrapResolverSupplierWithMaterializations(resolverSupplier, materializationStore) providerOpts := buildProviderOptions(config.StatePollInterval, config.LogPollInterval, false, config.DisableExposureCollection) provider := NewLocalResolverProvider(resolverSupplierWithMaterialization, config.StateProvider, config.FlagLogger, config.ClientSecret, logger, providerOpts...) @@ -139,13 +143,13 @@ func NewProviderForTest(ctx context.Context, config ProviderTestConfig) (*LocalR return provider, nil } -func newLocalResolverSupplier(poolSize int, useWasmInterpreter bool) func(context.Context, lr.LogSink) lr.LocalResolver { +func newLocalResolverSupplier(poolSize int, useWasmInterpreter bool, initLabels map[string]string) func(context.Context, lr.LogSink) lr.LocalResolver { cfg := lr.LocalResolverConfig{ PoolSize: poolSize, UseWasmInterpreter: useWasmInterpreter, } return func(ctx context.Context, logSink lr.LogSink) lr.LocalResolver { - return lr.NewLocalResolver(ctx, logSink, cfg) + return lr.NewLocalResolverWithLabels(ctx, logSink, cfg, initLabels) } } diff --git a/openfeature-provider/java/src/main/java/com/spotify/confidence/sdk/OpenFeatureLocalResolveProvider.java b/openfeature-provider/java/src/main/java/com/spotify/confidence/sdk/OpenFeatureLocalResolveProvider.java index 450852b78..e28165c76 100644 --- a/openfeature-provider/java/src/main/java/com/spotify/confidence/sdk/OpenFeatureLocalResolveProvider.java +++ b/openfeature-provider/java/src/main/java/com/spotify/confidence/sdk/OpenFeatureLocalResolveProvider.java @@ -20,6 +20,7 @@ import io.grpc.StatusRuntimeException; import java.time.Duration; import java.util.List; +import java.util.Map; import java.util.Optional; import java.util.concurrent.*; import java.util.concurrent.atomic.AtomicReference; @@ -162,6 +163,8 @@ public OpenFeatureLocalResolveProvider( new GrpcWasmFlagLogger( clientSecret, config.getChannelFactory(), config.getHttpClientFactory()); this.flagLogger = wasmFlagLogger; + final Map initLabels = + Map.of("encryption", String.valueOf(config.getEncryptionKey() != null)); final int numInstances = PooledResolver.getNumInstances(config.getResolverPoolSize()); final LocalResolver inner = new PooledResolver( @@ -172,7 +175,8 @@ public OpenFeatureLocalResolveProvider( new WasmLocalResolver( flagLogger::write, config.isEnableApplyDedup(), - config.isDisableExposureCollection()))); + config.isDisableExposureCollection(), + initLabels))); this.resolver = new MaterializingResolver(inner, materializationStore); } @@ -231,7 +235,10 @@ public OpenFeatureLocalResolveProvider( new RecoveringResolver( () -> new WasmLocalResolver( - wasmFlagLogger::write, enableApplyDedup, disableExposureCollection))); + wasmFlagLogger::write, + enableApplyDedup, + disableExposureCollection, + Map.of()))); this.resolver = new MaterializingResolver(inner, materializationStore); } diff --git a/openfeature-provider/java/src/main/java/com/spotify/confidence/sdk/WasmLocalResolver.java b/openfeature-provider/java/src/main/java/com/spotify/confidence/sdk/WasmLocalResolver.java index f599ec039..f05555d88 100644 --- a/openfeature-provider/java/src/main/java/com/spotify/confidence/sdk/WasmLocalResolver.java +++ b/openfeature-provider/java/src/main/java/com/spotify/confidence/sdk/WasmLocalResolver.java @@ -18,10 +18,12 @@ import com.spotify.confidence.sdk.flags.resolver.v1.ResolveProcessRequest; import com.spotify.confidence.sdk.flags.resolver.v1.ResolveProcessResponse; import com.spotify.confidence.sdk.flags.resolver.v1.Sdk; +import com.spotify.confidence.sdk.flags.resolver.v1.TelemetryData; import com.spotify.confidence.sdk.flags.resolver.v1.WriteFlagLogsRequest; import com.spotify.confidence.sdk.wasm.Messages; import java.time.Instant; import java.util.List; +import java.util.Map; import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletionStage; import java.util.concurrent.atomic.AtomicInteger; @@ -49,6 +51,8 @@ class WasmLocalResolver implements LocalResolver { private final ExportFunction wasmMsgAlloc; private final ExportFunction wasmMsgFree; private final Consumer logSink; + private final Map initLabels; + private boolean firstFlush = true; // api private final ExportFunction wasmMsgGuestSetResolverState; @@ -63,20 +67,29 @@ class WasmLocalResolver implements LocalResolver { private final boolean disableExposureCollection; public WasmLocalResolver(Consumer logSink) { - this(logSink, false); + this(logSink, false, false, Map.of()); } public WasmLocalResolver(Consumer logSink, boolean enableApplyDedup) { - this(logSink, enableApplyDedup, false); + this(logSink, enableApplyDedup, false, Map.of()); } public WasmLocalResolver( Consumer logSink, boolean enableApplyDedup, boolean disableExposureCollection) { + this(logSink, enableApplyDedup, disableExposureCollection, Map.of()); + } + + public WasmLocalResolver( + Consumer logSink, + boolean enableApplyDedup, + boolean disableExposureCollection, + Map initLabels) { this.logSink = logSink; this.enableApplyDedup = enableApplyDedup; this.disableExposureCollection = disableExposureCollection; + this.initLabels = initLabels; this.instanceId = String.valueOf(INSTANCE_COUNTER.getAndIncrement()); instance = Instance.builder(ConfidenceResolverModule.load()) @@ -209,7 +222,21 @@ public void flushAllLogs() { final var voidRequest = Messages.Void.getDefaultInstance(); final var reqPtr = transferRequest(voidRequest); final var respPtr = (int) wasmMsgBoundedFlushLogs.apply(reqPtr)[0]; - final var request = consumeResponse(respPtr, WriteFlagLogsRequest::parseFrom); + var request = consumeResponse(respPtr, WriteFlagLogsRequest::parseFrom); + if (firstFlush) { + firstFlush = false; + request = + request.toBuilder() + .setTelemetryData( + request.getTelemetryData().toBuilder() + .addProviderInitRate( + TelemetryData.ProviderInitRate.newBuilder() + .setCount(1) + .putAllLabels(initLabels) + .build()) + .build()) + .build(); + } if (!isEmptyLogRequest(request)) { logSink.accept(request); } @@ -296,9 +323,8 @@ private T consumeResponse(int addr, ParserFn codec) { final Messages.Response response = Messages.Response.parseFrom(consume(addr)); if (response.hasError()) { throw new RuntimeException(response.getError()); - } else { - return codec.apply(response.getData().toByteArray()); } + return codec.apply(response.getData().toByteArray()); } catch (InvalidProtocolBufferException e) { throw new RuntimeException(e); } @@ -367,4 +393,5 @@ private interface ParserFn { T apply(byte[] data) throws InvalidProtocolBufferException; } + } diff --git a/openfeature-provider/java/src/test/java/com/spotify/confidence/sdk/ApplyFlagsErrorHandlingTest.java b/openfeature-provider/java/src/test/java/com/spotify/confidence/sdk/ApplyFlagsErrorHandlingTest.java index e0227a91c..c2bee2a66 100644 --- a/openfeature-provider/java/src/test/java/com/spotify/confidence/sdk/ApplyFlagsErrorHandlingTest.java +++ b/openfeature-provider/java/src/test/java/com/spotify/confidence/sdk/ApplyFlagsErrorHandlingTest.java @@ -25,6 +25,7 @@ import java.time.Instant; import java.util.ArrayList; import java.util.List; +import java.util.Map; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -154,7 +155,7 @@ private static Timestamp toTs(Instant t) { @Test void applyWithoutSendTime_isSwallowed_andRecordsNoFlagAssigned() { final List captured = new ArrayList<>(); - final var resolver = new WasmLocalResolver(captured::add); + final var resolver = new WasmLocalResolver(captured::add, Map.of()); resolver.setResolverState(buildState(), ACCOUNT, null); final var resolveResp = resolveWithApplyFalse(resolver); @@ -185,7 +186,7 @@ void applyWithoutSendTime_isSwallowed_andRecordsNoFlagAssigned() { @Test void applyWithSendTime_recordsOneFlagAssigned() { final List captured = new ArrayList<>(); - final var resolver = new WasmLocalResolver(captured::add); + final var resolver = new WasmLocalResolver(captured::add, Map.of()); resolver.setResolverState(buildState(), ACCOUNT, null); final var resolveResp = resolveWithApplyFalse(resolver); @@ -233,7 +234,7 @@ void applyWithSendTime_recordsOneFlagAssigned() { @Test void applyAfterStateRotated_secretRemoved_isSwallowedAndLogged() { final List captured = new ArrayList<>(); - final var resolver = new WasmLocalResolver(captured::add); + final var resolver = new WasmLocalResolver(captured::add, Map.of()); resolver.setResolverState(buildState(SECRET), ACCOUNT, null); final var resolveResp = resolveWithApplyFalse(resolver); diff --git a/openfeature-provider/java/src/test/java/com/spotify/confidence/sdk/ResolveTest.java b/openfeature-provider/java/src/test/java/com/spotify/confidence/sdk/ResolveTest.java index 70f93095e..cbd7f5495 100644 --- a/openfeature-provider/java/src/test/java/com/spotify/confidence/sdk/ResolveTest.java +++ b/openfeature-provider/java/src/test/java/com/spotify/confidence/sdk/ResolveTest.java @@ -36,7 +36,7 @@ class ResolveTest { private final LocalResolver resolver; public ResolveTest() { - resolver = new WasmLocalResolver(request -> {}); + resolver = new WasmLocalResolver(request -> {}, Map.of()); } @BeforeEach diff --git a/openfeature-provider/java/src/test/java/com/spotify/confidence/sdk/WasmMemoryLeakTest.java b/openfeature-provider/java/src/test/java/com/spotify/confidence/sdk/WasmMemoryLeakTest.java index 7982a1e69..5f28c179b 100644 --- a/openfeature-provider/java/src/test/java/com/spotify/confidence/sdk/WasmMemoryLeakTest.java +++ b/openfeature-provider/java/src/test/java/com/spotify/confidence/sdk/WasmMemoryLeakTest.java @@ -10,6 +10,7 @@ import com.spotify.confidence.sdk.flags.resolver.v1.ResolveProcessRequest; import java.lang.reflect.Field; import java.util.List; +import java.util.Map; import org.junit.jupiter.api.Test; /** @@ -32,7 +33,7 @@ private static int getWasmMemoryPages(WasmLocalResolver resolver) { @Test void wasmMemoryStableOnRepeatedResolveCalls() { - WasmLocalResolver resolver = new WasmLocalResolver(request -> {}); + WasmLocalResolver resolver = new WasmLocalResolver(request -> {}, Map.of()); resolver.setResolverState(ResolveTest.exampleStateBytes, "account", null); ResolveProcessRequest request = diff --git a/openfeature-provider/java/src/test/java/com/spotify/confidence/sdk/WasmResolveApiFlushCloseRaceTest.java b/openfeature-provider/java/src/test/java/com/spotify/confidence/sdk/WasmResolveApiFlushCloseRaceTest.java index cdd632580..933a66551 100644 --- a/openfeature-provider/java/src/test/java/com/spotify/confidence/sdk/WasmResolveApiFlushCloseRaceTest.java +++ b/openfeature-provider/java/src/test/java/com/spotify/confidence/sdk/WasmResolveApiFlushCloseRaceTest.java @@ -9,6 +9,7 @@ import com.spotify.confidence.sdk.flags.resolver.v1.Sdk; import com.spotify.confidence.sdk.flags.resolver.v1.SdkId; import com.spotify.confidence.sdk.flags.resolver.v1.WriteFlagLogsRequest; +import java.util.Map; import java.util.concurrent.CountDownLatch; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; @@ -44,7 +45,7 @@ void concurrentFlushAndCloseShouldNotLoseAssignments() throws Exception { for (int i = 0; i < iterations; i++) { final var logger = new CapturingWasmFlagLogger(); - final var resolver = new WasmLocalResolver(logger::write); + final var resolver = new WasmLocalResolver(logger::write, Map.of()); resolver.setResolverState(resolverState, accountId, null); // Resolve a flag to create a flag assignment in the WASM buffer diff --git a/openfeature-provider/js/demo.mjs b/openfeature-provider/js/demo.mjs new file mode 100644 index 000000000..56748ec8d --- /dev/null +++ b/openfeature-provider/js/demo.mjs @@ -0,0 +1,246 @@ +#!/usr/bin/env node +/** + * Demo: run the local provider, resolve flags, and inspect telemetry. + * + * Usage: + * node demo.mjs --secret [--encryption-key ] [--flag ] [--duration ] + * + * Examples: + * node demo.mjs --secret abc123 --flag web-sdk-e2e-flag --duration 60 + * node demo.mjs --secret abc123 --encryption-key deadbeef... --flag web-sdk-e2e-flag --duration 300 + */ + +import { OpenFeature } from '@openfeature/server-sdk'; +import { createConfidenceServerProvider } from './dist/index.node.js'; + +function parseArgs() { + const args = process.argv.slice(2); + const opts = { flags: [], duration: 60 }; + for (let i = 0; i < args.length; i++) { + switch (args[i]) { + case '--secret': + opts.secret = args[++i]; + break; + case '--encryption-key': + opts.encryptionKey = args[++i]; + break; + case '--flag': + opts.flags.push(args[++i]); + break; + case '--duration': + opts.duration = parseInt(args[++i], 10); + break; + } + } + if (!opts.secret) { + console.error('Usage: node demo.mjs --secret [--encryption-key ] [--flag ] [--duration ]'); + process.exit(1); + } + if (opts.flags.length === 0) opts.flags.push('web-sdk-e2e-flag'); + return opts; +} + +const opts = parseArgs(); + +console.log(`=== Confidence Provider Demo ===`); +console.log(` encryption: ${opts.encryptionKey ? 'enabled' : 'disabled'}`); +console.log(` flags: ${opts.flags.join(', ')}`); +console.log(` duration: ${opts.duration}s`); +console.log(); + +// Minimal protobuf varint + length-delimited decoder for TelemetryData inspection +function decodeProviderInitRate(buf) { + // Walk WriteFlagLogsRequest looking for field 2 (telemetry_data), + // then inside that look for field 9 (provider_init_rate) + let pos = 0; + while (pos < buf.length) { + const tag = buf[pos++]; + const fieldNum = tag >> 3; + const wireType = tag & 0x7; + if (wireType === 2) { + // length-delimited + let len = 0, + shift = 0; + while (buf[pos] & 0x80) { + len |= (buf[pos++] & 0x7f) << shift; + shift += 7; + } + len |= buf[pos++] << shift; + if (fieldNum === 2) { + // telemetry_data — recurse into it + const inner = buf.subarray(pos, pos + len); + const result = findInitRate(inner); + if (result) return result; + } + pos += len; + } else if (wireType === 0) { + // varint + while (buf[pos++] & 0x80) {} + } else { + break; + } + } + return null; +} + +function findInitRate(buf) { + let pos = 0; + while (pos < buf.length) { + const tag = buf[pos++]; + const fieldNum = tag >> 3; + const wireType = tag & 0x7; + if (wireType === 2) { + let len = 0, + shift = 0; + while (buf[pos] & 0x80) { + len |= (buf[pos++] & 0x7f) << shift; + shift += 7; + } + len |= buf[pos++] << shift; + if (fieldNum === 9) { + // provider_init_rate + return parseInitRate(buf.subarray(pos, pos + len)); + } + pos += len; + } else if (wireType === 0) { + while (buf[pos++] & 0x80) {} + } else { + break; + } + } + return null; +} + +function parseInitRate(buf) { + let count = 0; + const labels = {}; + let pos = 0; + while (pos < buf.length) { + const tag = buf[pos++]; + const fieldNum = tag >> 3; + const wireType = tag & 0x7; + if (wireType === 0 && fieldNum === 1) { + // count + count = 0; + let shift = 0; + while (buf[pos] & 0x80) { + count |= (buf[pos++] & 0x7f) << shift; + shift += 7; + } + count |= buf[pos++] << shift; + } else if (wireType === 2 && fieldNum === 3) { + // labels map entry + let len = 0, + shift = 0; + while (buf[pos] & 0x80) { + len |= (buf[pos++] & 0x7f) << shift; + shift += 7; + } + len |= buf[pos++] << shift; + const entry = parseMapEntry(buf.subarray(pos, pos + len)); + if (entry) labels[entry.key] = entry.value; + pos += len; + } else if (wireType === 2) { + let len = 0, + shift = 0; + while (buf[pos] & 0x80) { + len |= (buf[pos++] & 0x7f) << shift; + shift += 7; + } + len |= buf[pos++] << shift; + pos += len; + } else if (wireType === 0) { + while (buf[pos++] & 0x80) {} + } else { + break; + } + } + return { count, labels }; +} + +function parseMapEntry(buf) { + let key = '', + value = ''; + let pos = 0; + while (pos < buf.length) { + const tag = buf[pos++]; + const fieldNum = tag >> 3; + const wireType = tag & 0x7; + if (wireType === 2) { + let len = 0, + shift = 0; + while (buf[pos] & 0x80) { + len |= (buf[pos++] & 0x7f) << shift; + shift += 7; + } + len |= buf[pos++] << shift; + const str = new TextDecoder().decode(buf.subarray(pos, pos + len)); + if (fieldNum === 1) key = str; + else if (fieldNum === 2) value = str; + pos += len; + } else { + break; + } + } + return { key, value }; +} + +let flushCount = 0; + +// Intercept outgoing fetches to log telemetry +const originalFetch = globalThis.fetch; +const wrappedFetch = async (url, init) => { + let bodyBytes = null; + if (typeof url === 'string' && url.includes('clientFlagLogs') && init?.body) { + bodyBytes = new Uint8Array(init.body); + } + const resp = await originalFetch(url, init); + if (bodyBytes) { + flushCount++; + const initRate = decodeProviderInitRate(bodyBytes); + if (initRate) { + console.log( + `\n[flush #${flushCount}] → ${resp.status} ★ provider_init_rate: count=${ + initRate.count + }, labels=${JSON.stringify(initRate.labels)}`, + ); + } else { + console.log(`\n[flush #${flushCount}] → ${resp.status} (no provider_init_rate)`); + } + } + return resp; +}; + +const provider = createConfidenceServerProvider({ + flagClientSecret: opts.secret, + encryptionKey: opts.encryptionKey, + flushInterval: 10_000, + fetch: wrappedFetch, +}); + +console.log('Initializing provider...'); +await OpenFeature.setProviderAndWait(provider); +console.log('Provider ready.\n'); + +const client = OpenFeature.getClient(); +const endTime = Date.now() + opts.duration * 1000; +let resolveCount = 0; + +const ctx = { targetingKey: 'demo-user', sticky: false }; + +while (Date.now() < endTime) { + for (const flag of opts.flags) { + const result = client.getBooleanDetails(`${flag}.bool`, false, ctx); + resolveCount++; + } + + const remaining = Math.ceil((endTime - Date.now()) / 1000); + process.stdout.write(`\r resolves: ${resolveCount} | remaining: ${remaining}s `); + + await new Promise(r => setTimeout(r, 2000)); +} + +console.log(`\n\nDone. Total resolves: ${resolveCount}`); +console.log('Shutting down (final flush)...'); +await OpenFeature.close(); +console.log('Bye.'); diff --git a/openfeature-provider/js/proto/test-only.proto b/openfeature-provider/js/proto/test-only.proto index 99f266687..e5d59fef8 100644 --- a/openfeature-provider/js/proto/test-only.proto +++ b/openfeature-provider/js/proto/test-only.proto @@ -35,6 +35,14 @@ message TelemetryData { string resolver_version = 8; + repeated ProviderInitRate provider_init_rate = 9; + + message ProviderInitRate { + uint32 count = 1; + reserved 2; // status — tbd + map labels = 3; + } + message ResolveLatency { uint32 sum = 1; uint32 count = 2; diff --git a/openfeature-provider/js/src/ConfidenceServerProviderLocal.ts b/openfeature-provider/js/src/ConfidenceServerProviderLocal.ts index 22cf30df7..098eba903 100644 --- a/openfeature-provider/js/src/ConfidenceServerProviderLocal.ts +++ b/openfeature-provider/js/src/ConfidenceServerProviderLocal.ts @@ -77,6 +77,8 @@ export class ConfidenceServerProviderLocal implements Provider { private readonly stateUpdateInterval: number; private readonly flushInterval: number; private readonly materializationStore: MaterializationStore | null; + private readonly initLabels: Record; + private firstFlush = true; private stateEtag: string | null = null; private logDestinations: LogDestination[] = []; private accountId = ''; @@ -164,6 +166,7 @@ export class ConfidenceServerProviderLocal implements Provider { } else { this.materializationStore = null; } + this.initLabels = { encryption: options.encryptionKey ? 'true' : 'false' }; } async initialize(context?: EvaluationContext): Promise { @@ -375,8 +378,17 @@ export class ConfidenceServerProviderLocal implements Provider { // TODO should this return success/failure, or even throw? async flush(signal?: AbortSignal): Promise { - const writeFlagLogRequest = this.resolver.flushLogs(); + let writeFlagLogRequest = this.resolver.flushLogs(); if (writeFlagLogRequest.length > 0) { + if (this.firstFlush) { + this.firstFlush = false; + const decoded = WriteFlagLogsRequest.decode(writeFlagLogRequest); + if (!decoded.telemetryData) { + decoded.telemetryData = { providerInitRate: [] }; + } + decoded.telemetryData!.providerInitRate = [{ count: 1, labels: this.initLabels }]; + writeFlagLogRequest = WriteFlagLogsRequest.encode(decoded).finish(); + } await this.sendFlagLogs(writeFlagLogRequest, signal); } } diff --git a/openfeature-provider/proto/confidence/flags/resolver/v1/internal_api.proto b/openfeature-provider/proto/confidence/flags/resolver/v1/internal_api.proto index b82063440..10fa0c965 100644 --- a/openfeature-provider/proto/confidence/flags/resolver/v1/internal_api.proto +++ b/openfeature-provider/proto/confidence/flags/resolver/v1/internal_api.proto @@ -50,6 +50,14 @@ message IngestFlagLogsRequest { message TelemetryData { // Information about the SDK/provider Sdk sdk = 2; + + repeated ProviderInitRate provider_init_rate = 9; + + message ProviderInitRate { + uint32 count = 1; + reserved 2; // status — tbd + map labels = 3; + } } message ClientInfo { diff --git a/openfeature-provider/python/src/confidence/provider.py b/openfeature-provider/python/src/confidence/provider.py index 79e5bfd80..d4d015d16 100644 --- a/openfeature-provider/python/src/confidence/provider.py +++ b/openfeature-provider/python/src/confidence/provider.py @@ -35,7 +35,11 @@ VariantReadResult, VariantWriteOp, ) -from confidence.proto.confidence.flags.resolver.v1 import api_pb2, types_pb2 +from confidence.proto.confidence.flags.resolver.v1 import ( + api_pb2, + internal_api_pb2, + types_pb2, +) from confidence.proto.confidence.wasm import wasm_api_pb2 from confidence.state_fetcher import StateFetcher from confidence.version import __version__ @@ -165,6 +169,10 @@ def __init__( """ self._client_secret = client_secret self._encryption_key = encryption_key + self._init_labels: Dict[str, str] = { + "encryption": str(bool(encryption_key)).lower() + } + self._first_flush = True self._state_poll_interval = state_poll_interval self._log_poll_interval = log_poll_interval self._assign_poll_interval = assign_poll_interval @@ -322,7 +330,7 @@ def shutdown(self) -> None: # Flush final logs if self._resolver is not None: try: - log_data = self._resolver.flush_logs() + log_data = self._append_init(self._resolver.flush_logs()) if log_data and self._flag_logger is not None: self._flag_logger.write(log_data) except Exception as e: @@ -801,6 +809,19 @@ def _create_flag_logger( channel=self._grpc_channel, ) + def _append_init(self, log_data: bytes) -> bytes: + if self._first_flush and log_data: + self._first_flush = False + request = internal_api_pb2.WriteFlagLogsRequest() + request.ParseFromString(log_data) + init_rate = request.telemetry_data.provider_init_rate.add() + init_rate.count = 1 + for k, v in self._init_labels.items(): + init_rate.labels[k] = v + return request.SerializeToString() + self._first_flush = False + return log_data + def _flush_assigned(self) -> None: """Flush assigned logs.""" if self._resolver is None or self._flag_logger is None: @@ -872,6 +893,7 @@ def _state_poll_loop(self) -> None: self._enable_apply_dedup, self._disable_exposure_collection, ) + flushed_logs = self._append_init(flushed_logs) if flushed_logs and self._flag_logger is not None: self._flag_logger.write(flushed_logs) @@ -911,6 +933,7 @@ def _log_flush_loop(self) -> None: try: with self._resolver_lock: log_data = self._resolver.flush_logs() + log_data = self._append_init(log_data) if log_data and self._flag_logger is not None: self._flag_logger.write(log_data) except Exception as e: diff --git a/openfeature-provider/rust/AGENTS.md b/openfeature-provider/rust/AGENTS.md new file mode 120000 index 000000000..681311eb9 --- /dev/null +++ b/openfeature-provider/rust/AGENTS.md @@ -0,0 +1 @@ +CLAUDE.md \ No newline at end of file diff --git a/openfeature-provider/rust/src/logger.rs b/openfeature-provider/rust/src/logger.rs index 57721e6ea..511a8e40d 100644 --- a/openfeature-provider/rust/src/logger.rs +++ b/openfeature-provider/rust/src/logger.rs @@ -1,13 +1,17 @@ //! Log management for sending flag logs to the Confidence API. use std::sync::Arc; +use std::collections::BTreeMap; +use std::sync::atomic::{AtomicBool, Ordering}; use prost::Message; use reqwest_middleware::ClientWithMiddleware; use tokio::sync::RwLock; use confidence_resolver::assign_logger::AssignLogger; -use confidence_resolver::proto::confidence::flags::resolver::v1::{Sdk, WriteFlagLogsRequest}; +use confidence_resolver::proto::confidence::flags::resolver::v1::{ + telemetry_data::ProviderInitRate, Sdk, WriteFlagLogsRequest, +}; use confidence_resolver::resolve_logger::ResolveLogger; use crate::error::Result; @@ -156,19 +160,25 @@ fn encode_ingest_request(account_id: &str, batch: &[u8]) -> Vec { pub struct LogManager { sender: LogSender, sdk: Sdk, + init_labels: BTreeMap, + first_flush: AtomicBool, } impl LogManager { + /// Create a new log manager with the given client, client secret, and SDK identity. pub fn new( client: ClientWithMiddleware, client_secret: String, sdk: Sdk, account_id: Arc>>, destinations: Arc>>, + init_labels: BTreeMap, ) -> Self { Self { sender: LogSender::new(client, client_secret, account_id, destinations), sdk, + init_labels, + first_flush: AtomicBool::new(true), } } @@ -183,6 +193,12 @@ impl LogManager { let mut td = TELEMETRY.delta_snapshot(&LAST_FLUSHED); td.sdk = Some(self.sdk.clone()); + if self.first_flush.swap(false, Ordering::Relaxed) { + td.provider_init_rate.push(ProviderInitRate { + count: 1, + labels: self.init_labels.clone(), + }); + } request.telemetry_data = Some(td); let encoded = request.encode_to_vec(); diff --git a/openfeature-provider/rust/src/provider.rs b/openfeature-provider/rust/src/provider.rs index e2d1d3a9d..32edb0be4 100644 --- a/openfeature-provider/rust/src/provider.rs +++ b/openfeature-provider/rust/src/provider.rs @@ -1,6 +1,6 @@ //! OpenFeature provider implementation for Confidence. -use std::collections::HashMap; +use std::collections::{BTreeMap, HashMap}; use std::sync::Arc; use std::time::{Duration, Instant}; @@ -182,6 +182,7 @@ impl ConfidenceProvider { let client = client_builder.build(); let sdk = provider_sdk(); + let encryption_enabled = options.encryption_key.is_some(); let state_fetcher = Arc::new(StateFetcher::new( client.clone(), options.client_secret.clone(), @@ -189,12 +190,15 @@ impl ConfidenceProvider { options.encryption_key, )); let shared_state = Arc::new(SharedState::new()); + let init_labels = + BTreeMap::from([("encryption".to_string(), encryption_enabled.to_string())]); let log_manager = Arc::new(LogManager::new( client.clone(), options.client_secret.clone(), sdk, Arc::clone(&shared_state.account_id), Arc::clone(&shared_state.log_destinations), + init_labels, )); // Create materialization store if configured From 7b532e52925e83930295fa9f9b61a309d31dd17e Mon Sep 17 00:00:00 2001 From: Nicklas Lundin Date: Mon, 17 Aug 2026 10:07:42 +0200 Subject: [PATCH 2/9] style(java): format provider sources Co-Authored-By: Codex --- .../main/java/com/spotify/confidence/sdk/WasmLocalResolver.java | 1 - 1 file changed, 1 deletion(-) diff --git a/openfeature-provider/java/src/main/java/com/spotify/confidence/sdk/WasmLocalResolver.java b/openfeature-provider/java/src/main/java/com/spotify/confidence/sdk/WasmLocalResolver.java index f05555d88..2c287fc19 100644 --- a/openfeature-provider/java/src/main/java/com/spotify/confidence/sdk/WasmLocalResolver.java +++ b/openfeature-provider/java/src/main/java/com/spotify/confidence/sdk/WasmLocalResolver.java @@ -393,5 +393,4 @@ private interface ParserFn { T apply(byte[] data) throws InvalidProtocolBufferException; } - } From b44882b02cda87b285f768e3c7ab827db74743b3 Mon Sep 17 00:00:00 2001 From: Nicklas Lundin Date: Mon, 17 Aug 2026 12:05:26 +0200 Subject: [PATCH 3/9] fix: preserve provider init telemetry metadata --- .../local_resolver/default_resolver_test.go | 33 ++++++ .../internal/local_resolver/local_resolver.go | 10 +- .../internal/local_resolver/wasm.go | 9 +- .../proto/resolverinternal/internal_api.pb.go | 17 ++- .../go/confidence/provider_builder.go | 8 +- .../confidence/sdk/WasmLocalResolver.java | 7 +- .../sdk/WasmLocalResolverTelemetryTest.java | 29 +++++ .../src/ConfidenceServerProviderLocal.test.ts | 22 ++++ .../js/src/ConfidenceServerProviderLocal.ts | 6 +- .../flags/resolver/v1/internal_api.proto | 4 + .../flags/resolver/v1/internal_api_pb2.py | 104 +++++++++--------- .../python/src/confidence/provider.py | 6 + .../python/tests/test_provider.py | 21 ++++ 13 files changed, 216 insertions(+), 60 deletions(-) create mode 100644 openfeature-provider/java/src/test/java/com/spotify/confidence/sdk/WasmLocalResolverTelemetryTest.java diff --git a/openfeature-provider/go/confidence/internal/local_resolver/default_resolver_test.go b/openfeature-provider/go/confidence/internal/local_resolver/default_resolver_test.go index 82b0fedd4..24c77640f 100644 --- a/openfeature-provider/go/confidence/internal/local_resolver/default_resolver_test.go +++ b/openfeature-provider/go/confidence/internal/local_resolver/default_resolver_test.go @@ -10,6 +10,7 @@ import ( "github.com/spotify/confidence-resolver/openfeature-provider/go/confidence/internal/proto/wasm" tu "github.com/spotify/confidence-resolver/openfeature-provider/go/confidence/internal/testutil" + "google.golang.org/protobuf/proto" "google.golang.org/protobuf/types/known/structpb" ) @@ -44,6 +45,38 @@ func TestSwapWasmResolverApi_NewSwapWasmResolverApi(t *testing.T) { } +func TestFirstFlushIncludesInitSDK(t *testing.T) { + ctx := context.Background() + sdk := &resolver.Sdk{ + Sdk: &resolver.Sdk_Id{Id: resolver.SdkId_SDK_ID_GO_LOCAL_PROVIDER}, + Version: "test-version", + } + var flushed *resolverv1.WriteFlagLogsRequest + factory := NewWasmResolverFactoryWithLabels( + func(logs *resolverv1.WriteFlagLogsRequest) { flushed = logs }, + true, + map[string]string{"encryption": "true"}, + sdk, + ) + defer factory.Close(ctx) + + localResolver := factory.New() + defer localResolver.Close(ctx) + if err := localResolver.FlushAllLogs(); err != nil { + t.Fatalf("Failed to flush logs: %v", err) + } + + if flushed == nil || flushed.GetTelemetryData() == nil { + t.Fatal("Expected telemetry on first flush") + } + if got := flushed.GetTelemetryData().GetSdk(); !proto.Equal(got, sdk) { + t.Fatalf("Expected SDK %v, got %v", sdk, got) + } + if got := flushed.GetTelemetryData().GetProviderInitRate(); len(got) != 1 { + t.Fatalf("Expected one provider init rate, got %d", len(got)) + } +} + func TestSwapWasmResolverApi_WithRealState_InterpreterMode(t *testing.T) { ctx := context.Background() diff --git a/openfeature-provider/go/confidence/internal/local_resolver/local_resolver.go b/openfeature-provider/go/confidence/internal/local_resolver/local_resolver.go index 088c5cfb0..10abf7ea3 100644 --- a/openfeature-provider/go/confidence/internal/local_resolver/local_resolver.go +++ b/openfeature-provider/go/confidence/internal/local_resolver/local_resolver.go @@ -14,6 +14,8 @@ const DefaultPoolSize = 2 type LocalResolverConfig struct { PoolSize int UseWasmInterpreter bool + InitLabels map[string]string + InitSDK *resolver.Sdk } type LocalResolverSupplier func() LocalResolver @@ -39,7 +41,7 @@ type LocalResolver interface { // DefaultResolverFactory composes the default stack: Wasm -> Recovering -> Pooled(DefaultPoolSize) func DefaultResolverFactory(logSink LogSink, cfg LocalResolverConfig) LocalResolverFactory { - base := NewWasmResolverFactory(logSink, cfg.UseWasmInterpreter) + base := NewWasmResolverFactoryWithLabels(logSink, cfg.UseWasmInterpreter, cfg.InitLabels, cfg.InitSDK) rcv := NewRecoveringResolverFactory(base) poolSize := cfg.PoolSize if poolSize <= 0 { @@ -58,7 +60,7 @@ func NewLocalResolverWithPoolSize(ctx context.Context, logSink LogSink, poolSize } func NewLocalResolver(ctx context.Context, logSink LogSink, cfg LocalResolverConfig) LocalResolver { - return newLocalResolver(ctx, logSink, cfg, nil) + return newLocalResolver(ctx, logSink, cfg, cfg.InitLabels) } func NewLocalResolverWithLabels(ctx context.Context, logSink LogSink, cfg LocalResolverConfig, initLabels map[string]string) LocalResolver { @@ -71,8 +73,8 @@ func newLocalResolver(ctx context.Context, logSink LogSink, cfg LocalResolverCon poolSize = DefaultPoolSize } var factory LocalResolverFactory - if len(initLabels) > 0 { - factory = NewWasmResolverFactoryWithLabels(logSink, cfg.UseWasmInterpreter, initLabels) + if len(initLabels) > 0 || cfg.InitSDK != nil { + factory = NewWasmResolverFactoryWithLabels(logSink, cfg.UseWasmInterpreter, initLabels, cfg.InitSDK) } else { factory = NewWasmResolverFactory(logSink, cfg.UseWasmInterpreter) } diff --git a/openfeature-provider/go/confidence/internal/local_resolver/wasm.go b/openfeature-provider/go/confidence/internal/local_resolver/wasm.go index aaade9b99..3ec2dcf6d 100644 --- a/openfeature-provider/go/confidence/internal/local_resolver/wasm.go +++ b/openfeature-provider/go/confidence/internal/local_resolver/wasm.go @@ -43,6 +43,7 @@ type WasmResolver struct { instanceID string fnCache sync.Map initLabels map[string]string + initSDK *resolver.Sdk firstFlush bool } @@ -96,6 +97,9 @@ func (r *WasmResolver) FlushAllLogs() error { if resp.TelemetryData == nil { resp.TelemetryData = &resolverv1.TelemetryData{} } + if r.initSDK != nil { + resp.TelemetryData.Sdk = r.initSDK + } resp.TelemetryData.ProviderInitRate = append( resp.TelemetryData.ProviderInitRate, &resolverv1.TelemetryData_ProviderInitRate{Count: 1, Labels: r.initLabels}, @@ -177,6 +181,7 @@ type WasmResolverFactory struct { module wazero.CompiledModule logSink LogSink initLabels map[string]string + initSDK *resolver.Sdk } var _ LocalResolverFactory = (*WasmResolverFactory)(nil) @@ -233,9 +238,10 @@ func NewWasmResolverFactory(logSink LogSink, useInterpreter bool) LocalResolverF } } -func NewWasmResolverFactoryWithLabels(logSink LogSink, useInterpreter bool, initLabels map[string]string) LocalResolverFactory { +func NewWasmResolverFactoryWithLabels(logSink LogSink, useInterpreter bool, initLabels map[string]string, initSDK *resolver.Sdk) LocalResolverFactory { factory := NewWasmResolverFactory(logSink, useInterpreter).(*WasmResolverFactory) factory.initLabels = initLabels + factory.initSDK = initSDK return factory } @@ -253,6 +259,7 @@ func (wrf *WasmResolverFactory) New() LocalResolver { mu: &sync.Mutex{}, instanceID: fmt.Sprintf("%d", id), initLabels: wrf.initLabels, + initSDK: wrf.initSDK, firstFlush: true, } } diff --git a/openfeature-provider/go/confidence/internal/proto/resolverinternal/internal_api.pb.go b/openfeature-provider/go/confidence/internal/proto/resolverinternal/internal_api.pb.go index b8c3e8216..0050357cb 100644 --- a/openfeature-provider/go/confidence/internal/proto/resolverinternal/internal_api.pb.go +++ b/openfeature-provider/go/confidence/internal/proto/resolverinternal/internal_api.pb.go @@ -243,7 +243,10 @@ func (x *IngestFlagLogsRequest) GetBatch() *WriteFlagLogsRequest { type TelemetryData struct { state protoimpl.MessageState `protogen:"open.v1"` // Information about the SDK/provider - Sdk *resolver.Sdk `protobuf:"bytes,2,opt,name=sdk,proto3" json:"sdk,omitempty"` + Sdk *resolver.Sdk `protobuf:"bytes,2,opt,name=sdk,proto3" json:"sdk,omitempty"` + // Version of the embedded resolver (e.g. "0.14.0"). + // This must be preserved when providers decode and re-encode telemetry. + ResolverVersion string `protobuf:"bytes,8,opt,name=resolver_version,json=resolverVersion,proto3" json:"resolver_version,omitempty"` ProviderInitRate []*TelemetryData_ProviderInitRate `protobuf:"bytes,9,rep,name=provider_init_rate,json=providerInitRate,proto3" json:"provider_init_rate,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache @@ -286,6 +289,13 @@ func (x *TelemetryData) GetSdk() *resolver.Sdk { return nil } +func (x *TelemetryData) GetResolverVersion() string { + if x != nil { + return x.ResolverVersion + } + return "" +} + func (x *TelemetryData) GetProviderInitRate() []*TelemetryData_ProviderInitRate { if x != nil { return x.ProviderInitRate @@ -1572,9 +1582,10 @@ const file_confidence_flags_resolver_v1_internal_api_proto_rawDesc = "" + "\x15IngestFlagLogsRequest\x12\x1d\n" + "\n" + "account_id\x18\x01 \x01(\tR\taccountId\x12H\n" + - "\x05batch\x18\x02 \x01(\v22.confidence.flags.resolver.v1.WriteFlagLogsRequestR\x05batch\"\xfe\x02\n" + + "\x05batch\x18\x02 \x01(\v22.confidence.flags.resolver.v1.WriteFlagLogsRequestR\x05batch\"\xa9\x03\n" + "\rTelemetryData\x123\n" + - "\x03sdk\x18\x02 \x01(\v2!.confidence.flags.resolver.v1.SdkR\x03sdk\x12j\n" + + "\x03sdk\x18\x02 \x01(\v2!.confidence.flags.resolver.v1.SdkR\x03sdk\x12)\n" + + "\x10resolver_version\x18\b \x01(\tR\x0fresolverVersion\x12j\n" + "\x12provider_init_rate\x18\t \x03(\v2<.confidence.flags.resolver.v1.TelemetryData.ProviderInitRateR\x10providerInitRate\x1a\xcb\x01\n" + "\x10ProviderInitRate\x12\x14\n" + "\x05count\x18\x01 \x01(\rR\x05count\x12`\n" + diff --git a/openfeature-provider/go/confidence/provider_builder.go b/openfeature-provider/go/confidence/provider_builder.go index ae033f9f0..7a02608dc 100644 --- a/openfeature-provider/go/confidence/provider_builder.go +++ b/openfeature-provider/go/confidence/provider_builder.go @@ -11,6 +11,7 @@ import ( fl "github.com/spotify/confidence-resolver/openfeature-provider/go/confidence/internal/flag_logger" lr "github.com/spotify/confidence-resolver/openfeature-provider/go/confidence/internal/local_resolver" + resolvertypes "github.com/spotify/confidence-resolver/openfeature-provider/go/confidence/internal/proto/resolver" resolverv1 "github.com/spotify/confidence-resolver/openfeature-provider/go/confidence/internal/proto/resolverinternal" "google.golang.org/grpc" "google.golang.org/grpc/credentials" @@ -147,9 +148,14 @@ func newLocalResolverSupplier(poolSize int, useWasmInterpreter bool, initLabels cfg := lr.LocalResolverConfig{ PoolSize: poolSize, UseWasmInterpreter: useWasmInterpreter, + InitLabels: initLabels, + InitSDK: &resolvertypes.Sdk{ + Sdk: &resolvertypes.Sdk_Id{Id: resolvertypes.SdkId_SDK_ID_GO_LOCAL_PROVIDER}, + Version: Version, + }, } return func(ctx context.Context, logSink lr.LogSink) lr.LocalResolver { - return lr.NewLocalResolverWithLabels(ctx, logSink, cfg, initLabels) + return lr.NewLocalResolver(ctx, logSink, cfg) } } diff --git a/openfeature-provider/java/src/main/java/com/spotify/confidence/sdk/WasmLocalResolver.java b/openfeature-provider/java/src/main/java/com/spotify/confidence/sdk/WasmLocalResolver.java index 2c287fc19..fa90cb193 100644 --- a/openfeature-provider/java/src/main/java/com/spotify/confidence/sdk/WasmLocalResolver.java +++ b/openfeature-provider/java/src/main/java/com/spotify/confidence/sdk/WasmLocalResolver.java @@ -18,6 +18,7 @@ import com.spotify.confidence.sdk.flags.resolver.v1.ResolveProcessRequest; import com.spotify.confidence.sdk.flags.resolver.v1.ResolveProcessResponse; import com.spotify.confidence.sdk.flags.resolver.v1.Sdk; +import com.spotify.confidence.sdk.flags.resolver.v1.SdkId; import com.spotify.confidence.sdk.flags.resolver.v1.TelemetryData; import com.spotify.confidence.sdk.flags.resolver.v1.WriteFlagLogsRequest; import com.spotify.confidence.sdk.wasm.Messages; @@ -40,6 +41,8 @@ */ class WasmLocalResolver implements LocalResolver { private static final Logger logger = LoggerFactory.getLogger(WasmLocalResolver.class); + private static final Sdk SDK = + Sdk.newBuilder().setId(SdkId.SDK_ID_JAVA_LOCAL_PROVIDER).setVersion(Version.VERSION).build(); private static final AtomicInteger INSTANCE_COUNTER = new AtomicInteger(0); private final FunctionType HOST_FN_TYPE = FunctionType.of(List.of(ValType.I32), List.of(ValType.I32)); @@ -229,6 +232,7 @@ public void flushAllLogs() { request.toBuilder() .setTelemetryData( request.getTelemetryData().toBuilder() + .setSdk(SDK) .addProviderInitRate( TelemetryData.ProviderInitRate.newBuilder() .setCount(1) @@ -315,7 +319,8 @@ public String prometheusSnapshot() { private static boolean isEmptyLogRequest(WriteFlagLogsRequest request) { return request.getFlagAssignedCount() == 0 && request.getClientResolveInfoCount() == 0 - && request.getFlagResolveInfoCount() == 0; + && request.getFlagResolveInfoCount() == 0 + && !request.hasTelemetryData(); } private T consumeResponse(int addr, ParserFn codec) { diff --git a/openfeature-provider/java/src/test/java/com/spotify/confidence/sdk/WasmLocalResolverTelemetryTest.java b/openfeature-provider/java/src/test/java/com/spotify/confidence/sdk/WasmLocalResolverTelemetryTest.java new file mode 100644 index 000000000..2fc0e3bb5 --- /dev/null +++ b/openfeature-provider/java/src/test/java/com/spotify/confidence/sdk/WasmLocalResolverTelemetryTest.java @@ -0,0 +1,29 @@ +package com.spotify.confidence.sdk; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.spotify.confidence.sdk.flags.resolver.v1.SdkId; +import com.spotify.confidence.sdk.flags.resolver.v1.WriteFlagLogsRequest; +import java.util.ArrayList; +import java.util.Map; +import org.junit.jupiter.api.Test; + +class WasmLocalResolverTelemetryTest { + + @Test + void firstFlushIncludesProviderSdk() { + final var captured = new ArrayList(); + final var resolver = new WasmLocalResolver(captured::add, Map.of("encryption", "true")); + try { + resolver.flushAllLogs(); + } finally { + resolver.close(); + } + + assertThat(captured).isNotEmpty(); + final var telemetry = captured.get(0).getTelemetryData(); + assertThat(telemetry.getSdk().getId()).isEqualTo(SdkId.SDK_ID_JAVA_LOCAL_PROVIDER); + assertThat(telemetry.getSdk().getVersion()).isEqualTo(Version.VERSION); + assertThat(telemetry.getProviderInitRateCount()).isEqualTo(1); + } +} diff --git a/openfeature-provider/js/src/ConfidenceServerProviderLocal.test.ts b/openfeature-provider/js/src/ConfidenceServerProviderLocal.test.ts index c26298aff..6a24e2242 100644 --- a/openfeature-provider/js/src/ConfidenceServerProviderLocal.test.ts +++ b/openfeature-provider/js/src/ConfidenceServerProviderLocal.test.ts @@ -9,6 +9,7 @@ import { abortableSleep, TimeUnit, timeoutSignal } from './util'; import { advanceTimersUntil, NetworkMock } from './test-helpers'; import { sha256Hex } from './hash'; import { ResolveReason } from './proto/confidence/flags/resolver/v1/types'; +import { WriteFlagLogsRequest } from './proto/test-only'; vi.mock(import('./hash'), async () => { const { sha256Hex } = await import('./test-helpers'); @@ -156,6 +157,27 @@ describe('state update scheduling', () => { }); describe('flush behavior', () => { + it('preserves resolver version when adding provider init telemetry', async () => { + let sentBody: Uint8Array | undefined; + net.resolver.flagLogs.handler = async (req: Request) => { + sentBody = new Uint8Array(await req.arrayBuffer()); + return new Response(null, { status: 200 }); + }; + mockedWasmResolver.flushLogs.mockReturnValueOnce( + WriteFlagLogsRequest.encode( + WriteFlagLogsRequest.create({ telemetryData: { resolverVersion: '0.20.0' } }), + ).finish(), + ); + + await advanceTimersUntil(provider.flush()); + + expect(sentBody).toBeDefined(); + const decoded = WriteFlagLogsRequest.decode(sentBody!); + expect(decoded.telemetryData?.resolverVersion).toBe('0.20.0'); + expect(decoded.telemetryData?.sdk).toEqual({ id: 22, customId: undefined, version: '0.17.0' }); + expect(decoded.telemetryData?.providerInitRate).toEqual([{ count: 1, labels: { encryption: 'false' } }]); + }); + it('flushes periodically at the configured interval', async () => { await advanceTimersUntil(expect(provider.initialize()).resolves.toBeUndefined()); diff --git a/openfeature-provider/js/src/ConfidenceServerProviderLocal.ts b/openfeature-provider/js/src/ConfidenceServerProviderLocal.ts index 098eba903..340f91a95 100644 --- a/openfeature-provider/js/src/ConfidenceServerProviderLocal.ts +++ b/openfeature-provider/js/src/ConfidenceServerProviderLocal.ts @@ -384,8 +384,12 @@ export class ConfidenceServerProviderLocal implements Provider { this.firstFlush = false; const decoded = WriteFlagLogsRequest.decode(writeFlagLogRequest); if (!decoded.telemetryData) { - decoded.telemetryData = { providerInitRate: [] }; + decoded.telemetryData = { resolverVersion: '', providerInitRate: [] }; } + decoded.telemetryData.sdk ??= { + id: SdkId.SDK_ID_JS_LOCAL_SERVER_PROVIDER, + version: VERSION, + }; decoded.telemetryData!.providerInitRate = [{ count: 1, labels: this.initLabels }]; writeFlagLogRequest = WriteFlagLogsRequest.encode(decoded).finish(); } diff --git a/openfeature-provider/proto/confidence/flags/resolver/v1/internal_api.proto b/openfeature-provider/proto/confidence/flags/resolver/v1/internal_api.proto index 10fa0c965..8ba7bc835 100644 --- a/openfeature-provider/proto/confidence/flags/resolver/v1/internal_api.proto +++ b/openfeature-provider/proto/confidence/flags/resolver/v1/internal_api.proto @@ -51,6 +51,10 @@ message TelemetryData { // Information about the SDK/provider Sdk sdk = 2; + // Version of the embedded resolver (e.g. "0.14.0"). + // This must be preserved when providers decode and re-encode telemetry. + string resolver_version = 8; + repeated ProviderInitRate provider_init_rate = 9; message ProviderInitRate { diff --git a/openfeature-provider/python/src/confidence/proto/confidence/flags/resolver/v1/internal_api_pb2.py b/openfeature-provider/python/src/confidence/proto/confidence/flags/resolver/v1/internal_api_pb2.py index b1c029b86..a835c476f 100644 --- a/openfeature-provider/python/src/confidence/proto/confidence/flags/resolver/v1/internal_api_pb2.py +++ b/openfeature-provider/python/src/confidence/proto/confidence/flags/resolver/v1/internal_api_pb2.py @@ -26,7 +26,7 @@ from . import types_pb2 as confidence_dot_flags_dot_resolver_dot_v1_dot_types__pb2 -DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n/confidence/flags/resolver/v1/internal_api.proto\x12\x1c\x63onfidence.flags.resolver.v1\x1a\x1fgoogle/protobuf/timestamp.proto\x1a(confidence/flags/resolver/v1/types.proto\"\xb6\x02\n\x14WriteFlagLogsRequest\x12\x41\n\rflag_assigned\x18\x01 \x03(\x0b\x32*.confidence.flags.resolver.v1.FlagAssigned\x12\x43\n\x0etelemetry_data\x18\x02 \x01(\x0b\x32+.confidence.flags.resolver.v1.TelemetryData\x12L\n\x13\x63lient_resolve_info\x18\x03 \x03(\x0b\x32/.confidence.flags.resolver.v1.ClientResolveInfo\x12H\n\x11\x66lag_resolve_info\x18\x04 \x03(\x0b\x32-.confidence.flags.resolver.v1.FlagResolveInfo\"\x17\n\x15WriteFlagLogsResponse\"n\n\x15IngestFlagLogsRequest\x12\x12\n\naccount_id\x18\x01 \x01(\t\x12\x41\n\x05\x62\x61tch\x18\x02 \x01(\x0b\x32\x32.confidence.flags.resolver.v1.WriteFlagLogsRequest\"?\n\rTelemetryData\x12.\n\x03sdk\x18\x02 \x01(\x0b\x32!.confidence.flags.resolver.v1.Sdk\"g\n\nClientInfo\x12\x0e\n\x06\x63lient\x18\x01 \x01(\t\x12\x19\n\x11\x63lient_credential\x18\x02 \x01(\t\x12.\n\x03sdk\x18\x03 \x01(\x0b\x32!.confidence.flags.resolver.v1.Sdk\"\xa4\x07\n\x0c\x46lagAssigned\x12\x12\n\nresolve_id\x18\n \x01(\t\x12=\n\x0b\x63lient_info\x18\x03 \x01(\x0b\x32(.confidence.flags.resolver.v1.ClientInfo\x12\x45\n\x05\x66lags\x18\x0f \x03(\x0b\x32\x36.confidence.flags.resolver.v1.FlagAssigned.AppliedFlag\x1a\xbd\x03\n\x0b\x41ppliedFlag\x12\x0c\n\x04\x66lag\x18\x01 \x01(\t\x12\x15\n\rtargeting_key\x18\x02 \x01(\t\x12\x1e\n\x16targeting_key_selector\x18\x03 \x01(\t\x12T\n\x0f\x61ssignment_info\x18\x04 \x01(\x0b\x32\x39.confidence.flags.resolver.v1.FlagAssigned.AssignmentInfoH\x00\x12Z\n\x12\x64\x65\x66\x61ult_assignment\x18\x05 \x01(\x0b\x32<.confidence.flags.resolver.v1.FlagAssigned.DefaultAssignmentH\x00\x12\x15\n\rassignment_id\x18\x06 \x01(\t\x12\x0c\n\x04rule\x18\x07 \x01(\t\x12T\n\x17\x66\x61llthrough_assignments\x18\x08 \x03(\x0b\x32\x33.confidence.flags.resolver.v1.FallthroughAssignment\x12.\n\napply_time\x18\t \x01(\x0b\x32\x1a.google.protobuf.TimestampB\x0c\n\nassignment\x1a\x32\n\x0e\x41ssignmentInfo\x12\x0f\n\x07segment\x18\x01 \x01(\t\x12\x0f\n\x07variant\x18\x02 \x01(\t\x1a\x85\x02\n\x11\x44\x65\x66\x61ultAssignment\x12\x64\n\x06reason\x18\x01 \x01(\x0e\x32T.confidence.flags.resolver.v1.FlagAssigned.DefaultAssignment.DefaultAssignmentReason\"\x89\x01\n\x17\x44\x65\x66\x61ultAssignmentReason\x12)\n%DEFAULT_ASSIGNMENT_REASON_UNSPECIFIED\x10\x00\x12\x14\n\x10NO_SEGMENT_MATCH\x10\x01\x12\x1a\n\x12NO_TREATMENT_MATCH\x10\x02\x1a\x02\x08\x01\x12\x11\n\rFLAG_ARCHIVED\x10\x03\"s\n\x15\x46\x61llthroughAssignment\x12\x0c\n\x04rule\x18\x01 \x01(\t\x12\x15\n\rassignment_id\x18\x02 \x01(\t\x12\x15\n\rtargeting_key\x18\x03 \x01(\t\x12\x1e\n\x16targeting_key_selector\x18\x04 \x01(\t\"\xdf\x02\n\x11\x43lientResolveInfo\x12\x0e\n\x06\x63lient\x18\x01 \x01(\t\x12\x19\n\x11\x63lient_credential\x18\x02 \x01(\t\x12_\n\x06schema\x18\x03 \x03(\x0b\x32O.confidence.flags.resolver.v1.ClientResolveInfo.EvaluationContextSchemaInstance\x1a\xbd\x01\n\x1f\x45valuationContextSchemaInstance\x12k\n\x06schema\x18\x01 \x03(\x0b\x32[.confidence.flags.resolver.v1.ClientResolveInfo.EvaluationContextSchemaInstance.SchemaEntry\x1a-\n\x0bSchemaEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\x05:\x02\x38\x01\"\xb5\x01\n\x0f\x46lagResolveInfo\x12\x0c\n\x04\x66lag\x18\x01 \x01(\t\x12^\n\x14variant_resolve_info\x18\x02 \x03(\x0b\x32@.confidence.flags.resolver.v1.FlagResolveInfo.VariantResolveInfo\x1a\x34\n\x12VariantResolveInfo\x12\x0f\n\x07variant\x18\x01 \x01(\t\x12\r\n\x05\x63ount\x18\x03 \x01(\x03\"]\n\x16WriteOperationsRequest\x12\x43\n\x10store_variant_op\x18\x01 \x03(\x0b\x32).confidence.flags.resolver.v1.VariantData\"\x17\n\x15WriteOperationsResult\"D\n\rVariantReadOp\x12\x0c\n\x04unit\x18\x01 \x01(\t\x12\x17\n\x0fmaterialization\x18\x02 \x01(\t\x12\x0c\n\x04rule\x18\x03 \x01(\t\"8\n\x0fInclusionReadOp\x12\x0c\n\x04unit\x18\x01 \x01(\t\x12\x17\n\x0fmaterialization\x18\x02 \x01(\t\"\xa2\x01\n\x06ReadOp\x12\x46\n\x0fvariant_read_op\x18\x01 \x01(\x0b\x32+.confidence.flags.resolver.v1.VariantReadOpH\x00\x12J\n\x11inclusion_read_op\x18\x02 \x01(\x0b\x32-.confidence.flags.resolver.v1.InclusionReadOpH\x00\x42\x04\n\x02op\"J\n\x15ReadOperationsRequest\x12\x31\n\x03ops\x18\x03 \x03(\x0b\x32$.confidence.flags.resolver.v1.ReadOp\"S\n\x0bVariantData\x12\x0c\n\x04unit\x18\x01 \x01(\t\x12\x17\n\x0fmaterialization\x18\x02 \x01(\t\x12\x0c\n\x04rule\x18\x03 \x01(\t\x12\x0f\n\x07variant\x18\x04 \x01(\t\"K\n\rInclusionData\x12\x0c\n\x04unit\x18\x01 \x01(\t\x12\x17\n\x0fmaterialization\x18\x02 \x01(\t\x12\x13\n\x0bis_included\x18\x03 \x01(\x08\"\xa4\x01\n\nReadResult\x12\x43\n\x0evariant_result\x18\x01 \x01(\x0b\x32).confidence.flags.resolver.v1.VariantDataH\x00\x12G\n\x10inclusion_result\x18\x02 \x01(\x0b\x32+.confidence.flags.resolver.v1.InclusionDataH\x00\x42\x08\n\x06result\"Q\n\x14ReadOperationsResult\x12\x39\n\x07results\x18\x01 \x03(\x0b\x32(.confidence.flags.resolver.v1.ReadResult2\xb2\x03\n\x19InternalFlagLoggerService\x12~\n\x13\x43lientWriteFlagLogs\x12\x32.confidence.flags.resolver.v1.WriteFlagLogsRequest\x1a\x33.confidence.flags.resolver.v1.WriteFlagLogsResponse\x12\x8a\x01\n\x1bWriteMaterializedOperations\x12\x34.confidence.flags.resolver.v1.WriteOperationsRequest\x1a\x33.confidence.flags.resolver.v1.WriteOperationsResult\"\x00\x12\x87\x01\n\x1aReadMaterializedOperations\x12\x33.confidence.flags.resolver.v1.ReadOperationsRequest\x1a\x32.confidence.flags.resolver.v1.ReadOperationsResult\"\x00\x42\xad\x01\n,com.spotify.confidence.sdk.flags.resolver.v1B\x10InternalApiProtoP\x01Zigithub.com/spotify/confidence-resolver/openfeature-provider/go/confidence/internal/proto/resolverinternalb\x06proto3') +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n/confidence/flags/resolver/v1/internal_api.proto\x12\x1c\x63onfidence.flags.resolver.v1\x1a\x1fgoogle/protobuf/timestamp.proto\x1a(confidence/flags/resolver/v1/types.proto\"\xb6\x02\n\x14WriteFlagLogsRequest\x12\x41\n\rflag_assigned\x18\x01 \x03(\x0b\x32*.confidence.flags.resolver.v1.FlagAssigned\x12\x43\n\x0etelemetry_data\x18\x02 \x01(\x0b\x32+.confidence.flags.resolver.v1.TelemetryData\x12L\n\x13\x63lient_resolve_info\x18\x03 \x03(\x0b\x32/.confidence.flags.resolver.v1.ClientResolveInfo\x12H\n\x11\x66lag_resolve_info\x18\x04 \x03(\x0b\x32-.confidence.flags.resolver.v1.FlagResolveInfo\"\x17\n\x15WriteFlagLogsResponse\"n\n\x15IngestFlagLogsRequest\x12\x12\n\naccount_id\x18\x01 \x01(\t\x12\x41\n\x05\x62\x61tch\x18\x02 \x01(\x0b\x32\x32.confidence.flags.resolver.v1.WriteFlagLogsRequest\"\xe6\x02\n\rTelemetryData\x12.\n\x03sdk\x18\x02 \x01(\x0b\x32!.confidence.flags.resolver.v1.Sdk\x12\x18\n\x10resolver_version\x18\x08 \x01(\t\x12X\n\x12provider_init_rate\x18\t \x03(\x0b\x32<.confidence.flags.resolver.v1.TelemetryData.ProviderInitRate\x1a\xb0\x01\n\x10ProviderInitRate\x12\r\n\x05\x63ount\x18\x01 \x01(\r\x12X\n\x06labels\x18\x03 \x03(\x0b\x32H.confidence.flags.resolver.v1.TelemetryData.ProviderInitRate.LabelsEntry\x1a-\n\x0bLabelsEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01J\x04\x08\x02\x10\x03\"g\n\nClientInfo\x12\x0e\n\x06\x63lient\x18\x01 \x01(\t\x12\x19\n\x11\x63lient_credential\x18\x02 \x01(\t\x12.\n\x03sdk\x18\x03 \x01(\x0b\x32!.confidence.flags.resolver.v1.Sdk\"\xa4\x07\n\x0c\x46lagAssigned\x12\x12\n\nresolve_id\x18\n \x01(\t\x12=\n\x0b\x63lient_info\x18\x03 \x01(\x0b\x32(.confidence.flags.resolver.v1.ClientInfo\x12\x45\n\x05\x66lags\x18\x0f \x03(\x0b\x32\x36.confidence.flags.resolver.v1.FlagAssigned.AppliedFlag\x1a\xbd\x03\n\x0b\x41ppliedFlag\x12\x0c\n\x04\x66lag\x18\x01 \x01(\t\x12\x15\n\rtargeting_key\x18\x02 \x01(\t\x12\x1e\n\x16targeting_key_selector\x18\x03 \x01(\t\x12T\n\x0f\x61ssignment_info\x18\x04 \x01(\x0b\x32\x39.confidence.flags.resolver.v1.FlagAssigned.AssignmentInfoH\x00\x12Z\n\x12\x64\x65\x66\x61ult_assignment\x18\x05 \x01(\x0b\x32<.confidence.flags.resolver.v1.FlagAssigned.DefaultAssignmentH\x00\x12\x15\n\rassignment_id\x18\x06 \x01(\t\x12\x0c\n\x04rule\x18\x07 \x01(\t\x12T\n\x17\x66\x61llthrough_assignments\x18\x08 \x03(\x0b\x32\x33.confidence.flags.resolver.v1.FallthroughAssignment\x12.\n\napply_time\x18\t \x01(\x0b\x32\x1a.google.protobuf.TimestampB\x0c\n\nassignment\x1a\x32\n\x0e\x41ssignmentInfo\x12\x0f\n\x07segment\x18\x01 \x01(\t\x12\x0f\n\x07variant\x18\x02 \x01(\t\x1a\x85\x02\n\x11\x44\x65\x66\x61ultAssignment\x12\x64\n\x06reason\x18\x01 \x01(\x0e\x32T.confidence.flags.resolver.v1.FlagAssigned.DefaultAssignment.DefaultAssignmentReason\"\x89\x01\n\x17\x44\x65\x66\x61ultAssignmentReason\x12)\n%DEFAULT_ASSIGNMENT_REASON_UNSPECIFIED\x10\x00\x12\x14\n\x10NO_SEGMENT_MATCH\x10\x01\x12\x1a\n\x12NO_TREATMENT_MATCH\x10\x02\x1a\x02\x08\x01\x12\x11\n\rFLAG_ARCHIVED\x10\x03\"s\n\x15\x46\x61llthroughAssignment\x12\x0c\n\x04rule\x18\x01 \x01(\t\x12\x15\n\rassignment_id\x18\x02 \x01(\t\x12\x15\n\rtargeting_key\x18\x03 \x01(\t\x12\x1e\n\x16targeting_key_selector\x18\x04 \x01(\t\"\xdf\x02\n\x11\x43lientResolveInfo\x12\x0e\n\x06\x63lient\x18\x01 \x01(\t\x12\x19\n\x11\x63lient_credential\x18\x02 \x01(\t\x12_\n\x06schema\x18\x03 \x03(\x0b\x32O.confidence.flags.resolver.v1.ClientResolveInfo.EvaluationContextSchemaInstance\x1a\xbd\x01\n\x1f\x45valuationContextSchemaInstance\x12k\n\x06schema\x18\x01 \x03(\x0b\x32[.confidence.flags.resolver.v1.ClientResolveInfo.EvaluationContextSchemaInstance.SchemaEntry\x1a-\n\x0bSchemaEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\x05:\x02\x38\x01\"\xb5\x01\n\x0f\x46lagResolveInfo\x12\x0c\n\x04\x66lag\x18\x01 \x01(\t\x12^\n\x14variant_resolve_info\x18\x02 \x03(\x0b\x32@.confidence.flags.resolver.v1.FlagResolveInfo.VariantResolveInfo\x1a\x34\n\x12VariantResolveInfo\x12\x0f\n\x07variant\x18\x01 \x01(\t\x12\r\n\x05\x63ount\x18\x03 \x01(\x03\"]\n\x16WriteOperationsRequest\x12\x43\n\x10store_variant_op\x18\x01 \x03(\x0b\x32).confidence.flags.resolver.v1.VariantData\"\x17\n\x15WriteOperationsResult\"D\n\rVariantReadOp\x12\x0c\n\x04unit\x18\x01 \x01(\t\x12\x17\n\x0fmaterialization\x18\x02 \x01(\t\x12\x0c\n\x04rule\x18\x03 \x01(\t\"8\n\x0fInclusionReadOp\x12\x0c\n\x04unit\x18\x01 \x01(\t\x12\x17\n\x0fmaterialization\x18\x02 \x01(\t\"\xa2\x01\n\x06ReadOp\x12\x46\n\x0fvariant_read_op\x18\x01 \x01(\x0b\x32+.confidence.flags.resolver.v1.VariantReadOpH\x00\x12J\n\x11inclusion_read_op\x18\x02 \x01(\x0b\x32-.confidence.flags.resolver.v1.InclusionReadOpH\x00\x42\x04\n\x02op\"J\n\x15ReadOperationsRequest\x12\x31\n\x03ops\x18\x03 \x03(\x0b\x32$.confidence.flags.resolver.v1.ReadOp\"S\n\x0bVariantData\x12\x0c\n\x04unit\x18\x01 \x01(\t\x12\x17\n\x0fmaterialization\x18\x02 \x01(\t\x12\x0c\n\x04rule\x18\x03 \x01(\t\x12\x0f\n\x07variant\x18\x04 \x01(\t\"K\n\rInclusionData\x12\x0c\n\x04unit\x18\x01 \x01(\t\x12\x17\n\x0fmaterialization\x18\x02 \x01(\t\x12\x13\n\x0bis_included\x18\x03 \x01(\x08\"\xa4\x01\n\nReadResult\x12\x43\n\x0evariant_result\x18\x01 \x01(\x0b\x32).confidence.flags.resolver.v1.VariantDataH\x00\x12G\n\x10inclusion_result\x18\x02 \x01(\x0b\x32+.confidence.flags.resolver.v1.InclusionDataH\x00\x42\x08\n\x06result\"Q\n\x14ReadOperationsResult\x12\x39\n\x07results\x18\x01 \x03(\x0b\x32(.confidence.flags.resolver.v1.ReadResult2\xb2\x03\n\x19InternalFlagLoggerService\x12~\n\x13\x43lientWriteFlagLogs\x12\x32.confidence.flags.resolver.v1.WriteFlagLogsRequest\x1a\x33.confidence.flags.resolver.v1.WriteFlagLogsResponse\x12\x8a\x01\n\x1bWriteMaterializedOperations\x12\x34.confidence.flags.resolver.v1.WriteOperationsRequest\x1a\x33.confidence.flags.resolver.v1.WriteOperationsResult\"\x00\x12\x87\x01\n\x1aReadMaterializedOperations\x12\x33.confidence.flags.resolver.v1.ReadOperationsRequest\x1a\x32.confidence.flags.resolver.v1.ReadOperationsResult\"\x00\x42\xad\x01\n,com.spotify.confidence.sdk.flags.resolver.v1B\x10InternalApiProtoP\x01Zigithub.com/spotify/confidence-resolver/openfeature-provider/go/confidence/internal/proto/resolverinternalb\x06proto3') _globals = globals() _builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals) @@ -34,6 +34,8 @@ if not _descriptor._USE_C_DESCRIPTORS: _globals['DESCRIPTOR']._loaded_options = None _globals['DESCRIPTOR']._serialized_options = b'\n,com.spotify.confidence.sdk.flags.resolver.v1B\020InternalApiProtoP\001Zigithub.com/spotify/confidence-resolver/openfeature-provider/go/confidence/internal/proto/resolverinternal' + _globals['_TELEMETRYDATA_PROVIDERINITRATE_LABELSENTRY']._loaded_options = None + _globals['_TELEMETRYDATA_PROVIDERINITRATE_LABELSENTRY']._serialized_options = b'8\001' _globals['_FLAGASSIGNED_DEFAULTASSIGNMENT_DEFAULTASSIGNMENTREASON'].values_by_name["NO_TREATMENT_MATCH"]._loaded_options = None _globals['_FLAGASSIGNED_DEFAULTASSIGNMENT_DEFAULTASSIGNMENTREASON'].values_by_name["NO_TREATMENT_MATCH"]._serialized_options = b'\010\001' _globals['_CLIENTRESOLVEINFO_EVALUATIONCONTEXTSCHEMAINSTANCE_SCHEMAENTRY']._loaded_options = None @@ -44,52 +46,56 @@ _globals['_WRITEFLAGLOGSRESPONSE']._serialized_end=492 _globals['_INGESTFLAGLOGSREQUEST']._serialized_start=494 _globals['_INGESTFLAGLOGSREQUEST']._serialized_end=604 - _globals['_TELEMETRYDATA']._serialized_start=606 - _globals['_TELEMETRYDATA']._serialized_end=669 - _globals['_CLIENTINFO']._serialized_start=671 - _globals['_CLIENTINFO']._serialized_end=774 - _globals['_FLAGASSIGNED']._serialized_start=777 - _globals['_FLAGASSIGNED']._serialized_end=1709 - _globals['_FLAGASSIGNED_APPLIEDFLAG']._serialized_start=948 - _globals['_FLAGASSIGNED_APPLIEDFLAG']._serialized_end=1393 - _globals['_FLAGASSIGNED_ASSIGNMENTINFO']._serialized_start=1395 - _globals['_FLAGASSIGNED_ASSIGNMENTINFO']._serialized_end=1445 - _globals['_FLAGASSIGNED_DEFAULTASSIGNMENT']._serialized_start=1448 - _globals['_FLAGASSIGNED_DEFAULTASSIGNMENT']._serialized_end=1709 - _globals['_FLAGASSIGNED_DEFAULTASSIGNMENT_DEFAULTASSIGNMENTREASON']._serialized_start=1572 - _globals['_FLAGASSIGNED_DEFAULTASSIGNMENT_DEFAULTASSIGNMENTREASON']._serialized_end=1709 - _globals['_FALLTHROUGHASSIGNMENT']._serialized_start=1711 - _globals['_FALLTHROUGHASSIGNMENT']._serialized_end=1826 - _globals['_CLIENTRESOLVEINFO']._serialized_start=1829 - _globals['_CLIENTRESOLVEINFO']._serialized_end=2180 - _globals['_CLIENTRESOLVEINFO_EVALUATIONCONTEXTSCHEMAINSTANCE']._serialized_start=1991 - _globals['_CLIENTRESOLVEINFO_EVALUATIONCONTEXTSCHEMAINSTANCE']._serialized_end=2180 - _globals['_CLIENTRESOLVEINFO_EVALUATIONCONTEXTSCHEMAINSTANCE_SCHEMAENTRY']._serialized_start=2135 - _globals['_CLIENTRESOLVEINFO_EVALUATIONCONTEXTSCHEMAINSTANCE_SCHEMAENTRY']._serialized_end=2180 - _globals['_FLAGRESOLVEINFO']._serialized_start=2183 - _globals['_FLAGRESOLVEINFO']._serialized_end=2364 - _globals['_FLAGRESOLVEINFO_VARIANTRESOLVEINFO']._serialized_start=2312 - _globals['_FLAGRESOLVEINFO_VARIANTRESOLVEINFO']._serialized_end=2364 - _globals['_WRITEOPERATIONSREQUEST']._serialized_start=2366 - _globals['_WRITEOPERATIONSREQUEST']._serialized_end=2459 - _globals['_WRITEOPERATIONSRESULT']._serialized_start=2461 - _globals['_WRITEOPERATIONSRESULT']._serialized_end=2484 - _globals['_VARIANTREADOP']._serialized_start=2486 - _globals['_VARIANTREADOP']._serialized_end=2554 - _globals['_INCLUSIONREADOP']._serialized_start=2556 - _globals['_INCLUSIONREADOP']._serialized_end=2612 - _globals['_READOP']._serialized_start=2615 - _globals['_READOP']._serialized_end=2777 - _globals['_READOPERATIONSREQUEST']._serialized_start=2779 - _globals['_READOPERATIONSREQUEST']._serialized_end=2853 - _globals['_VARIANTDATA']._serialized_start=2855 - _globals['_VARIANTDATA']._serialized_end=2938 - _globals['_INCLUSIONDATA']._serialized_start=2940 - _globals['_INCLUSIONDATA']._serialized_end=3015 - _globals['_READRESULT']._serialized_start=3018 - _globals['_READRESULT']._serialized_end=3182 - _globals['_READOPERATIONSRESULT']._serialized_start=3184 - _globals['_READOPERATIONSRESULT']._serialized_end=3265 - _globals['_INTERNALFLAGLOGGERSERVICE']._serialized_start=3268 - _globals['_INTERNALFLAGLOGGERSERVICE']._serialized_end=3702 + _globals['_TELEMETRYDATA']._serialized_start=607 + _globals['_TELEMETRYDATA']._serialized_end=965 + _globals['_TELEMETRYDATA_PROVIDERINITRATE']._serialized_start=789 + _globals['_TELEMETRYDATA_PROVIDERINITRATE']._serialized_end=965 + _globals['_TELEMETRYDATA_PROVIDERINITRATE_LABELSENTRY']._serialized_start=914 + _globals['_TELEMETRYDATA_PROVIDERINITRATE_LABELSENTRY']._serialized_end=959 + _globals['_CLIENTINFO']._serialized_start=967 + _globals['_CLIENTINFO']._serialized_end=1070 + _globals['_FLAGASSIGNED']._serialized_start=1073 + _globals['_FLAGASSIGNED']._serialized_end=2005 + _globals['_FLAGASSIGNED_APPLIEDFLAG']._serialized_start=1244 + _globals['_FLAGASSIGNED_APPLIEDFLAG']._serialized_end=1689 + _globals['_FLAGASSIGNED_ASSIGNMENTINFO']._serialized_start=1691 + _globals['_FLAGASSIGNED_ASSIGNMENTINFO']._serialized_end=1741 + _globals['_FLAGASSIGNED_DEFAULTASSIGNMENT']._serialized_start=1744 + _globals['_FLAGASSIGNED_DEFAULTASSIGNMENT']._serialized_end=2005 + _globals['_FLAGASSIGNED_DEFAULTASSIGNMENT_DEFAULTASSIGNMENTREASON']._serialized_start=1868 + _globals['_FLAGASSIGNED_DEFAULTASSIGNMENT_DEFAULTASSIGNMENTREASON']._serialized_end=2005 + _globals['_FALLTHROUGHASSIGNMENT']._serialized_start=2007 + _globals['_FALLTHROUGHASSIGNMENT']._serialized_end=2122 + _globals['_CLIENTRESOLVEINFO']._serialized_start=2125 + _globals['_CLIENTRESOLVEINFO']._serialized_end=2476 + _globals['_CLIENTRESOLVEINFO_EVALUATIONCONTEXTSCHEMAINSTANCE']._serialized_start=2287 + _globals['_CLIENTRESOLVEINFO_EVALUATIONCONTEXTSCHEMAINSTANCE']._serialized_end=2476 + _globals['_CLIENTRESOLVEINFO_EVALUATIONCONTEXTSCHEMAINSTANCE_SCHEMAENTRY']._serialized_start=2431 + _globals['_CLIENTRESOLVEINFO_EVALUATIONCONTEXTSCHEMAINSTANCE_SCHEMAENTRY']._serialized_end=2476 + _globals['_FLAGRESOLVEINFO']._serialized_start=2479 + _globals['_FLAGRESOLVEINFO']._serialized_end=2660 + _globals['_FLAGRESOLVEINFO_VARIANTRESOLVEINFO']._serialized_start=2608 + _globals['_FLAGRESOLVEINFO_VARIANTRESOLVEINFO']._serialized_end=2660 + _globals['_WRITEOPERATIONSREQUEST']._serialized_start=2662 + _globals['_WRITEOPERATIONSREQUEST']._serialized_end=2755 + _globals['_WRITEOPERATIONSRESULT']._serialized_start=2757 + _globals['_WRITEOPERATIONSRESULT']._serialized_end=2780 + _globals['_VARIANTREADOP']._serialized_start=2782 + _globals['_VARIANTREADOP']._serialized_end=2850 + _globals['_INCLUSIONREADOP']._serialized_start=2852 + _globals['_INCLUSIONREADOP']._serialized_end=2908 + _globals['_READOP']._serialized_start=2911 + _globals['_READOP']._serialized_end=3073 + _globals['_READOPERATIONSREQUEST']._serialized_start=3075 + _globals['_READOPERATIONSREQUEST']._serialized_end=3149 + _globals['_VARIANTDATA']._serialized_start=3151 + _globals['_VARIANTDATA']._serialized_end=3234 + _globals['_INCLUSIONDATA']._serialized_start=3236 + _globals['_INCLUSIONDATA']._serialized_end=3311 + _globals['_READRESULT']._serialized_start=3314 + _globals['_READRESULT']._serialized_end=3478 + _globals['_READOPERATIONSRESULT']._serialized_start=3480 + _globals['_READOPERATIONSRESULT']._serialized_end=3561 + _globals['_INTERNALFLAGLOGGERSERVICE']._serialized_start=3564 + _globals['_INTERNALFLAGLOGGERSERVICE']._serialized_end=3998 # @@protoc_insertion_point(module_scope) diff --git a/openfeature-provider/python/src/confidence/provider.py b/openfeature-provider/python/src/confidence/provider.py index d4d015d16..219eb4ad9 100644 --- a/openfeature-provider/python/src/confidence/provider.py +++ b/openfeature-provider/python/src/confidence/provider.py @@ -814,6 +814,12 @@ def _append_init(self, log_data: bytes) -> bytes: self._first_flush = False request = internal_api_pb2.WriteFlagLogsRequest() request.ParseFromString(log_data) + request.telemetry_data.sdk.CopyFrom( + types_pb2.Sdk( + id=types_pb2.SdkId.SDK_ID_PYTHON_PROVIDER, + version=__version__, + ) + ) init_rate = request.telemetry_data.provider_init_rate.add() init_rate.count = 1 for k, v in self._init_labels.items(): diff --git a/openfeature-provider/python/tests/test_provider.py b/openfeature-provider/python/tests/test_provider.py index e07aaa03d..dc3507bee 100644 --- a/openfeature-provider/python/tests/test_provider.py +++ b/openfeature-provider/python/tests/test_provider.py @@ -5,6 +5,8 @@ from openfeature.flag_evaluation import FlagResolutionDetails, Reason from confidence.provider import ConfidenceProvider +from confidence.proto.confidence.flags.resolver.v1 import internal_api_pb2, types_pb2 +from confidence.version import __version__ from tests.conftest import MockFlagLogger, MockStateFetcher @@ -36,6 +38,25 @@ def test_get_metadata( class TestInitialize: """Tests for provider initialization.""" + def test_init_telemetry_includes_sdk( + self, + wasm_bytes: bytes, + test_client_secret: str, + ) -> None: + provider = ConfidenceProvider( + client_secret=test_client_secret, + wasm_bytes=wasm_bytes, + ) + request = internal_api_pb2.WriteFlagLogsRequest() + request.telemetry_data.SetInParent() + + encoded = provider._append_init(request.SerializeToString()) + decoded = internal_api_pb2.WriteFlagLogsRequest.FromString(encoded) + + assert decoded.telemetry_data.sdk.id == types_pb2.SdkId.SDK_ID_PYTHON_PROVIDER + assert decoded.telemetry_data.sdk.version == __version__ + assert len(decoded.telemetry_data.provider_init_rate) == 1 + def test_initialize_fetches_state( self, wasm_bytes: bytes, From 0e8456d1ac0a3e98e725ee10e114494a6fa35f4b Mon Sep 17 00:00:00 2001 From: Nicklas Lundin Date: Mon, 17 Aug 2026 15:31:14 +0200 Subject: [PATCH 4/9] fix: emit provider init telemetry once --- .../local_resolver/default_resolver_test.go | 33 --- .../internal/local_resolver/local_resolver.go | 19 +- .../internal/local_resolver/wasm.go | 34 +-- .../go/confidence/provider_builder.go | 12 +- .../go/confidence/provider_init_telemetry.go | 39 +++ .../provider_init_telemetry_test.go | 57 ++++ .../sdk/OpenFeatureLocalResolveProvider.java | 16 +- .../sdk/ProviderInitTelemetrySink.java | 52 ++++ .../confidence/sdk/WasmLocalResolver.java | 37 +-- .../sdk/ApplyFlagsErrorHandlingTest.java | 7 +- .../sdk/ProviderInitTelemetrySinkTest.java | 74 ++++++ .../spotify/confidence/sdk/ResolveTest.java | 2 +- .../sdk/WasmLocalResolverTelemetryTest.java | 29 --- .../confidence/sdk/WasmMemoryLeakTest.java | 3 +- .../sdk/WasmResolveApiFlushCloseRaceTest.java | 3 +- openfeature-provider/js/demo.mjs | 246 ------------------ .../src/ConfidenceServerProviderLocal.test.ts | 25 +- .../js/src/ConfidenceServerProviderLocal.ts | 20 +- .../python/src/confidence/provider.py | 69 ++--- .../python/tests/test_provider.py | 44 +++- openfeature-provider/rust/AGENTS.md | 1 - openfeature-provider/rust/src/logger.rs | 62 ++++- 22 files changed, 429 insertions(+), 455 deletions(-) create mode 100644 openfeature-provider/go/confidence/provider_init_telemetry.go create mode 100644 openfeature-provider/go/confidence/provider_init_telemetry_test.go create mode 100644 openfeature-provider/java/src/main/java/com/spotify/confidence/sdk/ProviderInitTelemetrySink.java create mode 100644 openfeature-provider/java/src/test/java/com/spotify/confidence/sdk/ProviderInitTelemetrySinkTest.java delete mode 100644 openfeature-provider/java/src/test/java/com/spotify/confidence/sdk/WasmLocalResolverTelemetryTest.java delete mode 100644 openfeature-provider/js/demo.mjs delete mode 120000 openfeature-provider/rust/AGENTS.md diff --git a/openfeature-provider/go/confidence/internal/local_resolver/default_resolver_test.go b/openfeature-provider/go/confidence/internal/local_resolver/default_resolver_test.go index 24c77640f..82b0fedd4 100644 --- a/openfeature-provider/go/confidence/internal/local_resolver/default_resolver_test.go +++ b/openfeature-provider/go/confidence/internal/local_resolver/default_resolver_test.go @@ -10,7 +10,6 @@ import ( "github.com/spotify/confidence-resolver/openfeature-provider/go/confidence/internal/proto/wasm" tu "github.com/spotify/confidence-resolver/openfeature-provider/go/confidence/internal/testutil" - "google.golang.org/protobuf/proto" "google.golang.org/protobuf/types/known/structpb" ) @@ -45,38 +44,6 @@ func TestSwapWasmResolverApi_NewSwapWasmResolverApi(t *testing.T) { } -func TestFirstFlushIncludesInitSDK(t *testing.T) { - ctx := context.Background() - sdk := &resolver.Sdk{ - Sdk: &resolver.Sdk_Id{Id: resolver.SdkId_SDK_ID_GO_LOCAL_PROVIDER}, - Version: "test-version", - } - var flushed *resolverv1.WriteFlagLogsRequest - factory := NewWasmResolverFactoryWithLabels( - func(logs *resolverv1.WriteFlagLogsRequest) { flushed = logs }, - true, - map[string]string{"encryption": "true"}, - sdk, - ) - defer factory.Close(ctx) - - localResolver := factory.New() - defer localResolver.Close(ctx) - if err := localResolver.FlushAllLogs(); err != nil { - t.Fatalf("Failed to flush logs: %v", err) - } - - if flushed == nil || flushed.GetTelemetryData() == nil { - t.Fatal("Expected telemetry on first flush") - } - if got := flushed.GetTelemetryData().GetSdk(); !proto.Equal(got, sdk) { - t.Fatalf("Expected SDK %v, got %v", sdk, got) - } - if got := flushed.GetTelemetryData().GetProviderInitRate(); len(got) != 1 { - t.Fatalf("Expected one provider init rate, got %d", len(got)) - } -} - func TestSwapWasmResolverApi_WithRealState_InterpreterMode(t *testing.T) { ctx := context.Background() diff --git a/openfeature-provider/go/confidence/internal/local_resolver/local_resolver.go b/openfeature-provider/go/confidence/internal/local_resolver/local_resolver.go index 10abf7ea3..17d35127d 100644 --- a/openfeature-provider/go/confidence/internal/local_resolver/local_resolver.go +++ b/openfeature-provider/go/confidence/internal/local_resolver/local_resolver.go @@ -14,8 +14,6 @@ const DefaultPoolSize = 2 type LocalResolverConfig struct { PoolSize int UseWasmInterpreter bool - InitLabels map[string]string - InitSDK *resolver.Sdk } type LocalResolverSupplier func() LocalResolver @@ -41,7 +39,7 @@ type LocalResolver interface { // DefaultResolverFactory composes the default stack: Wasm -> Recovering -> Pooled(DefaultPoolSize) func DefaultResolverFactory(logSink LogSink, cfg LocalResolverConfig) LocalResolverFactory { - base := NewWasmResolverFactoryWithLabels(logSink, cfg.UseWasmInterpreter, cfg.InitLabels, cfg.InitSDK) + base := NewWasmResolverFactory(logSink, cfg.UseWasmInterpreter) rcv := NewRecoveringResolverFactory(base) poolSize := cfg.PoolSize if poolSize <= 0 { @@ -60,24 +58,11 @@ func NewLocalResolverWithPoolSize(ctx context.Context, logSink LogSink, poolSize } func NewLocalResolver(ctx context.Context, logSink LogSink, cfg LocalResolverConfig) LocalResolver { - return newLocalResolver(ctx, logSink, cfg, cfg.InitLabels) -} - -func NewLocalResolverWithLabels(ctx context.Context, logSink LogSink, cfg LocalResolverConfig, initLabels map[string]string) LocalResolver { - return newLocalResolver(ctx, logSink, cfg, initLabels) -} - -func newLocalResolver(ctx context.Context, logSink LogSink, cfg LocalResolverConfig, initLabels map[string]string) LocalResolver { poolSize := cfg.PoolSize if poolSize <= 0 { poolSize = DefaultPoolSize } - var factory LocalResolverFactory - if len(initLabels) > 0 || cfg.InitSDK != nil { - factory = NewWasmResolverFactoryWithLabels(logSink, cfg.UseWasmInterpreter, initLabels, cfg.InitSDK) - } else { - factory = NewWasmResolverFactory(logSink, cfg.UseWasmInterpreter) - } + factory := NewWasmResolverFactory(logSink, cfg.UseWasmInterpreter) factory = NewRecoveringResolverFactory(factory) return &localResolverImpl{ PooledResolver: *NewPooledResolver(poolSize, factory.New), diff --git a/openfeature-provider/go/confidence/internal/local_resolver/wasm.go b/openfeature-provider/go/confidence/internal/local_resolver/wasm.go index 3ec2dcf6d..c028daa06 100644 --- a/openfeature-provider/go/confidence/internal/local_resolver/wasm.go +++ b/openfeature-provider/go/confidence/internal/local_resolver/wasm.go @@ -42,9 +42,6 @@ type WasmResolver struct { mu *sync.Mutex instanceID string fnCache sync.Map - initLabels map[string]string - initSDK *resolver.Sdk - firstFlush bool } var _ LocalResolver = (*WasmResolver)(nil) @@ -92,19 +89,6 @@ func (r *WasmResolver) ApplyFlags(request *resolver.ApplyFlagsRequest) error { func (r *WasmResolver) FlushAllLogs() error { resp := &resolverv1.WriteFlagLogsRequest{} err := r.call("wasm_msg_guest_bounded_flush_logs", nil, resp) - if err == nil && r.firstFlush { - r.firstFlush = false - if resp.TelemetryData == nil { - resp.TelemetryData = &resolverv1.TelemetryData{} - } - if r.initSDK != nil { - resp.TelemetryData.Sdk = r.initSDK - } - resp.TelemetryData.ProviderInitRate = append( - resp.TelemetryData.ProviderInitRate, - &resolverv1.TelemetryData_ProviderInitRate{Count: 1, Labels: r.initLabels}, - ) - } if err == nil && proto.Size(resp) > 0 { r.logSink(resp) } @@ -177,11 +161,9 @@ func (r *WasmResolver) call(fnName string, request proto.Message, response proto } type WasmResolverFactory struct { - runtime wazero.Runtime - module wazero.CompiledModule - logSink LogSink - initLabels map[string]string - initSDK *resolver.Sdk + runtime wazero.Runtime + module wazero.CompiledModule + logSink LogSink } var _ LocalResolverFactory = (*WasmResolverFactory)(nil) @@ -238,13 +220,6 @@ func NewWasmResolverFactory(logSink LogSink, useInterpreter bool) LocalResolverF } } -func NewWasmResolverFactoryWithLabels(logSink LogSink, useInterpreter bool, initLabels map[string]string, initSDK *resolver.Sdk) LocalResolverFactory { - factory := NewWasmResolverFactory(logSink, useInterpreter).(*WasmResolverFactory) - factory.initLabels = initLabels - factory.initSDK = initSDK - return factory -} - func (wrf *WasmResolverFactory) New() LocalResolver { ctx := context.Background() config := wazero.NewModuleConfig().WithName("") @@ -258,9 +233,6 @@ func (wrf *WasmResolverFactory) New() LocalResolver { logSink: wrf.logSink, mu: &sync.Mutex{}, instanceID: fmt.Sprintf("%d", id), - initLabels: wrf.initLabels, - initSDK: wrf.initSDK, - firstFlush: true, } } diff --git a/openfeature-provider/go/confidence/provider_builder.go b/openfeature-provider/go/confidence/provider_builder.go index 7a02608dc..266035ab9 100644 --- a/openfeature-provider/go/confidence/provider_builder.go +++ b/openfeature-provider/go/confidence/provider_builder.go @@ -11,7 +11,6 @@ import ( fl "github.com/spotify/confidence-resolver/openfeature-provider/go/confidence/internal/flag_logger" lr "github.com/spotify/confidence-resolver/openfeature-provider/go/confidence/internal/local_resolver" - resolvertypes "github.com/spotify/confidence-resolver/openfeature-provider/go/confidence/internal/proto/resolver" resolverv1 "github.com/spotify/confidence-resolver/openfeature-provider/go/confidence/internal/proto/resolverinternal" "google.golang.org/grpc" "google.golang.org/grpc/credentials" @@ -148,14 +147,13 @@ func newLocalResolverSupplier(poolSize int, useWasmInterpreter bool, initLabels cfg := lr.LocalResolverConfig{ PoolSize: poolSize, UseWasmInterpreter: useWasmInterpreter, - InitLabels: initLabels, - InitSDK: &resolvertypes.Sdk{ - Sdk: &resolvertypes.Sdk_Id{Id: resolvertypes.SdkId_SDK_ID_GO_LOCAL_PROVIDER}, - Version: Version, - }, } return func(ctx context.Context, logSink lr.LogSink) lr.LocalResolver { - return lr.NewLocalResolver(ctx, logSink, cfg) + return lr.NewLocalResolver( + ctx, + newProviderInitLogSink(logSink, initLabels), + cfg, + ) } } diff --git a/openfeature-provider/go/confidence/provider_init_telemetry.go b/openfeature-provider/go/confidence/provider_init_telemetry.go new file mode 100644 index 000000000..d1eeecd4f --- /dev/null +++ b/openfeature-provider/go/confidence/provider_init_telemetry.go @@ -0,0 +1,39 @@ +package confidence + +import ( + "sync" + + lr "github.com/spotify/confidence-resolver/openfeature-provider/go/confidence/internal/local_resolver" + resolvertypes "github.com/spotify/confidence-resolver/openfeature-provider/go/confidence/internal/proto/resolver" + resolverv1 "github.com/spotify/confidence-resolver/openfeature-provider/go/confidence/internal/proto/resolverinternal" +) + +// newProviderInitLogSink owns provider-init telemetry above the resolver pool. All pooled and +// recovered WASM instances share this sink, so one provider emits exactly one init event. +func newProviderInitLogSink(delegate lr.LogSink, labels map[string]string) lr.LogSink { + var mu sync.Mutex + sent := false + sdk := &resolvertypes.Sdk{ + Sdk: &resolvertypes.Sdk_Id{Id: resolvertypes.SdkId_SDK_ID_GO_LOCAL_PROVIDER}, + Version: Version, + } + + return func(logs *resolverv1.WriteFlagLogsRequest) { + mu.Lock() + defer mu.Unlock() + + if !sent { + if logs.TelemetryData == nil { + logs.TelemetryData = &resolverv1.TelemetryData{} + } + logs.TelemetryData.Sdk = sdk + logs.TelemetryData.ProviderInitRate = append( + logs.TelemetryData.ProviderInitRate, + &resolverv1.TelemetryData_ProviderInitRate{Count: 1, Labels: labels}, + ) + } + + delegate(logs) + sent = true + } +} diff --git a/openfeature-provider/go/confidence/provider_init_telemetry_test.go b/openfeature-provider/go/confidence/provider_init_telemetry_test.go new file mode 100644 index 000000000..6cf316a85 --- /dev/null +++ b/openfeature-provider/go/confidence/provider_init_telemetry_test.go @@ -0,0 +1,57 @@ +package confidence + +import ( + "testing" + + resolvertypes "github.com/spotify/confidence-resolver/openfeature-provider/go/confidence/internal/proto/resolver" + resolverv1 "github.com/spotify/confidence-resolver/openfeature-provider/go/confidence/internal/proto/resolverinternal" +) + +func TestProviderInitLogSinkEmitsOnceAcrossResolvers(t *testing.T) { + var captured []*resolverv1.WriteFlagLogsRequest + sink := newProviderInitLogSink( + func(logs *resolverv1.WriteFlagLogsRequest) { captured = append(captured, logs) }, + map[string]string{"encryption": "true"}, + ) + + sink(&resolverv1.WriteFlagLogsRequest{}) + sink(&resolverv1.WriteFlagLogsRequest{}) + sink(&resolverv1.WriteFlagLogsRequest{}) + + initEvents := 0 + for _, request := range captured { + initEvents += len(request.GetTelemetryData().GetProviderInitRate()) + } + if initEvents != 1 { + t.Fatalf("expected one provider init event, got %d", initEvents) + } + telemetry := captured[0].GetTelemetryData() + if telemetry.GetSdk().GetId() != resolvertypes.SdkId_SDK_ID_GO_LOCAL_PROVIDER { + t.Fatalf("unexpected SDK ID: %v", telemetry.GetSdk().GetId()) + } + if telemetry.GetSdk().GetVersion() != Version { + t.Fatalf("unexpected SDK version: %q", telemetry.GetSdk().GetVersion()) + } +} + +func TestProviderInitLogSinkRetriesAfterDelegatePanic(t *testing.T) { + attempts := 0 + var retried *resolverv1.WriteFlagLogsRequest + sink := newProviderInitLogSink(func(logs *resolverv1.WriteFlagLogsRequest) { + attempts++ + if attempts == 1 { + panic("send failed") + } + retried = logs + }, nil) + + func() { + defer func() { _ = recover() }() + sink(&resolverv1.WriteFlagLogsRequest{}) + }() + sink(&resolverv1.WriteFlagLogsRequest{}) + + if got := len(retried.GetTelemetryData().GetProviderInitRate()); got != 1 { + t.Fatalf("expected init telemetry to be retried, got %d events", got) + } +} diff --git a/openfeature-provider/java/src/main/java/com/spotify/confidence/sdk/OpenFeatureLocalResolveProvider.java b/openfeature-provider/java/src/main/java/com/spotify/confidence/sdk/OpenFeatureLocalResolveProvider.java index e28165c76..20c9b06e4 100644 --- a/openfeature-provider/java/src/main/java/com/spotify/confidence/sdk/OpenFeatureLocalResolveProvider.java +++ b/openfeature-provider/java/src/main/java/com/spotify/confidence/sdk/OpenFeatureLocalResolveProvider.java @@ -55,6 +55,7 @@ public class OpenFeatureLocalResolveProvider implements FeatureProvider { org.slf4j.LoggerFactory.getLogger(OpenFeatureLocalResolveProvider.class); private final LocalResolver resolver; private final WasmFlagLogger flagLogger; + private final ProviderInitTelemetrySink providerInitTelemetrySink; private final MaterializationStore materializationStore; private final boolean disableExposureCollection; private static final Duration ASSIGN_LOG_FLUSH_INTERVAL = Duration.ofMillis(100); @@ -165,6 +166,8 @@ public OpenFeatureLocalResolveProvider( this.flagLogger = wasmFlagLogger; final Map initLabels = Map.of("encryption", String.valueOf(config.getEncryptionKey() != null)); + this.providerInitTelemetrySink = + new ProviderInitTelemetrySink(flagLogger::write, SDK, initLabels); final int numInstances = PooledResolver.getNumInstances(config.getResolverPoolSize()); final LocalResolver inner = new PooledResolver( @@ -173,10 +176,9 @@ public OpenFeatureLocalResolveProvider( new RecoveringResolver( () -> new WasmLocalResolver( - flagLogger::write, + providerInitTelemetrySink, config.isEnableApplyDedup(), - config.isDisableExposureCollection(), - initLabels))); + config.isDisableExposureCollection()))); this.resolver = new MaterializingResolver(inner, materializationStore); } @@ -228,6 +230,8 @@ public OpenFeatureLocalResolveProvider( this.flagLogger = wasmFlagLogger; final int numInstances = PooledResolver.getNumInstances(LocalProviderConfig.DEFAULT_RESOLVER_POOL_SIZE); + this.providerInitTelemetrySink = + new ProviderInitTelemetrySink(wasmFlagLogger::write, SDK, Map.of()); final LocalResolver inner = new PooledResolver( numInstances, @@ -235,10 +239,9 @@ public OpenFeatureLocalResolveProvider( new RecoveringResolver( () -> new WasmLocalResolver( - wasmFlagLogger::write, + providerInitTelemetrySink, enableApplyDedup, - disableExposureCollection, - Map.of()))); + disableExposureCollection))); this.resolver = new MaterializingResolver(inner, materializationStore); } @@ -442,6 +445,7 @@ public void shutdown() { // resolver.close() flushes remaining logs via the log sink this.resolver.close(); + this.providerInitTelemetrySink.emitIfPending(); // flagLogger.shutdown() waits for pending async writes to complete this.flagLogger.shutdown(); diff --git a/openfeature-provider/java/src/main/java/com/spotify/confidence/sdk/ProviderInitTelemetrySink.java b/openfeature-provider/java/src/main/java/com/spotify/confidence/sdk/ProviderInitTelemetrySink.java new file mode 100644 index 000000000..07c33a849 --- /dev/null +++ b/openfeature-provider/java/src/main/java/com/spotify/confidence/sdk/ProviderInitTelemetrySink.java @@ -0,0 +1,52 @@ +package com.spotify.confidence.sdk; + +import com.spotify.confidence.sdk.flags.resolver.v1.Sdk; +import com.spotify.confidence.sdk.flags.resolver.v1.TelemetryData; +import com.spotify.confidence.sdk.flags.resolver.v1.WriteFlagLogsRequest; +import java.util.Map; +import java.util.function.Consumer; + +/** Adds provider-init telemetry once, above the resolver pool and recovery layers. */ +final class ProviderInitTelemetrySink implements Consumer { + private final Consumer delegate; + private final Sdk sdk; + private final Map labels; + private boolean sent; + + ProviderInitTelemetrySink( + Consumer delegate, Sdk sdk, Map labels) { + this.delegate = delegate; + this.sdk = sdk; + this.labels = Map.copyOf(labels); + } + + @Override + public synchronized void accept(WriteFlagLogsRequest request) { + final WriteFlagLogsRequest outgoing; + if (sent) { + outgoing = request; + } else { + outgoing = + request.toBuilder() + .setTelemetryData( + request.getTelemetryData().toBuilder() + .setSdk(sdk) + .addProviderInitRate( + TelemetryData.ProviderInitRate.newBuilder() + .setCount(1) + .putAllLabels(labels) + .build()) + .build()) + .build(); + } + + delegate.accept(outgoing); + sent = true; + } + + synchronized void emitIfPending() { + if (!sent) { + accept(WriteFlagLogsRequest.getDefaultInstance()); + } + } +} diff --git a/openfeature-provider/java/src/main/java/com/spotify/confidence/sdk/WasmLocalResolver.java b/openfeature-provider/java/src/main/java/com/spotify/confidence/sdk/WasmLocalResolver.java index fa90cb193..cb85f23aa 100644 --- a/openfeature-provider/java/src/main/java/com/spotify/confidence/sdk/WasmLocalResolver.java +++ b/openfeature-provider/java/src/main/java/com/spotify/confidence/sdk/WasmLocalResolver.java @@ -18,13 +18,10 @@ import com.spotify.confidence.sdk.flags.resolver.v1.ResolveProcessRequest; import com.spotify.confidence.sdk.flags.resolver.v1.ResolveProcessResponse; import com.spotify.confidence.sdk.flags.resolver.v1.Sdk; -import com.spotify.confidence.sdk.flags.resolver.v1.SdkId; -import com.spotify.confidence.sdk.flags.resolver.v1.TelemetryData; import com.spotify.confidence.sdk.flags.resolver.v1.WriteFlagLogsRequest; import com.spotify.confidence.sdk.wasm.Messages; import java.time.Instant; import java.util.List; -import java.util.Map; import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletionStage; import java.util.concurrent.atomic.AtomicInteger; @@ -41,8 +38,6 @@ */ class WasmLocalResolver implements LocalResolver { private static final Logger logger = LoggerFactory.getLogger(WasmLocalResolver.class); - private static final Sdk SDK = - Sdk.newBuilder().setId(SdkId.SDK_ID_JAVA_LOCAL_PROVIDER).setVersion(Version.VERSION).build(); private static final AtomicInteger INSTANCE_COUNTER = new AtomicInteger(0); private final FunctionType HOST_FN_TYPE = FunctionType.of(List.of(ValType.I32), List.of(ValType.I32)); @@ -54,8 +49,6 @@ class WasmLocalResolver implements LocalResolver { private final ExportFunction wasmMsgAlloc; private final ExportFunction wasmMsgFree; private final Consumer logSink; - private final Map initLabels; - private boolean firstFlush = true; // api private final ExportFunction wasmMsgGuestSetResolverState; @@ -70,29 +63,20 @@ class WasmLocalResolver implements LocalResolver { private final boolean disableExposureCollection; public WasmLocalResolver(Consumer logSink) { - this(logSink, false, false, Map.of()); + this(logSink, false, false); } public WasmLocalResolver(Consumer logSink, boolean enableApplyDedup) { - this(logSink, enableApplyDedup, false, Map.of()); + this(logSink, enableApplyDedup, false); } public WasmLocalResolver( Consumer logSink, boolean enableApplyDedup, boolean disableExposureCollection) { - this(logSink, enableApplyDedup, disableExposureCollection, Map.of()); - } - - public WasmLocalResolver( - Consumer logSink, - boolean enableApplyDedup, - boolean disableExposureCollection, - Map initLabels) { this.logSink = logSink; this.enableApplyDedup = enableApplyDedup; this.disableExposureCollection = disableExposureCollection; - this.initLabels = initLabels; this.instanceId = String.valueOf(INSTANCE_COUNTER.getAndIncrement()); instance = Instance.builder(ConfidenceResolverModule.load()) @@ -225,22 +209,7 @@ public void flushAllLogs() { final var voidRequest = Messages.Void.getDefaultInstance(); final var reqPtr = transferRequest(voidRequest); final var respPtr = (int) wasmMsgBoundedFlushLogs.apply(reqPtr)[0]; - var request = consumeResponse(respPtr, WriteFlagLogsRequest::parseFrom); - if (firstFlush) { - firstFlush = false; - request = - request.toBuilder() - .setTelemetryData( - request.getTelemetryData().toBuilder() - .setSdk(SDK) - .addProviderInitRate( - TelemetryData.ProviderInitRate.newBuilder() - .setCount(1) - .putAllLabels(initLabels) - .build()) - .build()) - .build(); - } + final var request = consumeResponse(respPtr, WriteFlagLogsRequest::parseFrom); if (!isEmptyLogRequest(request)) { logSink.accept(request); } diff --git a/openfeature-provider/java/src/test/java/com/spotify/confidence/sdk/ApplyFlagsErrorHandlingTest.java b/openfeature-provider/java/src/test/java/com/spotify/confidence/sdk/ApplyFlagsErrorHandlingTest.java index c2bee2a66..e0227a91c 100644 --- a/openfeature-provider/java/src/test/java/com/spotify/confidence/sdk/ApplyFlagsErrorHandlingTest.java +++ b/openfeature-provider/java/src/test/java/com/spotify/confidence/sdk/ApplyFlagsErrorHandlingTest.java @@ -25,7 +25,6 @@ import java.time.Instant; import java.util.ArrayList; import java.util.List; -import java.util.Map; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -155,7 +154,7 @@ private static Timestamp toTs(Instant t) { @Test void applyWithoutSendTime_isSwallowed_andRecordsNoFlagAssigned() { final List captured = new ArrayList<>(); - final var resolver = new WasmLocalResolver(captured::add, Map.of()); + final var resolver = new WasmLocalResolver(captured::add); resolver.setResolverState(buildState(), ACCOUNT, null); final var resolveResp = resolveWithApplyFalse(resolver); @@ -186,7 +185,7 @@ void applyWithoutSendTime_isSwallowed_andRecordsNoFlagAssigned() { @Test void applyWithSendTime_recordsOneFlagAssigned() { final List captured = new ArrayList<>(); - final var resolver = new WasmLocalResolver(captured::add, Map.of()); + final var resolver = new WasmLocalResolver(captured::add); resolver.setResolverState(buildState(), ACCOUNT, null); final var resolveResp = resolveWithApplyFalse(resolver); @@ -234,7 +233,7 @@ void applyWithSendTime_recordsOneFlagAssigned() { @Test void applyAfterStateRotated_secretRemoved_isSwallowedAndLogged() { final List captured = new ArrayList<>(); - final var resolver = new WasmLocalResolver(captured::add, Map.of()); + final var resolver = new WasmLocalResolver(captured::add); resolver.setResolverState(buildState(SECRET), ACCOUNT, null); final var resolveResp = resolveWithApplyFalse(resolver); diff --git a/openfeature-provider/java/src/test/java/com/spotify/confidence/sdk/ProviderInitTelemetrySinkTest.java b/openfeature-provider/java/src/test/java/com/spotify/confidence/sdk/ProviderInitTelemetrySinkTest.java new file mode 100644 index 000000000..b59dc5c1a --- /dev/null +++ b/openfeature-provider/java/src/test/java/com/spotify/confidence/sdk/ProviderInitTelemetrySinkTest.java @@ -0,0 +1,74 @@ +package com.spotify.confidence.sdk; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.spotify.confidence.sdk.flags.resolver.v1.Sdk; +import com.spotify.confidence.sdk.flags.resolver.v1.SdkId; +import com.spotify.confidence.sdk.flags.resolver.v1.WriteFlagLogsRequest; +import java.util.ArrayList; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.jupiter.api.Test; + +class ProviderInitTelemetrySinkTest { + private static final Sdk SDK = + Sdk.newBuilder().setId(SdkId.SDK_ID_JAVA_LOCAL_PROVIDER).setVersion("test-version").build(); + + @Test + void emitsOnceAcrossPooledResolvers() { + final var captured = new ArrayList(); + final var sink = + new ProviderInitTelemetrySink(captured::add, SDK, Map.of("encryption", "true")); + + sink.accept(WriteFlagLogsRequest.getDefaultInstance()); + sink.accept(WriteFlagLogsRequest.getDefaultInstance()); + sink.accept(WriteFlagLogsRequest.getDefaultInstance()); + + assertThat( + captured.stream() + .mapToInt(request -> request.getTelemetryData().getProviderInitRateCount()) + .sum()) + .isEqualTo(1); + final var telemetry = captured.get(0).getTelemetryData(); + assertThat(telemetry.getSdk()).isEqualTo(SDK); + assertThat(telemetry.getProviderInitRate(0).getLabelsMap()).containsEntry("encryption", "true"); + } + + @Test + void retriesAfterDelegateFailure() { + final var attempts = new AtomicInteger(); + final var captured = new ArrayList(); + final var sink = + new ProviderInitTelemetrySink( + request -> { + if (attempts.getAndIncrement() == 0) { + throw new IllegalStateException("send failed"); + } + captured.add(request); + }, + SDK, + Map.of()); + + assertThatThrownBy(() -> sink.accept(WriteFlagLogsRequest.getDefaultInstance())) + .isInstanceOf(IllegalStateException.class); + sink.accept(WriteFlagLogsRequest.getDefaultInstance()); + + assertThat(captured).hasSize(1); + assertThat(captured.get(0).getTelemetryData().getProviderInitRateCount()).isEqualTo(1); + } + + @Test + void shutdownBeforePeriodicFlushStillEmitsInit() { + final var captured = new ArrayList(); + final var sink = new ProviderInitTelemetrySink(captured::add, SDK, Map.of()); + + sink.emitIfPending(); + + assertThat( + captured.stream() + .mapToInt(request -> request.getTelemetryData().getProviderInitRateCount()) + .sum()) + .isEqualTo(1); + } +} diff --git a/openfeature-provider/java/src/test/java/com/spotify/confidence/sdk/ResolveTest.java b/openfeature-provider/java/src/test/java/com/spotify/confidence/sdk/ResolveTest.java index cbd7f5495..70f93095e 100644 --- a/openfeature-provider/java/src/test/java/com/spotify/confidence/sdk/ResolveTest.java +++ b/openfeature-provider/java/src/test/java/com/spotify/confidence/sdk/ResolveTest.java @@ -36,7 +36,7 @@ class ResolveTest { private final LocalResolver resolver; public ResolveTest() { - resolver = new WasmLocalResolver(request -> {}, Map.of()); + resolver = new WasmLocalResolver(request -> {}); } @BeforeEach diff --git a/openfeature-provider/java/src/test/java/com/spotify/confidence/sdk/WasmLocalResolverTelemetryTest.java b/openfeature-provider/java/src/test/java/com/spotify/confidence/sdk/WasmLocalResolverTelemetryTest.java deleted file mode 100644 index 2fc0e3bb5..000000000 --- a/openfeature-provider/java/src/test/java/com/spotify/confidence/sdk/WasmLocalResolverTelemetryTest.java +++ /dev/null @@ -1,29 +0,0 @@ -package com.spotify.confidence.sdk; - -import static org.assertj.core.api.Assertions.assertThat; - -import com.spotify.confidence.sdk.flags.resolver.v1.SdkId; -import com.spotify.confidence.sdk.flags.resolver.v1.WriteFlagLogsRequest; -import java.util.ArrayList; -import java.util.Map; -import org.junit.jupiter.api.Test; - -class WasmLocalResolverTelemetryTest { - - @Test - void firstFlushIncludesProviderSdk() { - final var captured = new ArrayList(); - final var resolver = new WasmLocalResolver(captured::add, Map.of("encryption", "true")); - try { - resolver.flushAllLogs(); - } finally { - resolver.close(); - } - - assertThat(captured).isNotEmpty(); - final var telemetry = captured.get(0).getTelemetryData(); - assertThat(telemetry.getSdk().getId()).isEqualTo(SdkId.SDK_ID_JAVA_LOCAL_PROVIDER); - assertThat(telemetry.getSdk().getVersion()).isEqualTo(Version.VERSION); - assertThat(telemetry.getProviderInitRateCount()).isEqualTo(1); - } -} diff --git a/openfeature-provider/java/src/test/java/com/spotify/confidence/sdk/WasmMemoryLeakTest.java b/openfeature-provider/java/src/test/java/com/spotify/confidence/sdk/WasmMemoryLeakTest.java index 5f28c179b..7982a1e69 100644 --- a/openfeature-provider/java/src/test/java/com/spotify/confidence/sdk/WasmMemoryLeakTest.java +++ b/openfeature-provider/java/src/test/java/com/spotify/confidence/sdk/WasmMemoryLeakTest.java @@ -10,7 +10,6 @@ import com.spotify.confidence.sdk.flags.resolver.v1.ResolveProcessRequest; import java.lang.reflect.Field; import java.util.List; -import java.util.Map; import org.junit.jupiter.api.Test; /** @@ -33,7 +32,7 @@ private static int getWasmMemoryPages(WasmLocalResolver resolver) { @Test void wasmMemoryStableOnRepeatedResolveCalls() { - WasmLocalResolver resolver = new WasmLocalResolver(request -> {}, Map.of()); + WasmLocalResolver resolver = new WasmLocalResolver(request -> {}); resolver.setResolverState(ResolveTest.exampleStateBytes, "account", null); ResolveProcessRequest request = diff --git a/openfeature-provider/java/src/test/java/com/spotify/confidence/sdk/WasmResolveApiFlushCloseRaceTest.java b/openfeature-provider/java/src/test/java/com/spotify/confidence/sdk/WasmResolveApiFlushCloseRaceTest.java index 933a66551..cdd632580 100644 --- a/openfeature-provider/java/src/test/java/com/spotify/confidence/sdk/WasmResolveApiFlushCloseRaceTest.java +++ b/openfeature-provider/java/src/test/java/com/spotify/confidence/sdk/WasmResolveApiFlushCloseRaceTest.java @@ -9,7 +9,6 @@ import com.spotify.confidence.sdk.flags.resolver.v1.Sdk; import com.spotify.confidence.sdk.flags.resolver.v1.SdkId; import com.spotify.confidence.sdk.flags.resolver.v1.WriteFlagLogsRequest; -import java.util.Map; import java.util.concurrent.CountDownLatch; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; @@ -45,7 +44,7 @@ void concurrentFlushAndCloseShouldNotLoseAssignments() throws Exception { for (int i = 0; i < iterations; i++) { final var logger = new CapturingWasmFlagLogger(); - final var resolver = new WasmLocalResolver(logger::write, Map.of()); + final var resolver = new WasmLocalResolver(logger::write); resolver.setResolverState(resolverState, accountId, null); // Resolve a flag to create a flag assignment in the WASM buffer diff --git a/openfeature-provider/js/demo.mjs b/openfeature-provider/js/demo.mjs deleted file mode 100644 index 56748ec8d..000000000 --- a/openfeature-provider/js/demo.mjs +++ /dev/null @@ -1,246 +0,0 @@ -#!/usr/bin/env node -/** - * Demo: run the local provider, resolve flags, and inspect telemetry. - * - * Usage: - * node demo.mjs --secret [--encryption-key ] [--flag ] [--duration ] - * - * Examples: - * node demo.mjs --secret abc123 --flag web-sdk-e2e-flag --duration 60 - * node demo.mjs --secret abc123 --encryption-key deadbeef... --flag web-sdk-e2e-flag --duration 300 - */ - -import { OpenFeature } from '@openfeature/server-sdk'; -import { createConfidenceServerProvider } from './dist/index.node.js'; - -function parseArgs() { - const args = process.argv.slice(2); - const opts = { flags: [], duration: 60 }; - for (let i = 0; i < args.length; i++) { - switch (args[i]) { - case '--secret': - opts.secret = args[++i]; - break; - case '--encryption-key': - opts.encryptionKey = args[++i]; - break; - case '--flag': - opts.flags.push(args[++i]); - break; - case '--duration': - opts.duration = parseInt(args[++i], 10); - break; - } - } - if (!opts.secret) { - console.error('Usage: node demo.mjs --secret [--encryption-key ] [--flag ] [--duration ]'); - process.exit(1); - } - if (opts.flags.length === 0) opts.flags.push('web-sdk-e2e-flag'); - return opts; -} - -const opts = parseArgs(); - -console.log(`=== Confidence Provider Demo ===`); -console.log(` encryption: ${opts.encryptionKey ? 'enabled' : 'disabled'}`); -console.log(` flags: ${opts.flags.join(', ')}`); -console.log(` duration: ${opts.duration}s`); -console.log(); - -// Minimal protobuf varint + length-delimited decoder for TelemetryData inspection -function decodeProviderInitRate(buf) { - // Walk WriteFlagLogsRequest looking for field 2 (telemetry_data), - // then inside that look for field 9 (provider_init_rate) - let pos = 0; - while (pos < buf.length) { - const tag = buf[pos++]; - const fieldNum = tag >> 3; - const wireType = tag & 0x7; - if (wireType === 2) { - // length-delimited - let len = 0, - shift = 0; - while (buf[pos] & 0x80) { - len |= (buf[pos++] & 0x7f) << shift; - shift += 7; - } - len |= buf[pos++] << shift; - if (fieldNum === 2) { - // telemetry_data — recurse into it - const inner = buf.subarray(pos, pos + len); - const result = findInitRate(inner); - if (result) return result; - } - pos += len; - } else if (wireType === 0) { - // varint - while (buf[pos++] & 0x80) {} - } else { - break; - } - } - return null; -} - -function findInitRate(buf) { - let pos = 0; - while (pos < buf.length) { - const tag = buf[pos++]; - const fieldNum = tag >> 3; - const wireType = tag & 0x7; - if (wireType === 2) { - let len = 0, - shift = 0; - while (buf[pos] & 0x80) { - len |= (buf[pos++] & 0x7f) << shift; - shift += 7; - } - len |= buf[pos++] << shift; - if (fieldNum === 9) { - // provider_init_rate - return parseInitRate(buf.subarray(pos, pos + len)); - } - pos += len; - } else if (wireType === 0) { - while (buf[pos++] & 0x80) {} - } else { - break; - } - } - return null; -} - -function parseInitRate(buf) { - let count = 0; - const labels = {}; - let pos = 0; - while (pos < buf.length) { - const tag = buf[pos++]; - const fieldNum = tag >> 3; - const wireType = tag & 0x7; - if (wireType === 0 && fieldNum === 1) { - // count - count = 0; - let shift = 0; - while (buf[pos] & 0x80) { - count |= (buf[pos++] & 0x7f) << shift; - shift += 7; - } - count |= buf[pos++] << shift; - } else if (wireType === 2 && fieldNum === 3) { - // labels map entry - let len = 0, - shift = 0; - while (buf[pos] & 0x80) { - len |= (buf[pos++] & 0x7f) << shift; - shift += 7; - } - len |= buf[pos++] << shift; - const entry = parseMapEntry(buf.subarray(pos, pos + len)); - if (entry) labels[entry.key] = entry.value; - pos += len; - } else if (wireType === 2) { - let len = 0, - shift = 0; - while (buf[pos] & 0x80) { - len |= (buf[pos++] & 0x7f) << shift; - shift += 7; - } - len |= buf[pos++] << shift; - pos += len; - } else if (wireType === 0) { - while (buf[pos++] & 0x80) {} - } else { - break; - } - } - return { count, labels }; -} - -function parseMapEntry(buf) { - let key = '', - value = ''; - let pos = 0; - while (pos < buf.length) { - const tag = buf[pos++]; - const fieldNum = tag >> 3; - const wireType = tag & 0x7; - if (wireType === 2) { - let len = 0, - shift = 0; - while (buf[pos] & 0x80) { - len |= (buf[pos++] & 0x7f) << shift; - shift += 7; - } - len |= buf[pos++] << shift; - const str = new TextDecoder().decode(buf.subarray(pos, pos + len)); - if (fieldNum === 1) key = str; - else if (fieldNum === 2) value = str; - pos += len; - } else { - break; - } - } - return { key, value }; -} - -let flushCount = 0; - -// Intercept outgoing fetches to log telemetry -const originalFetch = globalThis.fetch; -const wrappedFetch = async (url, init) => { - let bodyBytes = null; - if (typeof url === 'string' && url.includes('clientFlagLogs') && init?.body) { - bodyBytes = new Uint8Array(init.body); - } - const resp = await originalFetch(url, init); - if (bodyBytes) { - flushCount++; - const initRate = decodeProviderInitRate(bodyBytes); - if (initRate) { - console.log( - `\n[flush #${flushCount}] → ${resp.status} ★ provider_init_rate: count=${ - initRate.count - }, labels=${JSON.stringify(initRate.labels)}`, - ); - } else { - console.log(`\n[flush #${flushCount}] → ${resp.status} (no provider_init_rate)`); - } - } - return resp; -}; - -const provider = createConfidenceServerProvider({ - flagClientSecret: opts.secret, - encryptionKey: opts.encryptionKey, - flushInterval: 10_000, - fetch: wrappedFetch, -}); - -console.log('Initializing provider...'); -await OpenFeature.setProviderAndWait(provider); -console.log('Provider ready.\n'); - -const client = OpenFeature.getClient(); -const endTime = Date.now() + opts.duration * 1000; -let resolveCount = 0; - -const ctx = { targetingKey: 'demo-user', sticky: false }; - -while (Date.now() < endTime) { - for (const flag of opts.flags) { - const result = client.getBooleanDetails(`${flag}.bool`, false, ctx); - resolveCount++; - } - - const remaining = Math.ceil((endTime - Date.now()) / 1000); - process.stdout.write(`\r resolves: ${resolveCount} | remaining: ${remaining}s `); - - await new Promise(r => setTimeout(r, 2000)); -} - -console.log(`\n\nDone. Total resolves: ${resolveCount}`); -console.log('Shutting down (final flush)...'); -await OpenFeature.close(); -console.log('Bye.'); diff --git a/openfeature-provider/js/src/ConfidenceServerProviderLocal.test.ts b/openfeature-provider/js/src/ConfidenceServerProviderLocal.test.ts index 6a24e2242..1a4ce323c 100644 --- a/openfeature-provider/js/src/ConfidenceServerProviderLocal.test.ts +++ b/openfeature-provider/js/src/ConfidenceServerProviderLocal.test.ts @@ -178,6 +178,29 @@ describe('flush behavior', () => { expect(decoded.telemetryData?.providerInitRate).toEqual([{ count: 1, labels: { encryption: 'false' } }]); }); + it('retries provider init telemetry after a failed send', async () => { + const sentBodies: Uint8Array[] = []; + let attempts = 0; + net.resolver.flagLogs.handler = async (req: Request) => { + sentBodies.push(new Uint8Array(await req.arrayBuffer())); + attempts++; + return new Response(null, { status: attempts <= 3 ? 503 : 200 }); + }; + mockedWasmResolver.flushLogs.mockReturnValue( + WriteFlagLogsRequest.encode( + WriteFlagLogsRequest.create({ telemetryData: { resolverVersion: '0.20.0' } }), + ).finish(), + ); + + await advanceTimersUntil(expect(provider.flush()).rejects.toThrow('Failed to send flag logs')); + await advanceTimersUntil(provider.flush()); + + const firstAttempt = WriteFlagLogsRequest.decode(sentBodies[0]); + const retryAttempt = WriteFlagLogsRequest.decode(sentBodies[3]); + expect(firstAttempt.telemetryData?.providerInitRate).toHaveLength(1); + expect(retryAttempt.telemetryData?.providerInitRate).toHaveLength(1); + }); + it('flushes periodically at the configured interval', async () => { await advanceTimersUntil(expect(provider.initialize()).resolves.toBeUndefined()); @@ -197,7 +220,7 @@ describe('flush behavior', () => { net.resolver.flagLogs.status = 503; const start = net.resolver.flagLogs.calls; - await advanceTimersUntil(provider.flush()); + await advanceTimersUntil(expect(provider.flush()).rejects.toThrow('Failed to send flag logs')); const attempts = net.resolver.flagLogs.calls - start; expect(attempts).toBe(3); diff --git a/openfeature-provider/js/src/ConfidenceServerProviderLocal.ts b/openfeature-provider/js/src/ConfidenceServerProviderLocal.ts index 340f91a95..e70f972b1 100644 --- a/openfeature-provider/js/src/ConfidenceServerProviderLocal.ts +++ b/openfeature-provider/js/src/ConfidenceServerProviderLocal.ts @@ -78,7 +78,7 @@ export class ConfidenceServerProviderLocal implements Provider { private readonly flushInterval: number; private readonly materializationStore: MaterializationStore | null; private readonly initLabels: Record; - private firstFlush = true; + private initTelemetryState: 'pending' | 'sending' | 'sent' = 'pending'; private stateEtag: string | null = null; private logDestinations: LogDestination[] = []; private accountId = ''; @@ -380,8 +380,9 @@ export class ConfidenceServerProviderLocal implements Provider { async flush(signal?: AbortSignal): Promise { let writeFlagLogRequest = this.resolver.flushLogs(); if (writeFlagLogRequest.length > 0) { - if (this.firstFlush) { - this.firstFlush = false; + const includeInit = this.initTelemetryState === 'pending'; + if (includeInit) { + this.initTelemetryState = 'sending'; const decoded = WriteFlagLogsRequest.decode(writeFlagLogRequest); if (!decoded.telemetryData) { decoded.telemetryData = { resolverVersion: '', providerInitRate: [] }; @@ -393,7 +394,17 @@ export class ConfidenceServerProviderLocal implements Provider { decoded.telemetryData!.providerInitRate = [{ count: 1, labels: this.initLabels }]; writeFlagLogRequest = WriteFlagLogsRequest.encode(decoded).finish(); } - await this.sendFlagLogs(writeFlagLogRequest, signal); + try { + await this.sendFlagLogs(writeFlagLogRequest, signal); + if (includeInit) { + this.initTelemetryState = 'sent'; + } + } catch (error) { + if (includeInit) { + this.initTelemetryState = 'pending'; + } + throw error; + } } } @@ -418,6 +429,7 @@ export class ConfidenceServerProviderLocal implements Provider { logger.warn('Primary flag log destination returned error, trying fallback'); continue; } + throw new Error('Failed to send flag logs to all destinations'); } catch (err) { if (!isLast) { logger.warn('Primary flag log destination failed, trying fallback', err); diff --git a/openfeature-provider/python/src/confidence/provider.py b/openfeature-provider/python/src/confidence/provider.py index 219eb4ad9..28562454a 100644 --- a/openfeature-provider/python/src/confidence/provider.py +++ b/openfeature-provider/python/src/confidence/provider.py @@ -172,7 +172,8 @@ def __init__( self._init_labels: Dict[str, str] = { "encryption": str(bool(encryption_key)).lower() } - self._first_flush = True + self._init_telemetry_state = "pending" + self._init_telemetry_lock = threading.Lock() self._state_poll_interval = state_poll_interval self._log_poll_interval = log_poll_interval self._assign_poll_interval = assign_poll_interval @@ -330,9 +331,7 @@ def shutdown(self) -> None: # Flush final logs if self._resolver is not None: try: - log_data = self._append_init(self._resolver.flush_logs()) - if log_data and self._flag_logger is not None: - self._flag_logger.write(log_data) + self._write_logs(self._resolver.flush_logs()) except Exception as e: logger.error("Failed to flush final logs: %s", e) @@ -778,6 +777,41 @@ def _write_materializations( except Exception as e: logger.error("Failed to write materializations: %s", e) + def _write_logs(self, log_data: bytes) -> None: + if not log_data or self._flag_logger is None: + return + + include_init = False + with self._init_telemetry_lock: + if self._init_telemetry_state == "pending": + self._init_telemetry_state = "sending" + include_init = True + + if include_init: + request = internal_api_pb2.WriteFlagLogsRequest.FromString(log_data) + request.telemetry_data.sdk.CopyFrom( + types_pb2.Sdk( + id=types_pb2.SdkId.SDK_ID_PYTHON_PROVIDER, + version=__version__, + ) + ) + init_rate = request.telemetry_data.provider_init_rate.add() + init_rate.count = 1 + for k, v in self._init_labels.items(): + init_rate.labels[k] = v + log_data = request.SerializeToString() + + try: + self._flag_logger.write(log_data) + except Exception: + if include_init: + with self._init_telemetry_lock: + self._init_telemetry_state = "pending" + raise + else: + if include_init: + with self._init_telemetry_lock: + self._init_telemetry_state = "sent" def _create_flag_logger( self, account_id: str, log_destinations: List[int] ) -> FlagLogger: @@ -809,25 +843,6 @@ def _create_flag_logger( channel=self._grpc_channel, ) - def _append_init(self, log_data: bytes) -> bytes: - if self._first_flush and log_data: - self._first_flush = False - request = internal_api_pb2.WriteFlagLogsRequest() - request.ParseFromString(log_data) - request.telemetry_data.sdk.CopyFrom( - types_pb2.Sdk( - id=types_pb2.SdkId.SDK_ID_PYTHON_PROVIDER, - version=__version__, - ) - ) - init_rate = request.telemetry_data.provider_init_rate.add() - init_rate.count = 1 - for k, v in self._init_labels.items(): - init_rate.labels[k] = v - return request.SerializeToString() - self._first_flush = False - return log_data - def _flush_assigned(self) -> None: """Flush assigned logs.""" if self._resolver is None or self._flag_logger is None: @@ -899,9 +914,7 @@ def _state_poll_loop(self) -> None: self._enable_apply_dedup, self._disable_exposure_collection, ) - flushed_logs = self._append_init(flushed_logs) - if flushed_logs and self._flag_logger is not None: - self._flag_logger.write(flushed_logs) + self._write_logs(flushed_logs) # Update account ID and destinations on the logger if self._flag_logger is not None: @@ -939,9 +952,7 @@ def _log_flush_loop(self) -> None: try: with self._resolver_lock: log_data = self._resolver.flush_logs() - log_data = self._append_init(log_data) - if log_data and self._flag_logger is not None: - self._flag_logger.write(log_data) + self._write_logs(log_data) except Exception as e: logger.error("Failed to flush logs: %s", e) last_full_flush = now diff --git a/openfeature-provider/python/tests/test_provider.py b/openfeature-provider/python/tests/test_provider.py index dc3507bee..683dc45f8 100644 --- a/openfeature-provider/python/tests/test_provider.py +++ b/openfeature-provider/python/tests/test_provider.py @@ -45,18 +45,58 @@ def test_init_telemetry_includes_sdk( ) -> None: provider = ConfidenceProvider( client_secret=test_client_secret, + flag_logger=MockFlagLogger(), wasm_bytes=wasm_bytes, ) request = internal_api_pb2.WriteFlagLogsRequest() request.telemetry_data.SetInParent() - encoded = provider._append_init(request.SerializeToString()) - decoded = internal_api_pb2.WriteFlagLogsRequest.FromString(encoded) + provider._write_logs(request.SerializeToString()) + decoded = internal_api_pb2.WriteFlagLogsRequest.FromString( + provider._flag_logger.writes[0] + ) assert decoded.telemetry_data.sdk.id == types_pb2.SdkId.SDK_ID_PYTHON_PROVIDER assert decoded.telemetry_data.sdk.version == __version__ assert len(decoded.telemetry_data.provider_init_rate) == 1 + def test_init_telemetry_retries_after_failed_write( + self, + wasm_bytes: bytes, + test_client_secret: str, + ) -> None: + class FailOnceLogger(MockFlagLogger): + def __init__(self) -> None: + super().__init__() + self.attempts = 0 + + def write(self, request_bytes: bytes) -> None: + self.attempts += 1 + if self.attempts == 1: + raise RuntimeError("send failed") + super().write(request_bytes) + + mock_logger = FailOnceLogger() + provider = ConfidenceProvider( + client_secret=test_client_secret, + flag_logger=mock_logger, + wasm_bytes=wasm_bytes, + ) + request = internal_api_pb2.WriteFlagLogsRequest() + request.telemetry_data.SetInParent() + encoded = request.SerializeToString() + + try: + provider._write_logs(encoded) + except RuntimeError: + pass + provider._write_logs(encoded) + + decoded = internal_api_pb2.WriteFlagLogsRequest.FromString( + mock_logger.writes[0] + ) + assert len(decoded.telemetry_data.provider_init_rate) == 1 + def test_initialize_fetches_state( self, wasm_bytes: bytes, diff --git a/openfeature-provider/rust/AGENTS.md b/openfeature-provider/rust/AGENTS.md deleted file mode 120000 index 681311eb9..000000000 --- a/openfeature-provider/rust/AGENTS.md +++ /dev/null @@ -1 +0,0 @@ -CLAUDE.md \ No newline at end of file diff --git a/openfeature-provider/rust/src/logger.rs b/openfeature-provider/rust/src/logger.rs index 511a8e40d..b492e0b40 100644 --- a/openfeature-provider/rust/src/logger.rs +++ b/openfeature-provider/rust/src/logger.rs @@ -1,8 +1,8 @@ //! Log management for sending flag logs to the Confidence API. -use std::sync::Arc; use std::collections::BTreeMap; -use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::atomic::{AtomicU8, Ordering}; +use std::sync::Arc; use prost::Message; use reqwest_middleware::ClientWithMiddleware; @@ -24,6 +24,35 @@ const CLOUDFLARE_URL: &str = /// Target size for log batches (4 MB). const LOG_TARGET_BYTES: usize = 4 * 1024 * 1024; +const INIT_PENDING: u8 = 0; +const INIT_SENDING: u8 = 1; +const INIT_SENT: u8 = 2; + +struct InitTelemetryState(AtomicU8); + +impl InitTelemetryState { + fn new() -> Self { + Self(AtomicU8::new(INIT_PENDING)) + } + + fn claim(&self) -> bool { + self.0 + .compare_exchange( + INIT_PENDING, + INIT_SENDING, + Ordering::AcqRel, + Ordering::Acquire, + ) + .is_ok() + } + + fn complete(&self, success: bool) { + self.0.store( + if success { INIT_SENT } else { INIT_PENDING }, + Ordering::Release, + ); + } +} /// Log sender that sends flag logs to the Confidence API. pub struct LogSender { @@ -161,7 +190,7 @@ pub struct LogManager { sender: LogSender, sdk: Sdk, init_labels: BTreeMap, - first_flush: AtomicBool, + init_state: InitTelemetryState, } impl LogManager { @@ -178,7 +207,7 @@ impl LogManager { sender: LogSender::new(client, client_secret, account_id, destinations), sdk, init_labels, - first_flush: AtomicBool::new(true), + init_state: InitTelemetryState::new(), } } @@ -193,7 +222,8 @@ impl LogManager { let mut td = TELEMETRY.delta_snapshot(&LAST_FLUSHED); td.sdk = Some(self.sdk.clone()); - if self.first_flush.swap(false, Ordering::Relaxed) { + let include_init = self.init_state.claim(); + if include_init { td.provider_init_rate.push(ProviderInitRate { count: 1, labels: self.init_labels.clone(), @@ -203,7 +233,15 @@ impl LogManager { let encoded = request.encode_to_vec(); if !encoded.is_empty() && has_logs(&request) { - self.sender.send(&encoded).await?; + if let Err(error) = self.sender.send(&encoded).await { + if include_init { + self.init_state.complete(false); + } + return Err(error); + } + if include_init { + self.init_state.complete(true); + } } Ok(()) @@ -263,4 +301,16 @@ mod tests { assert_eq!(decoded.account_id, "acct"); assert!(decoded.batch.is_empty()); } + + #[test] + fn init_telemetry_state_retries_failure_and_stops_after_success() { + let state = InitTelemetryState::new(); + + assert!(state.claim()); + assert!(!state.claim()); + state.complete(false); + assert!(state.claim()); + state.complete(true); + assert!(!state.claim()); + } } From 787b1881b215d9a2b9ae51959e98b77cb8f3dd69 Mon Sep 17 00:00:00 2001 From: Nicklas Lundin Date: Wed, 19 Aug 2026 14:33:11 +0200 Subject: [PATCH 5/9] fix: emit provider telemetry above resolver pools Co-Authored-By: Codex --- .../confidence/internal/flag_logger/grpc.go | 2 +- .../confidence/internal/flag_logger/multi.go | 2 +- .../internal/flag_logger/multi_test.go | 23 ++++ .../go/confidence/provider_builder.go | 10 +- .../go/confidence/provider_init_telemetry.go | 39 ------ .../provider_init_telemetry_test.go | 57 -------- .../confidence/provider_telemetry_resolver.go | 104 +++++++++++++++ .../provider_telemetry_resolver_test.go | 109 +++++++++++++++ .../confidence/sdk/GrpcWasmFlagLogger.java | 3 +- .../sdk/OpenFeatureLocalResolveProvider.java | 56 ++++---- .../sdk/ProviderInitTelemetrySink.java | 52 -------- .../sdk/ProviderTelemetryResolver.java | 105 +++++++++++++++ .../sdk/GrpcWasmFlagLoggerTest.java | 16 +++ .../sdk/ProviderInitTelemetrySinkTest.java | 74 ----------- .../sdk/ProviderTelemetryResolverTest.java | 124 ++++++++++++++++++ 15 files changed, 521 insertions(+), 255 deletions(-) delete mode 100644 openfeature-provider/go/confidence/provider_init_telemetry.go delete mode 100644 openfeature-provider/go/confidence/provider_init_telemetry_test.go create mode 100644 openfeature-provider/go/confidence/provider_telemetry_resolver.go create mode 100644 openfeature-provider/go/confidence/provider_telemetry_resolver_test.go delete mode 100644 openfeature-provider/java/src/main/java/com/spotify/confidence/sdk/ProviderInitTelemetrySink.java create mode 100644 openfeature-provider/java/src/main/java/com/spotify/confidence/sdk/ProviderTelemetryResolver.java delete mode 100644 openfeature-provider/java/src/test/java/com/spotify/confidence/sdk/ProviderInitTelemetrySinkTest.java create mode 100644 openfeature-provider/java/src/test/java/com/spotify/confidence/sdk/ProviderTelemetryResolverTest.java diff --git a/openfeature-provider/go/confidence/internal/flag_logger/grpc.go b/openfeature-provider/go/confidence/internal/flag_logger/grpc.go index 0edabcdfe..b2949a148 100644 --- a/openfeature-provider/go/confidence/internal/flag_logger/grpc.go +++ b/openfeature-provider/go/confidence/internal/flag_logger/grpc.go @@ -35,7 +35,7 @@ func (g *GrpcFlagLogger) Write(request *resolverv1.WriteFlagLogsRequest) { clientResolveCount := len(request.ClientResolveInfo) flagResolveCount := len(request.FlagResolveInfo) - if clientResolveCount == 0 && flagAssignedCount == 0 && flagResolveCount == 0 { + if clientResolveCount == 0 && flagAssignedCount == 0 && flagResolveCount == 0 && request.TelemetryData == nil { g.logger.Debug("Skipping empty flag log request") return } diff --git a/openfeature-provider/go/confidence/internal/flag_logger/multi.go b/openfeature-provider/go/confidence/internal/flag_logger/multi.go index 09152bf75..27afb18ca 100644 --- a/openfeature-provider/go/confidence/internal/flag_logger/multi.go +++ b/openfeature-provider/go/confidence/internal/flag_logger/multi.go @@ -73,7 +73,7 @@ func (m *MultiDestinationFlagLogger) Write(request *resolverv1.WriteFlagLogsRequ clientResolveCount := len(request.ClientResolveInfo) flagResolveCount := len(request.FlagResolveInfo) - if clientResolveCount == 0 && flagAssignedCount == 0 && flagResolveCount == 0 { + if clientResolveCount == 0 && flagAssignedCount == 0 && flagResolveCount == 0 && request.TelemetryData == nil { m.logger.Debug("Skipping empty flag log request") return } diff --git a/openfeature-provider/go/confidence/internal/flag_logger/multi_test.go b/openfeature-provider/go/confidence/internal/flag_logger/multi_test.go index 789690b0e..6ac3c871f 100644 --- a/openfeature-provider/go/confidence/internal/flag_logger/multi_test.go +++ b/openfeature-provider/go/confidence/internal/flag_logger/multi_test.go @@ -173,6 +173,29 @@ func TestMultiDestinationFlagLogger_SkipsEmptyRequest(t *testing.T) { } } +func TestMultiDestinationFlagLogger_SendsTelemetryOnlyRequest(t *testing.T) { + var called atomic.Int32 + logger := &MultiDestinationFlagLogger{ + senders: map[admin.LogDestination]logSender{ + admin.LogDestination_LOG_DESTINATION_SPOTIFY_EDGE: func(context.Context, *resolverv1.WriteFlagLogsRequest) error { + called.Add(1) + return nil + }, + }, + destinations: func() []admin.LogDestination { + return []admin.LogDestination{admin.LogDestination_LOG_DESTINATION_SPOTIFY_EDGE} + }, + logger: slog.New(slog.NewTextHandler(&bytes.Buffer{}, nil)), + } + + logger.Write(&resolverv1.WriteFlagLogsRequest{TelemetryData: &resolverv1.TelemetryData{}}) + logger.Shutdown() + + if called.Load() != 1 { + t.Fatalf("expected telemetry-only request to be sent once, got %d", called.Load()) + } +} + func TestMultiDestinationFlagLogger_AllDestinationsFail(t *testing.T) { var buf bytes.Buffer testLogger := slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelWarn})) diff --git a/openfeature-provider/go/confidence/provider_builder.go b/openfeature-provider/go/confidence/provider_builder.go index 266035ab9..29d631cc8 100644 --- a/openfeature-provider/go/confidence/provider_builder.go +++ b/openfeature-provider/go/confidence/provider_builder.go @@ -149,10 +149,12 @@ func newLocalResolverSupplier(poolSize int, useWasmInterpreter bool, initLabels UseWasmInterpreter: useWasmInterpreter, } return func(ctx context.Context, logSink lr.LogSink) lr.LocalResolver { - return lr.NewLocalResolver( - ctx, - newProviderInitLogSink(logSink, initLabels), - cfg, + return newProviderTelemetryResolver( + logSink, + initLabels, + func(providerLogSink lr.LogSink) lr.LocalResolver { + return lr.NewLocalResolver(ctx, providerLogSink, cfg) + }, ) } } diff --git a/openfeature-provider/go/confidence/provider_init_telemetry.go b/openfeature-provider/go/confidence/provider_init_telemetry.go deleted file mode 100644 index d1eeecd4f..000000000 --- a/openfeature-provider/go/confidence/provider_init_telemetry.go +++ /dev/null @@ -1,39 +0,0 @@ -package confidence - -import ( - "sync" - - lr "github.com/spotify/confidence-resolver/openfeature-provider/go/confidence/internal/local_resolver" - resolvertypes "github.com/spotify/confidence-resolver/openfeature-provider/go/confidence/internal/proto/resolver" - resolverv1 "github.com/spotify/confidence-resolver/openfeature-provider/go/confidence/internal/proto/resolverinternal" -) - -// newProviderInitLogSink owns provider-init telemetry above the resolver pool. All pooled and -// recovered WASM instances share this sink, so one provider emits exactly one init event. -func newProviderInitLogSink(delegate lr.LogSink, labels map[string]string) lr.LogSink { - var mu sync.Mutex - sent := false - sdk := &resolvertypes.Sdk{ - Sdk: &resolvertypes.Sdk_Id{Id: resolvertypes.SdkId_SDK_ID_GO_LOCAL_PROVIDER}, - Version: Version, - } - - return func(logs *resolverv1.WriteFlagLogsRequest) { - mu.Lock() - defer mu.Unlock() - - if !sent { - if logs.TelemetryData == nil { - logs.TelemetryData = &resolverv1.TelemetryData{} - } - logs.TelemetryData.Sdk = sdk - logs.TelemetryData.ProviderInitRate = append( - logs.TelemetryData.ProviderInitRate, - &resolverv1.TelemetryData_ProviderInitRate{Count: 1, Labels: labels}, - ) - } - - delegate(logs) - sent = true - } -} diff --git a/openfeature-provider/go/confidence/provider_init_telemetry_test.go b/openfeature-provider/go/confidence/provider_init_telemetry_test.go deleted file mode 100644 index 6cf316a85..000000000 --- a/openfeature-provider/go/confidence/provider_init_telemetry_test.go +++ /dev/null @@ -1,57 +0,0 @@ -package confidence - -import ( - "testing" - - resolvertypes "github.com/spotify/confidence-resolver/openfeature-provider/go/confidence/internal/proto/resolver" - resolverv1 "github.com/spotify/confidence-resolver/openfeature-provider/go/confidence/internal/proto/resolverinternal" -) - -func TestProviderInitLogSinkEmitsOnceAcrossResolvers(t *testing.T) { - var captured []*resolverv1.WriteFlagLogsRequest - sink := newProviderInitLogSink( - func(logs *resolverv1.WriteFlagLogsRequest) { captured = append(captured, logs) }, - map[string]string{"encryption": "true"}, - ) - - sink(&resolverv1.WriteFlagLogsRequest{}) - sink(&resolverv1.WriteFlagLogsRequest{}) - sink(&resolverv1.WriteFlagLogsRequest{}) - - initEvents := 0 - for _, request := range captured { - initEvents += len(request.GetTelemetryData().GetProviderInitRate()) - } - if initEvents != 1 { - t.Fatalf("expected one provider init event, got %d", initEvents) - } - telemetry := captured[0].GetTelemetryData() - if telemetry.GetSdk().GetId() != resolvertypes.SdkId_SDK_ID_GO_LOCAL_PROVIDER { - t.Fatalf("unexpected SDK ID: %v", telemetry.GetSdk().GetId()) - } - if telemetry.GetSdk().GetVersion() != Version { - t.Fatalf("unexpected SDK version: %q", telemetry.GetSdk().GetVersion()) - } -} - -func TestProviderInitLogSinkRetriesAfterDelegatePanic(t *testing.T) { - attempts := 0 - var retried *resolverv1.WriteFlagLogsRequest - sink := newProviderInitLogSink(func(logs *resolverv1.WriteFlagLogsRequest) { - attempts++ - if attempts == 1 { - panic("send failed") - } - retried = logs - }, nil) - - func() { - defer func() { _ = recover() }() - sink(&resolverv1.WriteFlagLogsRequest{}) - }() - sink(&resolverv1.WriteFlagLogsRequest{}) - - if got := len(retried.GetTelemetryData().GetProviderInitRate()); got != 1 { - t.Fatalf("expected init telemetry to be retried, got %d events", got) - } -} diff --git a/openfeature-provider/go/confidence/provider_telemetry_resolver.go b/openfeature-provider/go/confidence/provider_telemetry_resolver.go new file mode 100644 index 000000000..f7782653c --- /dev/null +++ b/openfeature-provider/go/confidence/provider_telemetry_resolver.go @@ -0,0 +1,104 @@ +package confidence + +import ( + "context" + "sync" + + lr "github.com/spotify/confidence-resolver/openfeature-provider/go/confidence/internal/local_resolver" + resolvertypes "github.com/spotify/confidence-resolver/openfeature-provider/go/confidence/internal/proto/resolver" + resolverv1 "github.com/spotify/confidence-resolver/openfeature-provider/go/confidence/internal/proto/resolverinternal" + "github.com/spotify/confidence-resolver/openfeature-provider/go/confidence/internal/proto/wasm" +) + +// providerTelemetryResolver owns provider-scoped telemetry above pooling and recovery. The inner +// resolver stack is constructed with writeLogs, so every pooled WASM instance shares one init +// state. Close forces an init-only request when no earlier full flush produced one. +type providerTelemetryResolver struct { + delegate lr.LocalResolver + logSink lr.LogSink + labels map[string]string + sdk *resolvertypes.Sdk + + mu sync.Mutex + initSent bool +} + +func newProviderTelemetryResolver( + logSink lr.LogSink, + labels map[string]string, + innerFactory func(lr.LogSink) lr.LocalResolver, +) lr.LocalResolver { + r := &providerTelemetryResolver{ + logSink: logSink, + labels: labels, + sdk: &resolvertypes.Sdk{ + Sdk: &resolvertypes.Sdk_Id{Id: resolvertypes.SdkId_SDK_ID_GO_LOCAL_PROVIDER}, + Version: Version, + }, + } + r.delegate = innerFactory(r.writeLogs) + return r +} + +func (r *providerTelemetryResolver) writeLogs(logs *resolverv1.WriteFlagLogsRequest) { + r.mu.Lock() + defer r.mu.Unlock() + + if !r.initSent { + r.addInitTelemetry(logs) + } + r.logSink(logs) + r.initSent = true +} + +func (r *providerTelemetryResolver) emitInitIfPending() { + r.mu.Lock() + defer r.mu.Unlock() + + if r.initSent { + return + } + logs := &resolverv1.WriteFlagLogsRequest{} + r.addInitTelemetry(logs) + r.logSink(logs) + r.initSent = true +} + +func (r *providerTelemetryResolver) addInitTelemetry(logs *resolverv1.WriteFlagLogsRequest) { + if logs.TelemetryData == nil { + logs.TelemetryData = &resolverv1.TelemetryData{} + } + logs.TelemetryData.Sdk = r.sdk + logs.TelemetryData.ProviderInitRate = append( + logs.TelemetryData.ProviderInitRate, + &resolverv1.TelemetryData_ProviderInitRate{Count: 1, Labels: r.labels}, + ) +} + +func (r *providerTelemetryResolver) SetResolverState(request *wasm.SetResolverStateRequest) error { + return r.delegate.SetResolverState(request) +} + +func (r *providerTelemetryResolver) ResolveProcess(request *wasm.ResolveProcessRequest) (*wasm.ResolveProcessResponse, error) { + return r.delegate.ResolveProcess(request) +} + +func (r *providerTelemetryResolver) RegisterResolve(request *wasm.RegisterResolveRequest) { + r.delegate.RegisterResolve(request) +} + +func (r *providerTelemetryResolver) ApplyFlags(request *resolvertypes.ApplyFlagsRequest) error { + return r.delegate.ApplyFlags(request) +} + +func (r *providerTelemetryResolver) FlushAllLogs() error { return r.delegate.FlushAllLogs() } +func (r *providerTelemetryResolver) FlushAssignLogs() error { return r.delegate.FlushAssignLogs() } +func (r *providerTelemetryResolver) PrometheusSnapshot(bucketsPerDecade uint32, openmetrics bool) string { + return r.delegate.PrometheusSnapshot(bucketsPerDecade, openmetrics) +} + +func (r *providerTelemetryResolver) Close(ctx context.Context) error { + err := r.delegate.Close(ctx) + r.emitInitIfPending() + return err +} diff --git a/openfeature-provider/go/confidence/provider_telemetry_resolver_test.go b/openfeature-provider/go/confidence/provider_telemetry_resolver_test.go new file mode 100644 index 000000000..82fe5d1f0 --- /dev/null +++ b/openfeature-provider/go/confidence/provider_telemetry_resolver_test.go @@ -0,0 +1,109 @@ +package confidence + +import ( + "context" + "testing" + + lr "github.com/spotify/confidence-resolver/openfeature-provider/go/confidence/internal/local_resolver" + resolvertypes "github.com/spotify/confidence-resolver/openfeature-provider/go/confidence/internal/proto/resolver" + resolverv1 "github.com/spotify/confidence-resolver/openfeature-provider/go/confidence/internal/proto/resolverinternal" + "github.com/spotify/confidence-resolver/openfeature-provider/go/confidence/internal/proto/wasm" +) + +func TestProviderTelemetryResolverEmitsOnceAcrossPooledFlushes(t *testing.T) { + var captured []*resolverv1.WriteFlagLogsRequest + resolver := newProviderTelemetryResolver( + func(logs *resolverv1.WriteFlagLogsRequest) { captured = append(captured, logs) }, + map[string]string{"encryption": "true"}, + func(sink lr.LogSink) lr.LocalResolver { return &telemetryTestResolver{sink: sink, flushCount: 3} }, + ) + + if err := resolver.FlushAllLogs(); err != nil { + t.Fatal(err) + } + + if got := providerInitEventCount(captured); got != 1 { + t.Fatalf("expected one provider init event across pooled flushes, got %d", got) + } + telemetry := captured[0].GetTelemetryData() + if telemetry.GetSdk().GetId() != resolvertypes.SdkId_SDK_ID_GO_LOCAL_PROVIDER { + t.Fatalf("unexpected SDK ID: %v", telemetry.GetSdk().GetId()) + } + if telemetry.GetSdk().GetVersion() != Version { + t.Fatalf("unexpected SDK version: %q", telemetry.GetSdk().GetVersion()) + } +} + +func TestProviderTelemetryResolverCloseEmitsWithoutResolve(t *testing.T) { + var captured []*resolverv1.WriteFlagLogsRequest + resolver := newProviderTelemetryResolver( + func(logs *resolverv1.WriteFlagLogsRequest) { captured = append(captured, logs) }, + map[string]string{"encryption": "true"}, + func(sink lr.LogSink) lr.LocalResolver { return &telemetryTestResolver{sink: sink} }, + ) + + if err := resolver.Close(context.Background()); err != nil { + t.Fatal(err) + } + + if got := providerInitEventCount(captured); got != 1 { + t.Fatalf("expected shutdown to emit one provider init event, got %d", got) + } +} + +func TestProviderTelemetryResolverRetriesAfterSinkFailure(t *testing.T) { + attempts := 0 + var captured []*resolverv1.WriteFlagLogsRequest + resolver := newProviderTelemetryResolver( + func(logs *resolverv1.WriteFlagLogsRequest) { + attempts++ + if attempts == 1 { + panic("send failed") + } + captured = append(captured, logs) + }, + nil, + func(sink lr.LogSink) lr.LocalResolver { return &telemetryTestResolver{sink: sink, flushCount: 1} }, + ) + + func() { + defer func() { _ = recover() }() + _ = resolver.FlushAllLogs() + }() + if err := resolver.FlushAllLogs(); err != nil { + t.Fatal(err) + } + + if got := providerInitEventCount(captured); got != 1 { + t.Fatalf("expected init telemetry to be retried, got %d events", got) + } +} + +func providerInitEventCount(requests []*resolverv1.WriteFlagLogsRequest) int { + count := 0 + for _, request := range requests { + count += len(request.GetTelemetryData().GetProviderInitRate()) + } + return count +} + +type telemetryTestResolver struct { + sink lr.LogSink + flushCount int +} + +func (r *telemetryTestResolver) SetResolverState(*wasm.SetResolverStateRequest) error { return nil } +func (r *telemetryTestResolver) ResolveProcess(*wasm.ResolveProcessRequest) (*wasm.ResolveProcessResponse, error) { + return &wasm.ResolveProcessResponse{}, nil +} +func (r *telemetryTestResolver) RegisterResolve(*wasm.RegisterResolveRequest) {} +func (r *telemetryTestResolver) ApplyFlags(*resolvertypes.ApplyFlagsRequest) error { return nil } +func (r *telemetryTestResolver) FlushAllLogs() error { + for range r.flushCount { + r.sink(&resolverv1.WriteFlagLogsRequest{}) + } + return nil +} +func (r *telemetryTestResolver) FlushAssignLogs() error { return nil } +func (r *telemetryTestResolver) PrometheusSnapshot(uint32, bool) string { return "" } +func (r *telemetryTestResolver) Close(context.Context) error { return nil } diff --git a/openfeature-provider/java/src/main/java/com/spotify/confidence/sdk/GrpcWasmFlagLogger.java b/openfeature-provider/java/src/main/java/com/spotify/confidence/sdk/GrpcWasmFlagLogger.java index 8c0618361..ad7f7e342 100644 --- a/openfeature-provider/java/src/main/java/com/spotify/confidence/sdk/GrpcWasmFlagLogger.java +++ b/openfeature-provider/java/src/main/java/com/spotify/confidence/sdk/GrpcWasmFlagLogger.java @@ -108,7 +108,8 @@ private InternalFlagLoggerServiceGrpc.InternalFlagLoggerServiceBlockingStub crea public void write(WriteFlagLogsRequest request) { if (request.getClientResolveInfoList().isEmpty() && request.getFlagAssignedList().isEmpty() - && request.getFlagResolveInfoList().isEmpty()) { + && request.getFlagResolveInfoList().isEmpty() + && !request.hasTelemetryData()) { logger.debug("Skipping empty flag log request"); return; } diff --git a/openfeature-provider/java/src/main/java/com/spotify/confidence/sdk/OpenFeatureLocalResolveProvider.java b/openfeature-provider/java/src/main/java/com/spotify/confidence/sdk/OpenFeatureLocalResolveProvider.java index 20c9b06e4..e0c75587b 100644 --- a/openfeature-provider/java/src/main/java/com/spotify/confidence/sdk/OpenFeatureLocalResolveProvider.java +++ b/openfeature-provider/java/src/main/java/com/spotify/confidence/sdk/OpenFeatureLocalResolveProvider.java @@ -55,7 +55,6 @@ public class OpenFeatureLocalResolveProvider implements FeatureProvider { org.slf4j.LoggerFactory.getLogger(OpenFeatureLocalResolveProvider.class); private final LocalResolver resolver; private final WasmFlagLogger flagLogger; - private final ProviderInitTelemetrySink providerInitTelemetrySink; private final MaterializationStore materializationStore; private final boolean disableExposureCollection; private static final Duration ASSIGN_LOG_FLUSH_INTERVAL = Duration.ofMillis(100); @@ -166,20 +165,23 @@ public OpenFeatureLocalResolveProvider( this.flagLogger = wasmFlagLogger; final Map initLabels = Map.of("encryption", String.valueOf(config.getEncryptionKey() != null)); - this.providerInitTelemetrySink = - new ProviderInitTelemetrySink(flagLogger::write, SDK, initLabels); final int numInstances = PooledResolver.getNumInstances(config.getResolverPoolSize()); - final LocalResolver inner = - new PooledResolver( - numInstances, - () -> - new RecoveringResolver( + final LocalResolver telemetryResolver = + new ProviderTelemetryResolver( + flagLogger::write, + SDK, + initLabels, + providerLogSink -> + new PooledResolver( + numInstances, () -> - new WasmLocalResolver( - providerInitTelemetrySink, - config.isEnableApplyDedup(), - config.isDisableExposureCollection()))); - this.resolver = new MaterializingResolver(inner, materializationStore); + new RecoveringResolver( + () -> + new WasmLocalResolver( + providerLogSink, + config.isEnableApplyDedup(), + config.isDisableExposureCollection())))); + this.resolver = new MaterializingResolver(telemetryResolver, materializationStore); } /** @@ -230,19 +232,22 @@ public OpenFeatureLocalResolveProvider( this.flagLogger = wasmFlagLogger; final int numInstances = PooledResolver.getNumInstances(LocalProviderConfig.DEFAULT_RESOLVER_POOL_SIZE); - this.providerInitTelemetrySink = - new ProviderInitTelemetrySink(wasmFlagLogger::write, SDK, Map.of()); - final LocalResolver inner = - new PooledResolver( - numInstances, - () -> - new RecoveringResolver( + final LocalResolver telemetryResolver = + new ProviderTelemetryResolver( + wasmFlagLogger::write, + SDK, + Map.of(), + providerLogSink -> + new PooledResolver( + numInstances, () -> - new WasmLocalResolver( - providerInitTelemetrySink, - enableApplyDedup, - disableExposureCollection))); - this.resolver = new MaterializingResolver(inner, materializationStore); + new RecoveringResolver( + () -> + new WasmLocalResolver( + providerLogSink, + enableApplyDedup, + disableExposureCollection)))); + this.resolver = new MaterializingResolver(telemetryResolver, materializationStore); } @Override @@ -445,7 +450,6 @@ public void shutdown() { // resolver.close() flushes remaining logs via the log sink this.resolver.close(); - this.providerInitTelemetrySink.emitIfPending(); // flagLogger.shutdown() waits for pending async writes to complete this.flagLogger.shutdown(); diff --git a/openfeature-provider/java/src/main/java/com/spotify/confidence/sdk/ProviderInitTelemetrySink.java b/openfeature-provider/java/src/main/java/com/spotify/confidence/sdk/ProviderInitTelemetrySink.java deleted file mode 100644 index 07c33a849..000000000 --- a/openfeature-provider/java/src/main/java/com/spotify/confidence/sdk/ProviderInitTelemetrySink.java +++ /dev/null @@ -1,52 +0,0 @@ -package com.spotify.confidence.sdk; - -import com.spotify.confidence.sdk.flags.resolver.v1.Sdk; -import com.spotify.confidence.sdk.flags.resolver.v1.TelemetryData; -import com.spotify.confidence.sdk.flags.resolver.v1.WriteFlagLogsRequest; -import java.util.Map; -import java.util.function.Consumer; - -/** Adds provider-init telemetry once, above the resolver pool and recovery layers. */ -final class ProviderInitTelemetrySink implements Consumer { - private final Consumer delegate; - private final Sdk sdk; - private final Map labels; - private boolean sent; - - ProviderInitTelemetrySink( - Consumer delegate, Sdk sdk, Map labels) { - this.delegate = delegate; - this.sdk = sdk; - this.labels = Map.copyOf(labels); - } - - @Override - public synchronized void accept(WriteFlagLogsRequest request) { - final WriteFlagLogsRequest outgoing; - if (sent) { - outgoing = request; - } else { - outgoing = - request.toBuilder() - .setTelemetryData( - request.getTelemetryData().toBuilder() - .setSdk(sdk) - .addProviderInitRate( - TelemetryData.ProviderInitRate.newBuilder() - .setCount(1) - .putAllLabels(labels) - .build()) - .build()) - .build(); - } - - delegate.accept(outgoing); - sent = true; - } - - synchronized void emitIfPending() { - if (!sent) { - accept(WriteFlagLogsRequest.getDefaultInstance()); - } - } -} diff --git a/openfeature-provider/java/src/main/java/com/spotify/confidence/sdk/ProviderTelemetryResolver.java b/openfeature-provider/java/src/main/java/com/spotify/confidence/sdk/ProviderTelemetryResolver.java new file mode 100644 index 000000000..cf9dbc80d --- /dev/null +++ b/openfeature-provider/java/src/main/java/com/spotify/confidence/sdk/ProviderTelemetryResolver.java @@ -0,0 +1,105 @@ +package com.spotify.confidence.sdk; + +import com.spotify.confidence.sdk.flags.resolver.v1.ApplyFlagsRequest; +import com.spotify.confidence.sdk.flags.resolver.v1.RegisterResolveRequest; +import com.spotify.confidence.sdk.flags.resolver.v1.ResolveProcessRequest; +import com.spotify.confidence.sdk.flags.resolver.v1.ResolveProcessResponse; +import com.spotify.confidence.sdk.flags.resolver.v1.Sdk; +import com.spotify.confidence.sdk.flags.resolver.v1.TelemetryData; +import com.spotify.confidence.sdk.flags.resolver.v1.WriteFlagLogsRequest; +import java.util.Map; +import java.util.concurrent.CompletionStage; +import java.util.function.Consumer; +import java.util.function.Function; + +/** Owns provider-scoped telemetry above the resolver pool and recovery layers. */ +final class ProviderTelemetryResolver implements LocalResolver { + private final Consumer logSink; + private final Sdk sdk; + private final Map labels; + private final LocalResolver delegate; + private boolean initSent; + + ProviderTelemetryResolver( + Consumer logSink, + Sdk sdk, + Map labels, + Function, LocalResolver> innerFactory) { + this.logSink = logSink; + this.sdk = sdk; + this.labels = Map.copyOf(labels); + this.delegate = innerFactory.apply(this::writeLogs); + } + + private synchronized void writeLogs(WriteFlagLogsRequest request) { + final WriteFlagLogsRequest outgoing = initSent ? request : addInitTelemetry(request); + logSink.accept(outgoing); + initSent = true; + } + + private synchronized void emitInitIfPending() { + if (initSent) { + return; + } + logSink.accept(addInitTelemetry(WriteFlagLogsRequest.getDefaultInstance())); + initSent = true; + } + + private WriteFlagLogsRequest addInitTelemetry(WriteFlagLogsRequest request) { + return request.toBuilder() + .setTelemetryData( + request.getTelemetryData().toBuilder() + .setSdk(sdk) + .addProviderInitRate( + TelemetryData.ProviderInitRate.newBuilder() + .setCount(1) + .putAllLabels(labels) + .build()) + .build()) + .build(); + } + + @Override + public void setResolverState(byte[] state, String accountId, Sdk sdk) { + delegate.setResolverState(state, accountId, sdk); + } + + @Override + public CompletionStage resolveProcess(ResolveProcessRequest request) { + return delegate.resolveProcess(request); + } + + @Override + public void applyFlags(ApplyFlagsRequest request) { + delegate.applyFlags(request); + } + + @Override + public void registerResolve(RegisterResolveRequest request) { + delegate.registerResolve(request); + } + + @Override + public void flushAllLogs() { + delegate.flushAllLogs(); + } + + @Override + public void flushAssignLogs() { + delegate.flushAssignLogs(); + } + + @Override + public void close() { + try { + delegate.close(); + } finally { + emitInitIfPending(); + } + } + + @Override + public String prometheusSnapshot() { + return delegate.prometheusSnapshot(); + } +} diff --git a/openfeature-provider/java/src/test/java/com/spotify/confidence/sdk/GrpcWasmFlagLoggerTest.java b/openfeature-provider/java/src/test/java/com/spotify/confidence/sdk/GrpcWasmFlagLoggerTest.java index 4cd294302..bbfef2b72 100644 --- a/openfeature-provider/java/src/test/java/com/spotify/confidence/sdk/GrpcWasmFlagLoggerTest.java +++ b/openfeature-provider/java/src/test/java/com/spotify/confidence/sdk/GrpcWasmFlagLoggerTest.java @@ -18,6 +18,7 @@ import java.util.concurrent.CountDownLatch; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; @@ -39,6 +40,21 @@ void testEmptyRequest_shouldSkip() { logger.shutdown(); } + @Test + void testTelemetryOnlyRequest_shouldSend() { + final var capturedRequest = new AtomicReference(); + final var logger = new GrpcWasmFlagLogger("test-client-secret", capturedRequest::set); + final var request = + WriteFlagLogsRequest.newBuilder() + .setTelemetryData(TelemetryData.getDefaultInstance()) + .build(); + + logger.write(request); + logger.shutdown(); + + assertEquals(request, capturedRequest.get()); + } + @Test void testSmallRequest_shouldSendAsIs() { // Given diff --git a/openfeature-provider/java/src/test/java/com/spotify/confidence/sdk/ProviderInitTelemetrySinkTest.java b/openfeature-provider/java/src/test/java/com/spotify/confidence/sdk/ProviderInitTelemetrySinkTest.java deleted file mode 100644 index b59dc5c1a..000000000 --- a/openfeature-provider/java/src/test/java/com/spotify/confidence/sdk/ProviderInitTelemetrySinkTest.java +++ /dev/null @@ -1,74 +0,0 @@ -package com.spotify.confidence.sdk; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; - -import com.spotify.confidence.sdk.flags.resolver.v1.Sdk; -import com.spotify.confidence.sdk.flags.resolver.v1.SdkId; -import com.spotify.confidence.sdk.flags.resolver.v1.WriteFlagLogsRequest; -import java.util.ArrayList; -import java.util.Map; -import java.util.concurrent.atomic.AtomicInteger; -import org.junit.jupiter.api.Test; - -class ProviderInitTelemetrySinkTest { - private static final Sdk SDK = - Sdk.newBuilder().setId(SdkId.SDK_ID_JAVA_LOCAL_PROVIDER).setVersion("test-version").build(); - - @Test - void emitsOnceAcrossPooledResolvers() { - final var captured = new ArrayList(); - final var sink = - new ProviderInitTelemetrySink(captured::add, SDK, Map.of("encryption", "true")); - - sink.accept(WriteFlagLogsRequest.getDefaultInstance()); - sink.accept(WriteFlagLogsRequest.getDefaultInstance()); - sink.accept(WriteFlagLogsRequest.getDefaultInstance()); - - assertThat( - captured.stream() - .mapToInt(request -> request.getTelemetryData().getProviderInitRateCount()) - .sum()) - .isEqualTo(1); - final var telemetry = captured.get(0).getTelemetryData(); - assertThat(telemetry.getSdk()).isEqualTo(SDK); - assertThat(telemetry.getProviderInitRate(0).getLabelsMap()).containsEntry("encryption", "true"); - } - - @Test - void retriesAfterDelegateFailure() { - final var attempts = new AtomicInteger(); - final var captured = new ArrayList(); - final var sink = - new ProviderInitTelemetrySink( - request -> { - if (attempts.getAndIncrement() == 0) { - throw new IllegalStateException("send failed"); - } - captured.add(request); - }, - SDK, - Map.of()); - - assertThatThrownBy(() -> sink.accept(WriteFlagLogsRequest.getDefaultInstance())) - .isInstanceOf(IllegalStateException.class); - sink.accept(WriteFlagLogsRequest.getDefaultInstance()); - - assertThat(captured).hasSize(1); - assertThat(captured.get(0).getTelemetryData().getProviderInitRateCount()).isEqualTo(1); - } - - @Test - void shutdownBeforePeriodicFlushStillEmitsInit() { - final var captured = new ArrayList(); - final var sink = new ProviderInitTelemetrySink(captured::add, SDK, Map.of()); - - sink.emitIfPending(); - - assertThat( - captured.stream() - .mapToInt(request -> request.getTelemetryData().getProviderInitRateCount()) - .sum()) - .isEqualTo(1); - } -} diff --git a/openfeature-provider/java/src/test/java/com/spotify/confidence/sdk/ProviderTelemetryResolverTest.java b/openfeature-provider/java/src/test/java/com/spotify/confidence/sdk/ProviderTelemetryResolverTest.java new file mode 100644 index 000000000..7513a4d87 --- /dev/null +++ b/openfeature-provider/java/src/test/java/com/spotify/confidence/sdk/ProviderTelemetryResolverTest.java @@ -0,0 +1,124 @@ +package com.spotify.confidence.sdk; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.spotify.confidence.sdk.flags.resolver.v1.ApplyFlagsRequest; +import com.spotify.confidence.sdk.flags.resolver.v1.RegisterResolveRequest; +import com.spotify.confidence.sdk.flags.resolver.v1.ResolveProcessRequest; +import com.spotify.confidence.sdk.flags.resolver.v1.ResolveProcessResponse; +import com.spotify.confidence.sdk.flags.resolver.v1.Sdk; +import com.spotify.confidence.sdk.flags.resolver.v1.SdkId; +import com.spotify.confidence.sdk.flags.resolver.v1.WriteFlagLogsRequest; +import java.util.ArrayList; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Consumer; +import org.junit.jupiter.api.Test; + +class ProviderTelemetryResolverTest { + private static final Sdk SDK = + Sdk.newBuilder().setId(SdkId.SDK_ID_JAVA_LOCAL_PROVIDER).setVersion("test-version").build(); + + @Test + void emitsOnceAcrossPooledFlushes() { + final var captured = new ArrayList(); + final var resolver = + new ProviderTelemetryResolver( + captured::add, + SDK, + Map.of("encryption", "true"), + sink -> new TelemetryTestResolver(sink, 3)); + + resolver.flushAllLogs(); + + assertThat(providerInitEventCount(captured)).isEqualTo(1); + final var telemetry = captured.get(0).getTelemetryData(); + assertThat(telemetry.getSdk()).isEqualTo(SDK); + assertThat(telemetry.getProviderInitRate(0).getLabelsMap()).containsEntry("encryption", "true"); + } + + @Test + void closeEmitsWithoutResolve() { + final var captured = new ArrayList(); + final var resolver = + new ProviderTelemetryResolver( + captured::add, SDK, Map.of(), sink -> new TelemetryTestResolver(sink, 0)); + + resolver.close(); + + assertThat(providerInitEventCount(captured)).isEqualTo(1); + } + + @Test + void retriesAfterSinkFailure() { + final var attempts = new AtomicInteger(); + final var captured = new ArrayList(); + final var resolver = + new ProviderTelemetryResolver( + request -> { + if (attempts.getAndIncrement() == 0) { + throw new IllegalStateException("send failed"); + } + captured.add(request); + }, + SDK, + Map.of(), + sink -> new TelemetryTestResolver(sink, 1)); + + assertThatThrownBy(resolver::flushAllLogs).isInstanceOf(IllegalStateException.class); + resolver.flushAllLogs(); + + assertThat(providerInitEventCount(captured)).isEqualTo(1); + } + + private static int providerInitEventCount(ArrayList requests) { + return requests.stream() + .mapToInt(request -> request.getTelemetryData().getProviderInitRateCount()) + .sum(); + } + + private static final class TelemetryTestResolver implements LocalResolver { + private final Consumer sink; + private final int flushCount; + + private TelemetryTestResolver(Consumer sink, int flushCount) { + this.sink = sink; + this.flushCount = flushCount; + } + + @Override + public void setResolverState(byte[] state, String accountId, Sdk sdk) {} + + @Override + public CompletionStage resolveProcess(ResolveProcessRequest request) { + return CompletableFuture.completedFuture(ResolveProcessResponse.getDefaultInstance()); + } + + @Override + public void applyFlags(ApplyFlagsRequest request) {} + + @Override + public void registerResolve(RegisterResolveRequest request) {} + + @Override + public void flushAllLogs() { + for (int i = 0; i < flushCount; i++) { + sink.accept(WriteFlagLogsRequest.getDefaultInstance()); + } + } + + @Override + public void flushAssignLogs() {} + + @Override + public void close() {} + + @Override + public String prometheusSnapshot() { + return ""; + } + } +} From 1fb8cade75cd59ef43122c9d0df9d430f369a3eb Mon Sep 17 00:00:00 2001 From: Nicklas Lundin Date: Wed, 19 Aug 2026 15:31:01 +0200 Subject: [PATCH 6/9] test: use current JS provider version in telemetry assertion Co-Authored-By: Codex --- .../js/src/ConfidenceServerProviderLocal.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/openfeature-provider/js/src/ConfidenceServerProviderLocal.test.ts b/openfeature-provider/js/src/ConfidenceServerProviderLocal.test.ts index 1a4ce323c..db832ded0 100644 --- a/openfeature-provider/js/src/ConfidenceServerProviderLocal.test.ts +++ b/openfeature-provider/js/src/ConfidenceServerProviderLocal.test.ts @@ -10,6 +10,7 @@ import { advanceTimersUntil, NetworkMock } from './test-helpers'; import { sha256Hex } from './hash'; import { ResolveReason } from './proto/confidence/flags/resolver/v1/types'; import { WriteFlagLogsRequest } from './proto/test-only'; +import { VERSION } from './version'; vi.mock(import('./hash'), async () => { const { sha256Hex } = await import('./test-helpers'); @@ -174,7 +175,7 @@ describe('flush behavior', () => { expect(sentBody).toBeDefined(); const decoded = WriteFlagLogsRequest.decode(sentBody!); expect(decoded.telemetryData?.resolverVersion).toBe('0.20.0'); - expect(decoded.telemetryData?.sdk).toEqual({ id: 22, customId: undefined, version: '0.17.0' }); + expect(decoded.telemetryData?.sdk).toEqual({ id: 22, customId: undefined, version: VERSION }); expect(decoded.telemetryData?.providerInitRate).toEqual([{ count: 1, labels: { encryption: 'false' } }]); }); From 89fb845927c5922fabf05d593fbc4909cee1fe8e Mon Sep 17 00:00:00 2001 From: Nicklas Lundin Date: Fri, 21 Aug 2026 13:53:31 +0200 Subject: [PATCH 7/9] chore: sync WASM and provider formatting --- .../assets/confidence_resolver.wasm | Bin 499212 -> 500786 bytes .../python/src/confidence/provider.py | 1 + 2 files changed, 1 insertion(+) diff --git a/openfeature-provider/go/confidence/internal/local_resolver/assets/confidence_resolver.wasm b/openfeature-provider/go/confidence/internal/local_resolver/assets/confidence_resolver.wasm index 7e8ae7076ad998f1277503828be5ba8bf881fe8d..ae9ebde1864391a5d7be740adc3a9a1979f32659 100755 GIT binary patch delta 63496 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 None: if include_init: with self._init_telemetry_lock: self._init_telemetry_state = "sent" + def _create_flag_logger( self, account_id: str, log_destinations: List[int] ) -> FlagLogger: From e94778fe3862e0da40e765d442374b5fa5ccff9b Mon Sep 17 00:00:00 2001 From: Nicklas Lundin Date: Mon, 24 Aug 2026 14:48:48 +0200 Subject: [PATCH 8/9] fix(js): preserve provider telemetry metadata --- .../js/src/ConfidenceServerProviderLocal.test.ts | 15 ++++++++++++--- .../js/src/ConfidenceServerProviderLocal.ts | 4 ++-- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/openfeature-provider/js/src/ConfidenceServerProviderLocal.test.ts b/openfeature-provider/js/src/ConfidenceServerProviderLocal.test.ts index db832ded0..ff1d1ea27 100644 --- a/openfeature-provider/js/src/ConfidenceServerProviderLocal.test.ts +++ b/openfeature-provider/js/src/ConfidenceServerProviderLocal.test.ts @@ -158,7 +158,7 @@ describe('state update scheduling', () => { }); describe('flush behavior', () => { - it('preserves resolver version when adding provider init telemetry', async () => { + it('preserves existing telemetry and sets provider SDK metadata when adding provider init telemetry', async () => { let sentBody: Uint8Array | undefined; net.resolver.flagLogs.handler = async (req: Request) => { sentBody = new Uint8Array(await req.arrayBuffer()); @@ -166,7 +166,13 @@ describe('flush behavior', () => { }; mockedWasmResolver.flushLogs.mockReturnValueOnce( WriteFlagLogsRequest.encode( - WriteFlagLogsRequest.create({ telemetryData: { resolverVersion: '0.20.0' } }), + WriteFlagLogsRequest.create({ + telemetryData: { + resolverVersion: '0.20.0', + sdk: { id: 25, version: 'resolver-version' }, + providerInitRate: [{ count: 2, labels: { existing: 'true' } }], + }, + }), ).finish(), ); @@ -176,7 +182,10 @@ describe('flush behavior', () => { const decoded = WriteFlagLogsRequest.decode(sentBody!); expect(decoded.telemetryData?.resolverVersion).toBe('0.20.0'); expect(decoded.telemetryData?.sdk).toEqual({ id: 22, customId: undefined, version: VERSION }); - expect(decoded.telemetryData?.providerInitRate).toEqual([{ count: 1, labels: { encryption: 'false' } }]); + expect(decoded.telemetryData?.providerInitRate).toEqual([ + { count: 2, labels: { existing: 'true' } }, + { count: 1, labels: { encryption: 'false' } }, + ]); }); it('retries provider init telemetry after a failed send', async () => { diff --git a/openfeature-provider/js/src/ConfidenceServerProviderLocal.ts b/openfeature-provider/js/src/ConfidenceServerProviderLocal.ts index e70f972b1..9a10727d4 100644 --- a/openfeature-provider/js/src/ConfidenceServerProviderLocal.ts +++ b/openfeature-provider/js/src/ConfidenceServerProviderLocal.ts @@ -387,11 +387,11 @@ export class ConfidenceServerProviderLocal implements Provider { if (!decoded.telemetryData) { decoded.telemetryData = { resolverVersion: '', providerInitRate: [] }; } - decoded.telemetryData.sdk ??= { + decoded.telemetryData.sdk = { id: SdkId.SDK_ID_JS_LOCAL_SERVER_PROVIDER, version: VERSION, }; - decoded.telemetryData!.providerInitRate = [{ count: 1, labels: this.initLabels }]; + decoded.telemetryData.providerInitRate.push({ count: 1, labels: this.initLabels }); writeFlagLogRequest = WriteFlagLogsRequest.encode(decoded).finish(); } try { From e78779e9f6ed8c6e353f94a11a3cdfe6a81c5569 Mon Sep 17 00:00:00 2001 From: Nicklas Lundin Date: Mon, 24 Aug 2026 14:49:54 +0200 Subject: [PATCH 9/9] fix(js): emit provider init telemetry on close --- .../src/ConfidenceServerProviderLocal.test.ts | 29 ++++++++++-- .../js/src/ConfidenceServerProviderLocal.ts | 46 +++++++++++++------ 2 files changed, 58 insertions(+), 17 deletions(-) diff --git a/openfeature-provider/js/src/ConfidenceServerProviderLocal.test.ts b/openfeature-provider/js/src/ConfidenceServerProviderLocal.test.ts index ff1d1ea27..1244ef9db 100644 --- a/openfeature-provider/js/src/ConfidenceServerProviderLocal.test.ts +++ b/openfeature-provider/js/src/ConfidenceServerProviderLocal.test.ts @@ -188,7 +188,7 @@ describe('flush behavior', () => { ]); }); - it('retries provider init telemetry after a failed send', async () => { + it('does not retry provider init telemetry after an HTTP failure', async () => { const sentBodies: Uint8Array[] = []; let attempts = 0; net.resolver.flagLogs.handler = async (req: Request) => { @@ -202,13 +202,13 @@ describe('flush behavior', () => { ).finish(), ); - await advanceTimersUntil(expect(provider.flush()).rejects.toThrow('Failed to send flag logs')); + await advanceTimersUntil(provider.flush()); await advanceTimersUntil(provider.flush()); const firstAttempt = WriteFlagLogsRequest.decode(sentBodies[0]); const retryAttempt = WriteFlagLogsRequest.decode(sentBodies[3]); expect(firstAttempt.telemetryData?.providerInitRate).toHaveLength(1); - expect(retryAttempt.telemetryData?.providerInitRate).toHaveLength(1); + expect(retryAttempt.telemetryData?.providerInitRate).toHaveLength(0); }); it('flushes periodically at the configured interval', async () => { @@ -230,7 +230,7 @@ describe('flush behavior', () => { net.resolver.flagLogs.status = 503; const start = net.resolver.flagLogs.calls; - await advanceTimersUntil(expect(provider.flush()).rejects.toThrow('Failed to send flag logs')); + await advanceTimersUntil(provider.flush()); const attempts = net.resolver.flagLogs.calls - start; expect(attempts).toBe(3); @@ -245,6 +245,27 @@ describe('flush behavior', () => { expect(net.resolver.flagLogs.calls).toBe(start + 1); }); + it('emits provider init telemetry on close when there are no resolver logs', async () => { + let sentBody: Uint8Array | undefined; + net.resolver.flagLogs.handler = async (req: Request) => { + sentBody = new Uint8Array(await req.arrayBuffer()); + return new Response(null, { status: 200 }); + }; + mockedWasmResolver.flushLogs.mockReturnValueOnce(new Uint8Array(0)); + + await advanceTimersUntil(expect(provider.onClose()).resolves.toBeUndefined()); + + expect(sentBody).toBeDefined(); + const decoded = WriteFlagLogsRequest.decode(sentBody!); + expect(decoded.telemetryData?.sdk).toEqual({ id: 22, customId: undefined, version: VERSION }); + expect(decoded.telemetryData?.providerInitRate).toEqual([{ count: 1, labels: { encryption: 'false' } }]); + }); + it('keeps close best-effort when provider init telemetry cannot be sent', async () => { + mockedWasmResolver.flushLogs.mockReturnValueOnce(new Uint8Array(0)); + net.resolver.flagLogs.status = 'No network'; + + await advanceTimersUntil(expect(provider.onClose()).resolves.toBeUndefined()); + }); it('skips flush if there are no logs to send', async () => { await advanceTimersUntil(expect(provider.initialize()).resolves.toBeUndefined()); diff --git a/openfeature-provider/js/src/ConfidenceServerProviderLocal.ts b/openfeature-provider/js/src/ConfidenceServerProviderLocal.ts index 9a10727d4..6daf14e1c 100644 --- a/openfeature-provider/js/src/ConfidenceServerProviderLocal.ts +++ b/openfeature-provider/js/src/ConfidenceServerProviderLocal.ts @@ -198,8 +198,25 @@ export class ConfidenceServerProviderLocal implements Provider { } async onClose(): Promise { - await this.flush(timeoutSignal(3000)); - this.main.abort(); + const signal = timeoutSignal(3000); + try { + try { + await this.flush(signal); + } catch { + // best-effort: try an init-only request below + } + if (this.initTelemetryState !== 'sent') { + try { + const request = this.addProviderInitTelemetry(new Uint8Array()); + await this.sendFlagLogs(request, signal); + this.initTelemetryState = 'sent'; + } catch { + // best-effort: provider is shutting down + } + } + } finally { + this.main.abort(); + } } async resolve(context: EvaluationContext, flagNames: string[], apply = false): Promise { @@ -383,16 +400,7 @@ export class ConfidenceServerProviderLocal implements Provider { const includeInit = this.initTelemetryState === 'pending'; if (includeInit) { this.initTelemetryState = 'sending'; - const decoded = WriteFlagLogsRequest.decode(writeFlagLogRequest); - if (!decoded.telemetryData) { - decoded.telemetryData = { resolverVersion: '', providerInitRate: [] }; - } - decoded.telemetryData.sdk = { - id: SdkId.SDK_ID_JS_LOCAL_SERVER_PROVIDER, - version: VERSION, - }; - decoded.telemetryData.providerInitRate.push({ count: 1, labels: this.initLabels }); - writeFlagLogRequest = WriteFlagLogsRequest.encode(decoded).finish(); + writeFlagLogRequest = this.addProviderInitTelemetry(writeFlagLogRequest); } try { await this.sendFlagLogs(writeFlagLogRequest, signal); @@ -429,7 +437,6 @@ export class ConfidenceServerProviderLocal implements Provider { logger.warn('Primary flag log destination returned error, trying fallback'); continue; } - throw new Error('Failed to send flag logs to all destinations'); } catch (err) { if (!isLast) { logger.warn('Primary flag log destination failed, trying fallback', err); @@ -442,6 +449,19 @@ export class ConfidenceServerProviderLocal implements Provider { } } + private addProviderInitTelemetry(encodedWriteFlagLogRequest: Uint8Array): Uint8Array { + const request = WriteFlagLogsRequest.decode(encodedWriteFlagLogRequest); + if (!request.telemetryData) { + request.telemetryData = { resolverVersion: '', providerInitRate: [] }; + } + request.telemetryData.sdk = { + id: SdkId.SDK_ID_JS_LOCAL_SERVER_PROVIDER, + version: VERSION, + }; + request.telemetryData.providerInitRate.push({ count: 1, labels: this.initLabels }); + return WriteFlagLogsRequest.encode(request).finish(); + } + /** * Send flag logs to a specific destination. Returns true on success (HTTP 2xx), * false on a non-OK HTTP response. Throws on network errors.